first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run

This commit is contained in:
Zakaria
2026-05-04 14:58:14 -04:00
commit a46764fb1b
1210 changed files with 233231 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
interface Props {
id: string;
size?: number;
className?: string;
}
interface Visual {
bg: string;
fg: string;
glyph: (size: number) => JSX.Element;
}
function star4(size: number, color: string) {
// Sparkle / 4-point star — used for Claude.
const s = size;
const c = s / 2;
const r = s * 0.36;
const t = s * 0.08;
return (
<path
d={`M ${c} ${c - r} C ${c} ${c - t}, ${c + t} ${c}, ${c + r} ${c} C ${c + t} ${c}, ${c} ${c + t}, ${c} ${c + r} C ${c} ${c + t}, ${c - t} ${c}, ${c - r} ${c} C ${c - t} ${c}, ${c} ${c - t}, ${c} ${c - r} Z`}
fill={color}
/>
);
}
const VISUALS: Record<string, Visual> = {
// Claude — warm Anthropic terracotta with sparkle.
claude: {
bg: 'linear-gradient(135deg, #d97757 0%, #b85a3b 100%)',
fg: '#fff7ef',
glyph: (s) => star4(s, '#fff7ef'),
},
// Codex — OpenAI signature dark green knot.
codex: {
bg: 'linear-gradient(135deg, #1a1a1a 0%, #303030 100%)',
fg: '#10a37f',
glyph: (s) => {
const c = s / 2;
const r = s * 0.32;
return (
<g
transform={`rotate(15 ${c} ${c})`}
stroke="#10a37f"
strokeWidth={s * 0.07}
fill="none"
strokeLinecap="round"
>
<ellipse cx={c} cy={c} rx={r} ry={r * 0.45} />
<ellipse
cx={c}
cy={c}
rx={r}
ry={r * 0.45}
transform={`rotate(60 ${c} ${c})`}
/>
<ellipse
cx={c}
cy={c}
rx={r}
ry={r * 0.45}
transform={`rotate(120 ${c} ${c})`}
/>
</g>
);
},
},
// Gemini — Google blue/purple with diamond spark.
gemini: {
bg: 'linear-gradient(135deg, #4285f4 0%, #9b72cb 60%, #d96570 100%)',
fg: '#ffffff',
glyph: (s) => star4(s, '#ffffff'),
},
// OpenCode — terminal green angle brackets.
opencode: {
bg: 'linear-gradient(135deg, #064e3b 0%, #0f766e 100%)',
fg: '#a7f3d0',
glyph: (s) => {
const c = s / 2;
const off = s * 0.16;
const arm = s * 0.12;
return (
<g
stroke="#a7f3d0"
strokeWidth={s * 0.08}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points={`${c - off + arm},${c - arm} ${c - off},${c} ${c - off + arm},${c + arm}`} />
<polyline points={`${c + off - arm},${c - arm} ${c + off},${c} ${c + off - arm},${c + arm}`} />
</g>
);
},
},
// Cursor — clean black with a cursor arrow.
'cursor-agent': {
bg: 'linear-gradient(135deg, #18181b 0%, #3f3f46 100%)',
fg: '#ffffff',
glyph: (s) => {
const c = s / 2;
const o = s * 0.22;
return (
<path
d={`M ${c - o} ${c - o} L ${c + o * 0.9} ${c} L ${c} ${c + o * 0.2} L ${c - o * 0.05} ${c + o * 0.85} Z`}
fill="#ffffff"
/>
);
},
},
// GitHub Copilot — GitHub-dark with the Copilot two-eye mark.
copilot: {
bg: 'linear-gradient(135deg, #0d1117 0%, #1f2937 100%)',
fg: '#ffffff',
glyph: (s) => {
const c = s / 2;
const eyeOff = s * 0.14;
const eyeRx = s * 0.075;
const eyeRy = s * 0.12;
return (
<g fill="#ffffff">
<ellipse cx={c - eyeOff} cy={c} rx={eyeRx} ry={eyeRy} />
<ellipse cx={c + eyeOff} cy={c} rx={eyeRx} ry={eyeRy} />
</g>
);
},
},
// Qwen — Alibaba indigo with stylized Q.
qwen: {
bg: 'linear-gradient(135deg, #615ced 0%, #8b5cf6 100%)',
fg: '#ffffff',
glyph: (s) => {
const c = s / 2;
const r = s * 0.26;
return (
<g fill="none" stroke="#ffffff" strokeWidth={s * 0.07} strokeLinecap="round">
<circle cx={c} cy={c} r={r} />
<line x1={c + r * 0.45} y1={c + r * 0.45} x2={c + r * 0.95} y2={c + r * 0.95} />
</g>
);
},
},
// DeepSeek — DeepSeek-blue with abstract whale-tail / wave glyph.
deepseek: {
bg: 'linear-gradient(135deg, #4d6bfe 0%, #1f3fce 100%)',
fg: '#ffffff',
glyph: (s) => {
const c = s / 2;
const r = s * 0.3;
return (
<g fill="none" stroke="#ffffff" strokeWidth={s * 0.08} strokeLinecap="round" strokeLinejoin="round">
<path d={`M ${c - r} ${c + r * 0.3} Q ${c - r * 0.4} ${c - r * 0.6}, ${c} ${c - r * 0.1} T ${c + r} ${c + r * 0.3}`} />
<path d={`M ${c - r * 0.6} ${c + r * 0.7} Q ${c} ${c + r * 0.2}, ${c + r * 0.6} ${c + r * 0.7}`} />
</g>
);
},
},
// MiMo — Xiaomi orange with "Mi" stylized mark.
mimo: {
bg: 'linear-gradient(135deg, #FF6900 0%, #FF4D00 100%)',
fg: '#ffffff',
glyph: (s) => {
const c = s / 2;
const r = s * 0.22;
return (
<g fill="none" stroke="#ffffff" strokeWidth={s * 0.06} strokeLinecap="round" strokeLinejoin="round">
{/* Stylized "Mi" — three vertical bars */}
<line x1={c - r * 0.9} y1={c - r * 0.6} x2={c - r * 0.9} y2={c + r * 0.8} />
<line x1={c - r * 0.1} y1={c - r * 0.6} x2={c - r * 0.1} y2={c + r * 0.8} />
<line x1={c + r * 0.9} y1={c - r * 0.6} x2={c + r * 0.9} y2={c + r * 0.8} />
{/* Connecting roof */}
<polyline points={`${c - r * 0.9},${c - r * 0.6} ${c - r * 0.1},${c - r * 1.2} ${c + r * 0.9},${c - r * 0.6}`} />
</g>
);
},
},
};
const FALLBACK: Visual = {
bg: 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)',
fg: '#ffffff',
glyph: (s) => {
const c = s / 2;
const r = s * 0.18;
return <circle cx={c} cy={c} r={r} fill="#ffffff" />;
},
};
export function AgentIcon({ id, size = 36, className }: Props) {
const v = VISUALS[id] ?? FALLBACK;
return (
<span
className={'agent-icon' + (className ? ' ' + className : '')}
style={{
width: size,
height: size,
background: v.bg,
borderRadius: Math.round(size * 0.28),
}}
aria-hidden="true"
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill={v.fg}>
{v.glyph(size)}
</svg>
</span>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useT } from '../i18n';
import type { AgentInfo, ExecMode } from '../types';
interface Props {
mode: ExecMode;
agents: AgentInfo[];
agentId: string | null;
daemonLive: boolean;
onModeChange: (mode: ExecMode) => void;
onAgentChange: (id: string) => void;
onRefresh: () => void;
}
export function AgentPicker({
mode,
agents,
agentId,
daemonLive,
onModeChange,
onAgentChange,
onRefresh,
}: Props) {
const t = useT();
const available = agents.filter((a) => a.available);
const currentAgent = agents.find((a) => a.id === agentId);
return (
<div className="picker agent-picker">
<span className="picker-label">{t('agentPicker.label')}</span>
<select
value={mode}
onChange={(e) => onModeChange(e.target.value as ExecMode)}
title={t('agentPicker.modeChoose')}
>
<option value="daemon" disabled={!daemonLive}>
{t('agentPicker.localCli')} {daemonLive ? '' : `· ${t('agentPicker.daemonOff')}`}
</option>
<option value="api">{t('agentPicker.byok')}</option>
</select>
{mode === 'daemon' ? (
<>
<select
value={agentId ?? ''}
onChange={(e) => onAgentChange(e.target.value)}
disabled={available.length === 0}
title={
currentAgent?.version
? `${currentAgent.name} · ${currentAgent.version}`
: t('agentPicker.selectAgent')
}
>
{available.length === 0 ? (
<option value="">{t('agentPicker.noAgents')}</option>
) : null}
{agents.map((a) => (
<option key={a.id} value={a.id} disabled={!a.available}>
{a.name}
{a.available ? '' : ` · ${t('agentPicker.notInstalled')}`}
</option>
))}
</select>
<button
onClick={onRefresh}
title={t('agentPicker.rescan')}
className="icon-btn"
>
</button>
</>
) : null}
</div>
);
}
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react';
import { useT } from '../i18n';
import { Icon } from './Icon';
interface Props {
actions?: ReactNode;
children?: ReactNode;
onBack?: () => void;
backLabel?: string;
}
export function AppChromeHeader({ actions, children, onBack, backLabel }: Props) {
const t = useT();
const resolvedBackLabel = backLabel ?? t('project.backToProjects');
return (
<header className="app-chrome-header">
<div className="app-chrome-traffic-space" aria-hidden />
<div className="app-chrome-brand" aria-label={t('app.brand')}>
<span className="app-chrome-mark" aria-hidden>
{/* decorative, parent has aria-label */}
<img src="/app-icon.svg" alt="" className="brand-mark-img" draggable={false} />
</span>
<span className="app-chrome-name">{t('app.brand')}</span>
</div>
{onBack ? (
<button
type="button"
className="app-chrome-back"
onClick={onBack}
title={resolvedBackLabel}
aria-label={resolvedBackLabel}
>
<Icon name="arrow-left" size={15} />
</button>
) : null}
{children ? <div className="app-chrome-content">{children}</div> : null}
<div className="app-chrome-drag" aria-hidden />
{actions ? <div className="app-chrome-actions">{actions}</div> : null}
</header>
);
}
export function SettingsIconButton({
onClick,
title,
ariaLabel,
}: {
onClick: () => void;
title: string;
ariaLabel: string;
}) {
return (
<button
type="button"
className="settings-icon-btn"
onClick={onClick}
title={title}
aria-label={ariaLabel}
>
<Icon name="settings" size={17} />
</button>
);
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { assistantRoleLabel } from './AssistantMessage';
import type { ChatMessage } from '../types';
const t = () => 'Assistant';
describe('assistantRoleLabel', () => {
it('prefers the persisted assistant display name over the protocol id', () => {
const message: ChatMessage = {
id: 'message-1',
role: 'assistant',
content: '',
agentId: 'openai-api',
agentName: 'OpenAI API · google/gemma-4-e4b',
};
expect(assistantRoleLabel(message, t)).toBe('OpenAI API · google/gemma-4-e4b');
});
it('maps API protocol ids to readable labels when no display name is saved', () => {
const message: ChatMessage = {
id: 'message-2',
role: 'assistant',
content: '',
agentId: 'openai-api',
};
expect(assistantRoleLabel(message, t)).toBe('OpenAI API');
});
it('normalizes saved API protocol ids used as display names', () => {
const message: ChatMessage = {
id: 'message-3',
role: 'assistant',
content: '',
agentName: 'openai-api',
};
expect(assistantRoleLabel(message, t)).toBe('OpenAI API');
});
it('preserves an explicit local agent model in the display name', () => {
const message: ChatMessage = {
id: 'message-4',
role: 'assistant',
content: '',
agentId: 'claude',
agentName: 'Claude · claude-sonnet-4-6',
};
expect(assistantRoleLabel(message, t)).toBe('Claude · claude-sonnet-4-6');
});
it('adds the model reported by a local CLI initializing event', () => {
const message: ChatMessage = {
id: 'message-5',
role: 'assistant',
content: '',
agentId: 'claude',
agentName: 'Claude',
events: [{ kind: 'status', label: 'initializing', detail: 'claude-sonnet-4-6' }],
};
expect(assistantRoleLabel(message, t)).toBe('Claude · claude-sonnet-4-6');
});
});
@@ -0,0 +1,802 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { ToolCard } from './ToolCard';
import { renderMarkdown } from '../runtime/markdown';
import { projectFileUrl } from '../providers/registry';
import { splitOnQuestionForms, type QuestionForm } from '../artifacts/question-form';
import { QuestionFormView, parseSubmittedAnswers } from './QuestionForm';
import { Icon } from './Icon';
import { useT } from '../i18n';
import { unfinishedTodosFromEvents, type TodoItem } from '../runtime/todos';
import type { Dict } from '../i18n/types';
import { agentDisplayName, exactAgentDisplayName } from '../utils/agentLabels';
import { exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
import type { AgentEvent, ChatMessage, ProjectFile } from '../types';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
interface Props {
message: ChatMessage;
streaming: boolean;
projectId: string | null;
projectFileNames?: Set<string>;
onRequestOpenFile?: (name: string) => void;
// True only for the most recent assistant message — gate question-form
// interactivity on this so older forms render as a locked "answered"
// capsule instead of being re-submittable.
isLast?: boolean;
// The user message that immediately follows this assistant turn (if
// any). Used to detect that a form was already answered so we can
// render its locked state with the user's picks visible.
nextUserContent?: string;
// Submit handler the form fires when the user picks answers — opaque
// to AssistantMessage; ProjectView wires it into onSend.
onSubmitForm?: (text: string) => void;
onContinueRemainingTasks?: (todos: TodoItem[]) => void;
}
/**
* Renders an assistant message as an interleaved flow of:
* - prose blocks (consecutive `text` events merged)
* - thinking blocks (collapsible)
* - grouped tool action cards — runs of consecutive same-name tools
* collapse into a single pill ("Editing ×3, Done") that expands to show
* the individual tool cards. Mirrors the chat surface in screenshot 9.
* - status pills
*/
export function AssistantMessage({
message,
streaming,
projectId,
projectFileNames,
onRequestOpenFile,
isLast,
nextUserContent,
onSubmitForm,
onContinueRemainingTasks,
}: Props) {
const t = useT();
const events = message.events ?? [];
const blocks = buildBlocks(events);
const usage = events.find((e) => e.kind === 'usage') as
| Extract<AgentEvent, { kind: 'usage' }>
| undefined;
const produced = message.producedFiles ?? [];
const roleLabel = assistantRoleLabel(message, t);
const unfinishedTodos = streaming ? [] : unfinishedTodosFromEvents(events);
const canContinueTodos =
!streaming && !!isLast && unfinishedTodos.length > 0 && !!onContinueRemainingTasks;
// Track which forms the user submitted in this session so we lock them
// immediately on click (without waiting for the parent to re-render).
const [locallySubmitted, setLocallySubmitted] = useState<Set<string>>(() => new Set());
return (
<div className="msg assistant">
<div className="role">
<span>{roleLabel}</span>
<MessageTimestamp message={message} t={t} />
</div>
<div className="assistant-flow">
{blocks.length === 0 && streaming ? (
<WaitingPill startedAt={message.startedAt} latestStatus={latestStatusLabel(events)} />
) : null}
{blocks.map((b, i) => {
if (b.kind === 'text')
return (
<ProseBlock
key={i}
text={b.text}
isLastAssistant={!!isLast}
streaming={streaming}
nextUserContent={nextUserContent}
locallySubmitted={locallySubmitted}
onSubmitForm={(formId, text) => {
setLocallySubmitted((prev) => {
const next = new Set(prev);
next.add(formId);
return next;
});
onSubmitForm?.(text);
}}
/>
);
if (b.kind === 'thinking') return <ThinkingBlock key={i} text={b.text} />;
if (b.kind === 'tool-group') {
return (
<ToolGroupCard
key={i}
items={b.items}
runStreaming={streaming}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
/>
);
}
if (b.kind === 'status') return <StatusPill key={i} label={b.label} detail={b.detail} />;
return null;
})}
{!streaming && produced.length > 0 && projectId ? (
<ProducedFiles
files={produced}
projectId={projectId}
onRequestOpenFile={onRequestOpenFile}
/>
) : null}
{!streaming && unfinishedTodos.length > 0 ? (
<UnfinishedTodosPanel
todos={unfinishedTodos}
canContinue={canContinueTodos}
onContinue={() => onContinueRemainingTasks?.(unfinishedTodos)}
/>
) : null}
<AssistantFooter
streaming={streaming}
startedAt={message.startedAt}
endedAt={message.endedAt}
usage={usage}
hasUnfinishedTodos={unfinishedTodos.length > 0}
/>
</div>
</div>
);
}
function MessageTimestamp({ message, t }: { message: ChatMessage; t: TranslateFn }) {
const ts = messageTime(message);
if (!ts) return null;
return (
<time className="msg-time" dateTime={new Date(ts).toISOString()} title={exactDateTime(ts)}>
{relativeTimeLong(ts, t)}
</time>
);
}
export function assistantRoleLabel(message: ChatMessage, t: TranslateFn): string {
const model = assistantModelDetail(message);
const fromName = message.agentName?.trim();
if (fromName) return appendRoleModel(exactAgentDisplayName(fromName) ?? fromName, model);
const fromId = agentDisplayName(message.agentId);
if (fromId) return appendRoleModel(fromId, model);
const starting = message.events?.find(
(e) => e.kind === 'status' && e.label === 'starting' && e.detail,
) as Extract<AgentEvent, { kind: 'status' }> | undefined;
return appendRoleModel(agentDisplayName(starting?.detail) ?? t('assistant.role'), model);
}
function assistantModelDetail(message: ChatMessage): string | null {
const initializing = message.events?.find(
(e) => e.kind === 'status' && e.label === 'initializing' && e.detail,
) as Extract<AgentEvent, { kind: 'status' }> | undefined;
const detail = initializing?.detail?.trim();
if (!detail || detail === 'default') return null;
return detail;
}
function appendRoleModel(label: string, model: string | null): string {
if (!model || label.includes(' · ')) return label;
return `${label} · ${model}`;
}
function AssistantFooter({
streaming,
startedAt,
endedAt,
usage,
hasUnfinishedTodos,
}: {
streaming: boolean;
startedAt: number | undefined;
endedAt: number | undefined;
usage: Extract<AgentEvent, { kind: 'usage' }> | undefined;
hasUnfinishedTodos: boolean;
}) {
const t = useT();
const elapsed = useLiveElapsed(streaming, startedAt, endedAt);
if (!streaming && !elapsed && !usage && !hasUnfinishedTodos) return null;
return (
<div className="assistant-footer" data-unfinished={hasUnfinishedTodos ? 'true' : 'false'}>
<span className="dot" data-active={streaming ? 'true' : 'false'} />
<span className="assistant-label">
{streaming
? t('assistant.workingLabel')
: hasUnfinishedTodos
? t('assistant.unfinishedLabel')
: t('assistant.doneLabel')}
</span>
<span className="assistant-stats">
{elapsed}
{usage?.outputTokens != null
? ` · ${t('assistant.outTokens', { n: usage.outputTokens })}`
: ''}
{typeof usage?.costUsd === 'number'
? ` · $${usage.costUsd.toFixed(4)}`
: ''}
</span>
</div>
);
}
function UnfinishedTodosPanel({
todos,
canContinue,
onContinue,
}: {
todos: TodoItem[];
canContinue: boolean;
onContinue: () => void;
}) {
const t = useT();
const visible = todos.slice(0, 3);
const hiddenCount = todos.length - visible.length;
return (
<div className="unfinished-todos">
<div className="unfinished-todos-head">
<span className="unfinished-todos-title">
{t('assistant.unfinishedSummary', { n: todos.length })}
</span>
{canContinue ? (
<button type="button" className="unfinished-todos-continue" onClick={onContinue}>
{t('assistant.continueRemaining')}
</button>
) : null}
</div>
<ul className="unfinished-todos-list">
{visible.map((todo, i) => (
<li key={`${todo.status}-${todo.content}-${i}`}>
{todo.status === 'in_progress' && todo.activeForm ? todo.activeForm : todo.content}
</li>
))}
</ul>
{hiddenCount > 0 ? (
<div className="unfinished-todos-more">
{t('assistant.unfinishedMore', { n: hiddenCount })}
</div>
) : null}
</div>
);
}
function ProducedFiles({
files,
projectId,
onRequestOpenFile,
}: {
files: ProjectFile[];
projectId: string;
onRequestOpenFile?: (name: string) => void;
}) {
const t = useT();
return (
<div className="produced-files">
<div className="produced-files-label">{t('assistant.producedFiles')}</div>
<div className="produced-files-list">
{files.map((f) => (
<div key={f.name} className="produced-file">
<span className="produced-file-icon" aria-hidden>
<Icon name={kindIconName(f.kind)} size={14} />
</span>
<span className="produced-file-name" title={f.name}>{f.name}</span>
<span className="produced-file-size">{humanBytes(f.size)}</span>
<div className="produced-file-actions">
{onRequestOpenFile ? (
<button
type="button"
className="ghost"
onClick={() => onRequestOpenFile(f.name)}
>
{t('assistant.openFile')}
</button>
) : null}
<a
className="ghost-link"
href={projectFileUrl(projectId, f.name)}
download={f.name}
>
{t('assistant.downloadFile')}
</a>
</div>
</div>
))}
</div>
</div>
);
}
function kindIconName(
kind: ProjectFile['kind'],
): 'file-code' | 'image' | 'pencil' | 'file' {
if (kind === 'html') return 'file-code';
if (kind === 'image') return 'image';
if (kind === 'sketch') return 'pencil';
if (kind === 'code') return 'file-code';
return 'file';
}
function humanBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
/**
* The pre-first-block waiting indicator. Shows "Waiting for first output…"
* normally, the latest status label (initializing / starting / thinking /
* streaming) once we have one, plus a soft hint after ~12 seconds telling
* the user they can stop the run if it really seems stuck.
*/
function WaitingPill({
startedAt,
latestStatus,
}: {
startedAt?: number;
latestStatus?: { label: string; detail?: string | undefined };
}) {
const t = useT();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
const elapsedSec = startedAt ? Math.max(0, Math.round((now - startedAt) / 1000)) : 0;
const slow = elapsedSec >= 12;
const label = latestStatus?.label
? humanizeStatus(latestStatus.label, t)
: t('assistant.waitingFirstOutput');
return (
<div className="op-waiting">
<span className="op-waiting-dot" aria-hidden />
<span className="op-waiting-label">{label}</span>
{latestStatus?.detail ? (
<code className="op-waiting-detail">{latestStatus.detail}</code>
) : null}
{slow ? (
<span className="op-waiting-hint">{t('assistant.slowHint')}</span>
) : null}
</div>
);
}
function humanizeStatus(label: string, t: (k: keyof Dict) => string): string {
if (label === 'initializing') return t('assistant.statusBootingAgent');
if (label === 'starting') return t('assistant.statusStarting');
if (label === 'requesting') return t('assistant.statusRequesting');
if (label === 'thinking') return t('assistant.statusThinking');
if (label === 'streaming') return t('assistant.statusStreaming');
return label.charAt(0).toUpperCase() + label.slice(1);
}
function latestStatusLabel(
events: AgentEvent[],
): { label: string; detail?: string | undefined } | undefined {
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i]!;
if (ev.kind === 'status') return { label: ev.label, detail: ev.detail };
}
return undefined;
}
function ProseBlock({
text,
isLastAssistant,
streaming,
nextUserContent,
locallySubmitted,
onSubmitForm,
}: {
text: string;
isLastAssistant: boolean;
streaming: boolean;
nextUserContent?: string;
locallySubmitted: Set<string>;
onSubmitForm: (formId: string, text: string) => void;
}) {
const cleaned = useMemo(() => stripArtifact(text), [text]);
const segments = useMemo(() => splitOnQuestionForms(cleaned), [cleaned]);
// Each text segment is further split on `<system-reminder>` blocks so
// those render as their own collapsible chip instead of raw markup.
const renderable = segments.flatMap((seg, idx): Array<
| { key: string; kind: 'text'; text: string }
| { key: string; kind: 'reminder'; text: string }
| { key: string; kind: 'form'; form: QuestionForm }
> => {
if (seg.kind === 'form') {
return [{ key: `f-${idx}`, kind: 'form', form: seg.form }];
}
if (seg.text.trim().length === 0) return [];
const sub = splitSystemReminders(seg.text);
return sub.map((s, j) => ({ key: `t-${idx}-${j}`, kind: s.kind, text: s.text }));
});
if (renderable.length === 0) return null;
return (
<div className="prose-block">
{renderable.map((seg) => {
if (seg.kind === 'reminder') {
return <SystemReminderBlock key={seg.key} text={seg.text} />;
}
if (seg.kind === 'text') {
return <Fragment key={seg.key}>{renderMarkdown(seg.text)}</Fragment>;
}
return (
<FormBlock
key={seg.key}
form={seg.form}
isLastAssistant={isLastAssistant}
streaming={streaming}
nextUserContent={nextUserContent}
locallySubmitted={locallySubmitted}
onSubmitForm={onSubmitForm}
/>
);
})}
</div>
);
}
function FormBlock({
form,
isLastAssistant,
streaming,
nextUserContent,
locallySubmitted,
onSubmitForm,
}: {
form: QuestionForm;
isLastAssistant: boolean;
streaming: boolean;
nextUserContent?: string;
locallySubmitted: Set<string>;
onSubmitForm: (formId: string, text: string) => void;
}) {
// Reconstruct prior answers from a follow-up user message so older
// forms in the scrollback render in their answered state.
const submittedFromHistory = useMemo(() => {
if (!nextUserContent) return null;
return parseSubmittedAnswers(form, nextUserContent);
}, [form, nextUserContent]);
const wasSubmittedLocally = locallySubmitted.has(form.id);
const interactive =
isLastAssistant && !streaming && !submittedFromHistory && !wasSubmittedLocally;
return (
<QuestionFormView
form={form}
interactive={interactive}
submittedAnswers={submittedFromHistory ?? undefined}
onSubmit={(text) => onSubmitForm(form.id, text)}
/>
);
}
function SystemReminderBlock({ text }: { text: string }) {
const t = useT();
const [open, setOpen] = useState(false);
const trimmed = text.trim();
const preview = trimmed.split('\n')[0]?.slice(0, 120) ?? '';
return (
<div className="system-reminder-block">
<button
className="system-reminder-toggle"
onClick={() => setOpen((o) => !o)}
type="button"
>
<span className="system-reminder-icon" aria-hidden>
<Icon name="settings" size={12} />
</span>
<span className="system-reminder-label">{t('assistant.systemReminder')}</span>
<span className="system-reminder-preview">
{open ? '' : preview}
{!open && trimmed.length > preview.length ? '…' : ''}
</span>
<span className="system-reminder-chev">
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={11} />
</span>
</button>
{open ? <pre className="system-reminder-body">{trimmed}</pre> : null}
</div>
);
}
function ThinkingBlock({ text }: { text: string }) {
const t = useT();
const [open, setOpen] = useState(false);
const preview = text.trim().slice(0, 140);
return (
<div className="thinking-block">
<button className="thinking-toggle" onClick={() => setOpen((o) => !o)}>
<span className="thinking-icon" aria-hidden>
<Icon name="sparkles" size={12} />
</span>
<span className="thinking-label">{t('assistant.thinking')}</span>
<span className="thinking-preview">{open ? '' : preview}{!open && text.length > 140 ? '…' : ''}</span>
<span className="thinking-chev">
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={11} />
</span>
</button>
{open ? <pre className="thinking-body">{text}</pre> : null}
</div>
);
}
function StatusPill({ label, detail }: { label: string; detail?: string | undefined }) {
return (
<div className="status-pill">
<span className="status-label">{label}</span>
{detail ? <span className="status-detail">{detail}</span> : null}
</div>
);
}
interface ToolItem {
use: Extract<AgentEvent, { kind: 'tool_use' }>;
result?: Extract<AgentEvent, { kind: 'tool_result' }>;
}
function ToolGroupCard({
items,
runStreaming,
projectFileNames,
onRequestOpenFile,
}: {
items: ToolItem[];
runStreaming: boolean;
projectFileNames?: Set<string>;
onRequestOpenFile?: (name: string) => void;
}) {
const t = useT();
const [open, setOpen] = useState(false);
// A run of one tool collapses to that tool's card directly so we don't
// wrap a single child in a redundant disclosure.
if (items.length === 1) {
return (
<ToolCard
use={items[0]!.use}
result={items[0]!.result}
runStreaming={runStreaming}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
/>
);
}
const summary = summarizeGroup(items, t);
const running = items.some((it) => !it.result);
return (
<div className="action-card">
<button
type="button"
className={`action-card-toggle ${running ? 'running' : ''}`}
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
<span className="ico" aria-hidden>{summary.icon}</span>
<span className="summary"><strong>{summary.label}</strong></span>
<span className="chev" aria-hidden>
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={11} />
</span>
</button>
{open ? (
<div className="action-card-body">
{items.map((it, i) => (
<ToolCard
key={i}
use={it.use}
result={it.result}
runStreaming={runStreaming}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
/>
))}
</div>
) : null}
</div>
);
}
function summarizeGroup(
items: ToolItem[],
t: (k: keyof Dict, vars?: Record<string, string | number>) => string,
): { label: string; icon: string } {
// All items share a tool family because the grouper only merges by name.
const name = items[0]?.use.name ?? '';
const family = toolFamily(name);
const icon = familyIcon(family);
const verbs = items.map((it) => verbForState(it, t));
// Roll the verbs into a comma-list with deduplicated last-state. So three
// edits whose results are all 'Done' render as "Editing ×3, Done"; mixed
// states render as "Editing, Reading, Done".
const head = countLabel(family, items.length, t);
const tail = lastStateLabel(verbs, t);
return { label: tail ? `${head}, ${tail}` : head, icon };
}
function toolFamily(name: string): string {
if (name === 'Edit' || name === 'str_replace_edit') return 'edit';
if (name === 'Write' || name === 'create_file') return 'write';
if (name === 'Read' || name === 'read_file') return 'read';
if (name === 'Glob' || name === 'list_files') return 'glob';
if (name === 'Grep') return 'grep';
if (name === 'Bash') return 'bash';
if (name === 'TodoWrite') return 'todo';
if (name === 'WebFetch' || name === 'web_fetch') return 'fetch';
if (name === 'WebSearch' || name === 'web_search') return 'search';
return name.toLowerCase();
}
function familyIcon(family: string): string {
if (family === 'edit') return '✎';
if (family === 'write') return '+';
if (family === 'read') return '↗';
if (family === 'glob' || family === 'grep' || family === 'search') return '⌕';
if (family === 'bash') return '$';
if (family === 'todo') return '☐';
if (family === 'fetch') return '↬';
return '·';
}
function countLabel(
family: string,
n: number,
t: (k: keyof Dict) => string,
): string {
const verb =
family === 'edit'
? t('assistant.verbEditing')
: family === 'write'
? t('assistant.verbWriting')
: family === 'read'
? t('assistant.verbReading')
: family === 'glob' || family === 'grep' || family === 'search'
? t('assistant.verbSearching')
: family === 'bash'
? t('assistant.verbRunning')
: family === 'todo'
? t('assistant.verbTodos')
: family === 'fetch'
? t('assistant.verbFetching')
: t('assistant.verbCalling');
return n > 1 ? `${verb} ×${n}` : verb;
}
function verbForState(
it: ToolItem,
t: (k: keyof Dict) => string,
): string {
if (!it.result) return t('assistant.verbRunning');
if (it.result.isError) return t('tool.error');
return t('tool.done');
}
function lastStateLabel(
verbs: string[],
t: (k: keyof Dict) => string,
): string {
const set = new Set(verbs);
if (set.size === 1) return verbs[verbs.length - 1] ?? '';
// Mixed states: surface error first, else running, else any.
if (set.has(t('tool.error'))) return t('tool.error');
if (set.has(t('assistant.verbRunning'))) return t('assistant.verbRunning');
return verbs[verbs.length - 1] ?? '';
}
type Block =
| { kind: 'text'; text: string }
| { kind: 'thinking'; text: string }
| { kind: 'tool-group'; items: ToolItem[] }
| { kind: 'status'; label: string; detail?: string | undefined };
/**
* Walk the event stream and build the rendering layout list. We additionally
* collapse runs of consecutive tool_uses sharing the same tool family into a
* single tool-group block so the chat surface stays compact during chains
* of edits / reads.
*/
function buildBlocks(events: AgentEvent[]): Block[] {
const out: Block[] = [];
const resultByToolId = new Map<string, Extract<AgentEvent, { kind: 'tool_result' }>>();
for (const ev of events) {
if (ev.kind === 'tool_result') resultByToolId.set(ev.toolUseId, ev);
}
for (const ev of events) {
if (ev.kind === 'text') {
const last = out[out.length - 1];
if (last && last.kind === 'text') last.text += ev.text;
else out.push({ kind: 'text', text: ev.text });
continue;
}
if (ev.kind === 'thinking') {
const last = out[out.length - 1];
if (last && last.kind === 'thinking') last.text += ev.text;
else out.push({ kind: 'thinking', text: ev.text });
continue;
}
if (ev.kind === 'tool_use') {
const result = resultByToolId.get(ev.id);
const item: ToolItem = result ? { use: ev, result } : { use: ev };
const last = out[out.length - 1];
const fam = toolFamily(ev.name);
if (
last &&
last.kind === 'tool-group' &&
toolFamily(last.items[last.items.length - 1]!.use.name) === fam
) {
last.items.push(item);
} else {
out.push({ kind: 'tool-group', items: [item] });
}
continue;
}
if (ev.kind === 'tool_result') continue;
if (ev.kind === 'status') {
if (ev.label === 'streaming' || ev.label === 'starting' || ev.label === 'requesting' || ev.label === 'thinking') continue;
const last = out[out.length - 1];
if (last && last.kind === 'status' && last.label === ev.label) continue;
out.push({ kind: 'status', label: ev.label, detail: ev.detail });
continue;
}
}
return out;
}
function stripArtifact(content: string): string {
const open = content.indexOf('<artifact');
if (open === -1) return content;
const closeTag = content.indexOf('>', open);
const end = content.indexOf('</artifact>', closeTag);
return (
content.slice(0, open) + content.slice(end === -1 ? content.length : end + 11)
).trim();
}
// Split prose into alternating plain-text and `<system-reminder>` segments.
// Claude Code injects `<system-reminder>...</system-reminder>` blocks into the
// agent's input (memory hints, tool reminders, etc.); the model occasionally
// echoes those tags into its response. Rendering the raw markup as prose
// looks broken — surface them as their own collapsible block, and strip stray
// orphan open/close tags from the surrounding text.
type ProseSegment = { kind: 'text' | 'reminder'; text: string };
function splitSystemReminders(input: string): ProseSegment[] {
const re = /<system-reminder>([\s\S]*?)<\/system-reminder>/g;
const out: ProseSegment[] = [];
let lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(input))) {
if (m.index > lastIndex) {
out.push({ kind: 'text', text: input.slice(lastIndex, m.index) });
}
out.push({ kind: 'reminder', text: m[1] ?? '' });
lastIndex = re.lastIndex;
}
if (lastIndex < input.length) {
out.push({ kind: 'text', text: input.slice(lastIndex) });
}
// Drop any orphan tags that survived (open without close, or vice versa)
// and discard text segments that became empty after stripping.
return out
.map((seg) =>
seg.kind === 'text'
? { ...seg, text: seg.text.replace(/<\/?system-reminder>/g, '') }
: seg,
)
.filter((seg) => seg.kind === 'reminder' || seg.text.trim().length > 0);
}
function useLiveElapsed(
streaming: boolean,
startedAt: number | undefined,
endedAt: number | undefined,
): string {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!streaming) return;
const id = window.setInterval(() => setNow(Date.now()), 200);
return () => window.clearInterval(id);
}, [streaming]);
if (!startedAt) return '';
const end = streaming ? now : (endedAt ?? now);
const ms = Math.max(0, end - startedAt);
const s = ms / 1000;
if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`;
const m = Math.floor(s / 60);
const rem = Math.floor(s - m * 60);
return `${m}m ${rem.toString().padStart(2, '0')}s`;
}
+296
View File
@@ -0,0 +1,296 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n';
import { AgentIcon } from './AgentIcon';
import { Icon } from './Icon';
import { renderModelOptions } from './modelOptions';
import type { AgentInfo, AppConfig, ExecMode } from '../types';
import { apiProtocolLabel } from '../utils/apiProtocol';
interface Props {
config: AppConfig;
agents: AgentInfo[];
daemonLive: boolean;
onModeChange: (mode: ExecMode) => void;
onAgentChange: (id: string) => void;
onAgentModelChange: (
id: string,
choice: { model?: string; reasoning?: string },
) => void;
onOpenSettings: () => void;
onRefreshAgents: () => void;
onBack?: () => void;
}
/**
* Compact settings control at the right of the project header. Click opens a dropdown
* with current execution mode, the agent picker (when in daemon mode), and
* a Settings entry — replaces the wide AgentPicker + env-pill row.
*/
export function AvatarMenu({
config,
agents,
daemonLive,
onModeChange,
onAgentChange,
onAgentModelChange,
onOpenSettings,
onRefreshAgents,
onBack,
}: Props) {
const t = useT();
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!wrapRef.current) return;
if (!wrapRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const currentAgent = useMemo(
() => agents.find((a) => a.id === config.agentId) ?? null,
[agents, config.agentId],
);
const installedAgents = agents.filter((a) => a.available);
// Resolve the user's model + reasoning pick for the active agent. Falls
// back to the agent's first declared option (`'default'`) when the user
// hasn't touched the picker yet so the labels don't read as empty.
const currentChoice =
(config.agentId && config.agentModels?.[config.agentId]) || {};
const currentModelId =
currentChoice.model ?? currentAgent?.models?.[0]?.id ?? null;
const currentReasoningId =
currentChoice.reasoning ?? currentAgent?.reasoningOptions?.[0]?.id ?? null;
const currentModelLabel = currentAgent?.models?.find(
(m) => m.id === currentModelId,
)?.label;
return (
<div className="avatar-menu" ref={wrapRef}>
<button
type="button"
className="settings-icon-btn"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
title={t('avatar.title')}
aria-label={t('avatar.title')}
>
<Icon name="settings" size={17} />
</button>
{open ? (
<div className="avatar-popover" role="menu">
<div className="avatar-popover-head">
<span className="who">
{config.mode === 'daemon'
? t('avatar.localCli')
: apiProtocolLabel(config.apiProtocol)}
</span>
<span className="where">
{config.mode === 'api'
? safeHost(config.baseUrl)
: currentAgent
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}${currentModelLabel && currentModelId !== 'default' ? ` · ${currentModelLabel}` : ''}`
: t('avatar.noAgentSelected')}
</span>
</div>
<button
type="button"
className="avatar-item"
onClick={() => {
onModeChange('daemon');
if (!daemonLive) {
// No daemon — let user know via settings page rather than
// silently failing.
setOpen(false);
onOpenSettings();
}
}}
disabled={!daemonLive && config.mode !== 'daemon'}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="file-code" size={14} />
</span>
<span>{t('avatar.useLocal')}</span>
{config.mode === 'daemon' ? (
<span className="avatar-item-meta">{t('avatar.metaActive')}</span>
) : !daemonLive ? (
<span className="avatar-item-meta">{t('avatar.metaOffline')}</span>
) : null}
</button>
<button
type="button"
className="avatar-item"
onClick={() => onModeChange('api')}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="link" size={14} />
</span>
<span>{t('avatar.useApi')}</span>
{config.mode === 'api' ? (
<span className="avatar-item-meta">{t('avatar.metaActive')}</span>
) : null}
</button>
{config.mode === 'daemon' && installedAgents.length > 0 ? (
<>
<div className="avatar-section-label">{t('avatar.codeAgent')}</div>
{installedAgents.map((a) => (
<button
type="button"
key={a.id}
className="avatar-item"
onClick={() => {
onAgentChange(a.id);
// Keep the popover open so the user can immediately
// pick a model for the agent they just chose.
}}
>
<AgentIcon id={a.id} size={18} />
<span>{a.name}</span>
{config.agentId === a.id ? (
<span className="avatar-item-meta">
{t('avatar.metaSelected')}
</span>
) : a.version ? (
<span className="avatar-item-meta">{a.version}</span>
) : null}
</button>
))}
{currentAgent &&
currentAgent.available &&
((currentAgent.models && currentAgent.models.length > 0) ||
(currentAgent.reasoningOptions &&
currentAgent.reasoningOptions.length > 0)) ? (
<div className="avatar-model-section">
<div className="avatar-section-label">
{t('avatar.modelSection')}
</div>
{currentAgent.models && currentAgent.models.length > 0 ? (
<label className="avatar-select-row">
<span className="avatar-select-label">
{t('avatar.modelLabel')}
</span>
<select
className="avatar-select"
value={currentModelId ?? ''}
onChange={(e) =>
onAgentModelChange(currentAgent.id, {
model: e.target.value,
})
}
>
{renderModelOptions(currentAgent.models)}
{/* When the user has typed a custom id in
Settings, surface it here too so the dropdown
actually shows the active selection rather
than collapsing to "Default". */}
{currentModelId &&
!currentAgent.models.some(
(m) => m.id === currentModelId,
) ? (
<option value={currentModelId}>
{currentModelId}{' '}
{t('avatar.customSuffix')}
</option>
) : null}
</select>
</label>
) : null}
{currentAgent.reasoningOptions &&
currentAgent.reasoningOptions.length > 0 ? (
<label className="avatar-select-row">
<span className="avatar-select-label">
{t('avatar.reasoningLabel')}
</span>
<select
className="avatar-select"
value={currentReasoningId ?? ''}
onChange={(e) =>
onAgentModelChange(currentAgent.id, {
reasoning: e.target.value,
})
}
>
{currentAgent.reasoningOptions.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
</label>
) : null}
</div>
) : null}
<button
type="button"
className="avatar-item"
onClick={() => {
onRefreshAgents();
}}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="reload" size={14} />
</span>
<span>{t('avatar.rescan')}</span>
</button>
</>
) : null}
<div style={{ height: 1, background: 'var(--border-soft)', margin: '4px 6px' }} />
<button
type="button"
className="avatar-item"
onClick={() => {
setOpen(false);
onOpenSettings();
}}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="settings" size={14} />
</span>
<span>{t('avatar.settings')}</span>
</button>
{onBack ? (
<button
type="button"
className="avatar-item"
onClick={() => {
setOpen(false);
onBack();
}}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="arrow-left" size={14} />
</span>
<span>{t('avatar.backToProjects')}</span>
</button>
) : null}
</div>
) : null}
</div>
);
}
function safeHost(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
+968
View File
@@ -0,0 +1,968 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { projectRawUrl, uploadProjectFiles } from "../providers/registry";
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile } from "../types";
import { Icon } from "./Icon";
import { BUILT_IN_PETS, CUSTOM_PET_ID, resolveActivePet } from "./pet/pets";
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
interface SlashCommand {
id: string;
// Visible label, e.g. `/hatch`. Shown in the popover row.
label: string;
// Text inserted into the draft when the user picks the entry. The
// cursor is positioned at the end of `insert`, so a trailing space
// is the difference between a "ready for argument" command and a
// "submit immediately" one.
insert: string;
// i18n key of the short description shown next to the label.
descKey: keyof Dict;
// Optional argument hint shown after the description.
argHint?: string;
// Icon glyph from the project Icon set.
icon: 'sparkles' | 'eye' | 'sliders';
}
interface Props {
projectId: string | null;
projectFiles: ProjectFile[];
streaming: boolean;
initialDraft?: string;
// Lazy ensure — the composer calls this before its first upload, so the
// project folder exists on disk before files land in it. Returns the
// project id when ready.
onEnsureProject: () => Promise<string | null>;
commentAttachments?: ChatCommentAttachment[];
onRemoveCommentAttachment?: (id: string) => void;
onSend: (prompt: string, attachments: ChatAttachment[], commentAttachments: ChatCommentAttachment[]) => void;
onStop: () => void;
// Opens the global settings dialog (CLI / model / agent picker). The
// composer's leading gear icon routes here so users can switch models
// without leaving the chat.
onOpenSettings?: () => void;
// Optional pet wiring — when present, the composer renders a small
// 🐾 button + popover so users can adopt / wake / tuck a pet without
// leaving chat. Typing `/pet` (or `/pet wake|tuck|<id>`) is parsed
// out of the draft and routed to the same handlers.
petConfig?: AppConfig['pet'];
onAdoptPet?: (petId: string) => void;
onTogglePet?: () => void;
onOpenPetSettings?: () => void;
}
// Imperative handle so ancestors (e.g. example chips in ChatPane) can
// push text into the composer without owning its draft state.
export interface ChatComposerHandle {
setDraft: (text: string) => void;
focus: () => void;
}
/**
* The chat composer: textarea + paste/drop/attach buttons + @-mention
* picker. Attachments are uploaded into the active project's folder so
* the agent can reference them by relative path on its next turn.
*
* `@` typed at a word boundary opens a popover listing project files.
* Selecting one inserts `@<path>` into the prompt and stages it as an
* attachment so the daemon also includes it explicitly.
*/
export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
function ChatComposer(
{
projectId,
projectFiles,
streaming,
initialDraft,
onEnsureProject,
commentAttachments = [],
onRemoveCommentAttachment,
onSend,
onStop,
onOpenSettings,
petConfig,
onAdoptPet,
onTogglePet,
onOpenPetSettings,
},
ref
) {
const t = useT();
const [draft, setDraft] = useState(initialDraft ?? "");
const [staged, setStaged] = useState<ChatAttachment[]>([]);
const [dragActive, setDragActive] = useState(false);
const [mention, setMention] = useState<{
q: string;
cursor: number;
} | null>(null);
// Slash-command popover state — when the draft starts with `/` and
// the cursor is still inside that token (no space committed yet),
// we show a small palette of supported commands. The query is the
// text after `/` so the user can type-to-filter.
const [slash, setSlash] = useState<{
q: string;
cursor: number;
} | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [petOpen, setPetOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const importMenuRef = useRef<HTMLDivElement | null>(null);
const importTriggerRef = useRef<HTMLButtonElement | null>(null);
const petMenuRef = useRef<HTMLDivElement | null>(null);
const petTriggerRef = useRef<HTMLButtonElement | null>(null);
const petEnabled = Boolean(onAdoptPet && onTogglePet);
// initialDraft is only honored on the first non-empty value the parent
// hands us. After we seed once, the composer is fully under user control
// — re-renders that pass the same prompt back must not reseed. If the
// initial useState above already consumed a non-empty initialDraft we
// mark it seeded immediately, so an early clear by the user (typing or
// backspace before the parent stops passing initialDraft) does not get
// overwritten by the effect.
const seededRef = useRef(Boolean(initialDraft));
useEffect(() => {
if (seededRef.current) return;
if (initialDraft && initialDraft !== draft) {
setDraft(initialDraft);
seededRef.current = true;
} else if (initialDraft === undefined) {
seededRef.current = true;
}
}, [initialDraft, draft]);
useEffect(() => {
if (!importOpen) return;
function onPointer(e: MouseEvent) {
const target = e.target as Node;
if (importMenuRef.current?.contains(target)) return;
if (importTriggerRef.current?.contains(target)) return;
setImportOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setImportOpen(false);
}
document.addEventListener("mousedown", onPointer);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, [importOpen]);
useEffect(() => {
if (!petOpen) return;
function onPointer(e: MouseEvent) {
const target = e.target as Node;
if (petMenuRef.current?.contains(target)) return;
if (petTriggerRef.current?.contains(target)) return;
setPetOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setPetOpen(false);
}
document.addEventListener("mousedown", onPointer);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, [petOpen]);
// Catalog of supported slash commands. Each entry shows up in the
// popover when the user types `/` in the composer. The `insert`
// value is what we drop into the draft when the user picks the
// entry — usually the canonical command form with a trailing space
// ready for an argument.
const slashCommands = useMemo<SlashCommand[]>(() => {
const list: SlashCommand[] = [];
if (petEnabled) {
list.push(
{
id: 'pet',
label: '/pet',
insert: '/pet ',
descKey: 'pet.slashPet',
icon: 'sparkles',
argHint: 'wake | tuck | <petId>',
},
{
id: 'pet-wake',
label: '/pet wake',
insert: '/pet wake',
descKey: 'pet.slashPetWake',
icon: 'eye',
},
{
id: 'pet-tuck',
label: '/pet tuck',
insert: '/pet tuck',
descKey: 'pet.slashPetTuck',
icon: 'eye',
},
{
id: 'hatch',
label: '/hatch',
insert: '/hatch ',
descKey: 'pet.slashHatch',
icon: 'sparkles',
argHint: t('pet.slashHatchArg'),
},
);
}
return list;
}, [petEnabled, t]);
const filteredSlash = useMemo(() => {
if (!slash) return [] as SlashCommand[];
const q = slash.q.toLowerCase();
if (!q) return slashCommands;
return slashCommands.filter((c) => c.label.toLowerCase().includes(q));
}, [slash, slashCommands]);
function pickSlash(cmd: SlashCommand) {
const ta = textareaRef.current;
if (!ta || !slash) return;
const before = draft.slice(0, slash.cursor);
const after = draft.slice(slash.cursor);
// Replace the in-flight `/<query>` token with the picked
// command's canonical insertion text.
const replaced = before.replace(/\/[^\s/]*$/, cmd.insert);
const next = replaced + after;
setDraft(next);
setSlash(null);
requestAnimationFrame(() => {
ta.focus();
const pos = replaced.length;
ta.setSelectionRange(pos, pos);
});
}
// Expand a `/hatch <concept>` draft into the canonical hatch-pet
// skill prompt before sending. Returns null when the draft is not a
// hatch command so the caller can fall through to the regular
// submit path.
function expandHatchCommand(input: string): string | null {
const m = /^\/hatch(?:\s+([\s\S]*))?$/i.exec(input.trim());
if (!m) return null;
const concept = m[1]?.trim() ?? '';
const intro = concept
? `Hatch a Codex-compatible animated pet for me. Concept: ${concept}.`
: 'Hatch a Codex-compatible animated pet for me.';
return [
intro,
'',
'Use the @hatch-pet skill end-to-end:',
'1. Generate the base look with $imagegen.',
'2. Generate every row strip (idle, running-right, waving, jumping, failed, waiting, running, review).',
'3. Mirror running-left from running-right only when the design is symmetric.',
'4. Run the deterministic scripts (extract / compose / validate / contact-sheet / videos).',
'5. Package the result into ${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/ with pet.json + spritesheet.webp.',
'',
'When the spritesheet is saved, tell me the absolute path and the pet folder name. I will adopt it from Settings → Pets → Recently hatched.',
].join('\n');
}
// Parse a `/pet [arg]` slash command out of the draft. Recognized
// forms: `/pet` (toggle wake/tuck), `/pet wake`, `/pet tuck`,
// `/pet adopt` (open settings), or `/pet <id>` to adopt a built-in
// by id. The slash is stripped from the draft on a successful match
// so the user does not accidentally send the command to the agent.
function tryHandlePetSlash(): boolean {
if (!petEnabled) return false;
const trimmed = draft.trim();
const match = /^\/pet(?:\s+(\S+))?$/i.exec(trimmed);
if (!match) return false;
const arg = match[1]?.toLowerCase();
if (!arg || arg === 'toggle') {
onTogglePet?.();
} else if (arg === 'wake' || arg === 'show') {
if (petConfig?.adopted) {
if (!petConfig.enabled) onTogglePet?.();
} else {
onOpenPetSettings?.();
}
} else if (arg === 'tuck' || arg === 'hide') {
if (petConfig?.enabled) onTogglePet?.();
} else if (arg === 'adopt' || arg === 'settings' || arg === 'change') {
onOpenPetSettings?.();
} else if (arg === CUSTOM_PET_ID) {
onAdoptPet?.(CUSTOM_PET_ID);
} else {
const pet = BUILT_IN_PETS.find((p) => p.id === arg);
if (pet) {
onAdoptPet?.(pet.id);
} else {
return false;
}
}
setDraft('');
return true;
}
useImperativeHandle(
ref,
() => ({
setDraft: (text: string) => {
setDraft(text);
seededRef.current = true;
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
const pos = text.length;
ta.setSelectionRange(pos, pos);
});
},
focus: () => {
textareaRef.current?.focus();
},
}),
[]
);
function reset() {
setDraft("");
setStaged([]);
setUploadError(null);
setMention(null);
setSlash(null);
}
async function ensureProject(): Promise<string | null> {
if (projectId) return projectId;
return onEnsureProject();
}
async function uploadFiles(files: File[]) {
if (files.length === 0) return;
const id = await ensureProject();
if (!id) return;
setUploading(true);
setUploadError(null);
try {
const result = await uploadProjectFiles(id, files);
if (result.uploaded.length > 0) {
setStaged((s) => [...s, ...result.uploaded]);
}
if (result.failed.length > 0) {
const failedCount = result.failed.length;
const uploadedCount = result.uploaded.length;
const detail = result.error ? ` (${result.error})` : '';
setUploadError(
uploadedCount > 0
? `Attached ${uploadedCount} file(s), but ${failedCount} failed${detail}.`
: `Attachment upload failed for ${failedCount} file(s)${detail}.`,
);
console.warn('Some attachments failed to upload', result.failed);
}
} finally {
setUploading(false);
}
}
function handlePaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
const items = Array.from(e.clipboardData?.items ?? []);
const files: File[] = [];
for (const item of items) {
if (item.kind === "file") {
const f = item.getAsFile();
if (f) files.push(f);
}
}
if (files.length > 0) {
e.preventDefault();
void uploadFiles(files);
}
}
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragActive(false);
const files = Array.from(e.dataTransfer.files ?? []);
if (files.length > 0) void uploadFiles(files);
}
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const value = e.target.value;
const cursor = e.target.selectionStart;
setDraft(value);
// Detect a fresh @ at start or after whitespace; capture the typed
// query up to the cursor.
const before = value.slice(0, cursor);
const m = /(^|\s)@([^\s@]*)$/.exec(before);
if (m) setMention({ q: m[2] ?? "", cursor });
else setMention(null);
// Slash-command popover — open as soon as the draft starts with
// `/` (and the cursor is still inside the bare command token, no
// space yet). Closes once the user commits a space or moves past
// the prefix.
const slashMatch = /^\/([^\s/]*)$/.exec(before);
if (slashMatch) {
setSlash({ q: slashMatch[1] ?? '', cursor });
setSlashIndex(0);
} else {
setSlash(null);
}
}
function insertMention(filePath: string) {
if (!mention) return;
const ta = textareaRef.current;
if (!ta) return;
const cursor = mention.cursor;
const before = draft.slice(0, cursor);
const after = draft.slice(cursor);
const replaced = before.replace(/@([^\s@]*)$/, `@${filePath} `);
const next = replaced + after;
setDraft(next);
setMention(null);
if (!staged.some((s) => s.path === filePath)) {
setStaged((s) => [
...s,
{
path: filePath,
name: filePath.split("/").pop() || filePath,
kind: looksLikeImage(filePath) ? "image" : "file",
},
]);
}
requestAnimationFrame(() => {
ta.focus();
const pos = replaced.length;
ta.setSelectionRange(pos, pos);
});
}
function removeStaged(p: string) {
setStaged((s) => s.filter((a) => a.path !== p));
}
async function submit() {
const prompt = draft.trim();
// Intercept `/pet …` before sending so the slash command never
// hits the agent — it is a local UX hook, not a model prompt.
if (tryHandlePetSlash()) return;
// `/hatch <concept>` expands into the canonical hatch-pet skill
// prompt and *is* sent to the agent — the agent runs the skill,
// packages a Codex pet under `~/.codex/pets/`, and the user
// adopts it from "Recently hatched" in pet settings afterwards.
const hatched = expandHatchCommand(prompt);
if (hatched) {
if (streaming) return;
onSend(hatched, staged, commentAttachments);
reset();
return;
}
if ((!prompt && commentAttachments.length === 0) || streaming) return;
onSend(prompt, staged, commentAttachments);
reset();
}
// The @-picker treats the project listing as path-shaped (path + size).
// ProjectFile.path is optional, so fall back to .name for the legacy
// flat shape — both ChatComposer and the old code paths see the same
// entries.
const filteredFiles = mention
? projectFiles
.filter((f) => f.type === undefined || f.type === "file")
.filter((f) => {
const key = f.path ?? f.name;
return key.toLowerCase().includes(mention.q.toLowerCase());
})
.slice(0, 12)
: [];
return (
<div
className={`composer${dragActive ? " drag-active" : ""}`}
data-testid="chat-composer"
onDragOver={(e) => {
e.preventDefault();
setDragActive(true);
}}
onDragLeave={() => setDragActive(false)}
onDrop={handleDrop}
>
<div className="composer-shell">
{staged.length > 0 ? (
<StagedAttachments
attachments={staged}
projectId={projectId}
onRemove={removeStaged}
t={t}
/>
) : null}
{commentAttachments.length > 0 ? (
<StagedCommentAttachments
attachments={commentAttachments}
onRemove={(id) => onRemoveCommentAttachment?.(id)}
t={t}
/>
) : null}
<div className="composer-input-wrap">
<textarea
ref={textareaRef}
data-testid="chat-composer-input"
value={draft}
placeholder={t('chat.composerPlaceholder')}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={(e) => {
if (slash && filteredSlash.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSlashIndex((i) => (i + 1) % filteredSlash.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSlashIndex(
(i) => (i - 1 + filteredSlash.length) % filteredSlash.length,
);
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey)) {
e.preventDefault();
const safe = Math.min(slashIndex, filteredSlash.length - 1);
pickSlash(filteredSlash[safe]!);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setSlash(null);
return;
}
}
if (mention && e.key === "Escape") {
setMention(null);
return;
}
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void submit();
}
}}
/>
{mention && filteredFiles.length > 0 ? (
<MentionPopover files={filteredFiles} onPick={insertMention} />
) : null}
{slash && filteredSlash.length > 0 ? (
<SlashPopover
commands={filteredSlash}
activeIndex={Math.min(slashIndex, filteredSlash.length - 1)}
onPick={pickSlash}
onHover={(i) => setSlashIndex(i)}
t={t}
/>
) : null}
</div>
<div className="composer-row">
<input
ref={fileInputRef}
data-testid="chat-file-input"
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
void uploadFiles(files);
e.target.value = "";
}}
/>
<button
className="icon-btn"
onClick={() => onOpenSettings?.()}
title={t('chat.cliSettingsTitle')}
aria-label={t('chat.cliSettingsAria')}
disabled={!onOpenSettings}
>
<Icon name="sliders" size={15} />
</button>
<button
className="icon-btn"
data-testid="chat-attach"
onClick={() => fileInputRef.current?.click()}
title={t('chat.attachTitle')}
disabled={uploading}
aria-label={t('chat.attachAria')}
>
{uploading ? (
<Icon name="spinner" size={15} />
) : (
<Icon name="attach" size={15} />
)}
</button>
<span className="composer-icon-divider" aria-hidden />
<div className="composer-import-wrap">
<button
ref={importTriggerRef}
type="button"
className="composer-import"
onClick={() => setImportOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={importOpen}
title={t('chat.importTitle')}
>
<Icon name="import" size={13} />
<span>{t('chat.importLabel')}</span>
<Icon name="chevron-down" size={12} />
</button>
{importOpen ? (
<div
ref={importMenuRef}
className="composer-import-menu"
role="menu"
>
<ImportItem icon="upload" label={t('chat.importFig')} t={t} />
<ImportItem icon="link" label={t('chat.importGitHub')} t={t} />
<ImportItem icon="grid" label={t('chat.importWeb')} t={t} />
<ImportItem icon="folder" label={t('chat.importFolder')} t={t} />
<ImportItem
icon="sparkles"
label={t('chat.importSkills')}
t={t}
/>
<ImportItem icon="file" label={t('chat.importProject')} t={t} />
</div>
) : null}
</div>
{petEnabled ? (
<div className="composer-pet-wrap">
<button
ref={petTriggerRef}
type="button"
className={`composer-pet${petConfig?.adopted ? ' adopted' : ''}`}
onClick={() => setPetOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={petOpen}
title={t('pet.composerTitle')}
>
<span className="composer-pet-glyph" aria-hidden>
{(() => {
const active = resolveActivePet(petConfig);
if (active) return active.glyph;
return '🐾';
})()}
</span>
<span className="composer-pet-label">
{petConfig?.adopted
? petConfig.enabled
? t('pet.tuck')
: t('pet.wake')
: t('pet.adopt')}
</span>
<Icon name="chevron-down" size={12} />
</button>
{petOpen ? (
<div
ref={petMenuRef}
className="composer-pet-menu"
role="menu"
>
<div className="composer-pet-menu-head">
<strong>{t('pet.composerMenuTitle')}</strong>
<span>{t('pet.composerMenuHint')}</span>
</div>
{petConfig?.adopted ? (
<button
type="button"
role="menuitem"
className="composer-pet-menu-row toggle"
onClick={() => {
onTogglePet?.();
setPetOpen(false);
}}
>
<Icon
name={petConfig.enabled ? 'eye' : 'sparkles'}
size={12}
/>
<span>
{petConfig.enabled
? t('pet.tuck')
: t('pet.wake')}
</span>
</button>
) : null}
<div className="composer-pet-menu-grid">
{BUILT_IN_PETS.map((p) => {
const active =
petConfig?.adopted && petConfig.petId === p.id;
return (
<button
type="button"
role="menuitem"
key={p.id}
className={`composer-pet-menu-pet${active ? ' active' : ''}`}
onClick={() => {
onAdoptPet?.(p.id);
setPetOpen(false);
}}
style={{ ['--pet-accent' as string]: p.accent }}
title={p.flavor}
>
<span aria-hidden>{p.glyph}</span>
<span>{p.name}</span>
</button>
);
})}
</div>
<button
type="button"
role="menuitem"
className="composer-pet-menu-row settings"
onClick={() => {
onOpenPetSettings?.();
setPetOpen(false);
}}
>
<Icon name="settings" size={12} />
<span>{t('pet.composerOpenSettings')}</span>
</button>
</div>
) : null}
</div>
) : null}
<span className="composer-spacer" />
{streaming ? (
<button
type="button"
className="composer-send stop"
onClick={onStop}
>
<Icon name="stop" size={13} />
<span>{t('chat.stop')}</span>
</button>
) : (
<button
type="button"
className="composer-send"
data-testid="chat-send"
onClick={() => void submit()}
disabled={!draft.trim() && commentAttachments.length === 0}
>
<Icon name="send" size={13} />
<span>{t('chat.send')}</span>
</button>
)}
</div>
</div>
{uploadError ? <span className="composer-hint">{uploadError}</span> : null}
<span className="composer-hint">{t('chat.composerHint')}</span>
</div>
);
}
);
function StagedAttachments({
attachments,
projectId,
onRemove,
t,
}: {
attachments: ChatAttachment[];
projectId: string | null;
onRemove: (path: string) => void;
t: TranslateFn;
}) {
return (
<div className="staged-row" data-testid="staged-attachments">
{attachments.map((a) => (
<div key={a.path} className={`staged-chip staged-${a.kind}`}>
{a.kind === "image" && projectId ? (
<img src={projectRawUrl(projectId, a.path)} alt={a.name} />
) : (
<span className="staged-icon" aria-hidden>
<Icon name="file" size={13} />
</span>
)}
<span className="staged-name" title={a.path}>
{a.name}
</span>
<button
className="staged-remove"
onClick={() => onRemove(a.path)}
title={t('common.delete')}
aria-label={t('chat.removeAria', { name: a.name })}
>
<Icon name="close" size={11} />
</button>
</div>
))}
</div>
);
}
function StagedCommentAttachments({
attachments,
onRemove,
t,
}: {
attachments: ChatCommentAttachment[];
onRemove: (id: string) => void;
t: TranslateFn;
}) {
return (
<div className="staged-row comment-staged-row" data-testid="staged-comment-attachments">
{attachments.map((a) => (
<div key={a.id} className="staged-chip staged-comment">
<span className="staged-name" title={`${a.elementId}: ${a.comment}`}>
<strong>{a.elementId}</strong>
<span>{a.comment}</span>
</span>
<button
className="staged-remove"
onClick={() => onRemove(a.id)}
title={t('chat.comments.removeAttachment')}
aria-label={t('chat.comments.removeAttachmentAria', { name: a.elementId })}
>
<Icon name="close" size={11} />
</button>
</div>
))}
</div>
);
}
function ImportItem({
icon,
label,
t,
}: {
icon: "upload" | "link" | "grid" | "folder" | "sparkles" | "file";
label: string;
t: TranslateFn;
}) {
return (
<button
type="button"
className="composer-import-item"
role="menuitem"
tabIndex={-1}
disabled
title={t('chat.importComingSoon')}
onClick={(e) => e.preventDefault()}
>
<span className="ico" aria-hidden>
<Icon name={icon} size={14} />
</span>
<span className="composer-import-item-label">{label}</span>
<span className="composer-import-item-soon">{t('chat.importSoon')}</span>
</button>
);
}
function SlashPopover({
commands,
activeIndex,
onPick,
onHover,
t,
}: {
commands: SlashCommand[];
activeIndex: number;
onPick: (cmd: SlashCommand) => void;
onHover: (index: number) => void;
t: TranslateFn;
}) {
return (
<div
className="slash-popover"
data-testid="slash-popover"
role="listbox"
aria-label={t('pet.slashPopoverAria')}
>
<div className="slash-popover-head">
<span>{t('pet.slashPopoverTitle')}</span>
<span className="slash-popover-hint">{t('pet.slashPopoverHint')}</span>
</div>
{commands.map((cmd, idx) => {
const active = idx === activeIndex;
return (
<button
key={cmd.id}
type="button"
role="option"
aria-selected={active}
className={`slash-item${active ? ' active' : ''}`}
onMouseDown={(e) => {
// Prevent the textarea from losing focus before the click
// handler fires — otherwise selectionStart resets and the
// pick replacement targets the wrong substring.
e.preventDefault();
}}
onMouseEnter={() => onHover(idx)}
onClick={() => onPick(cmd)}
>
<span className="slash-item-icon" aria-hidden>
<Icon name={cmd.icon} size={13} />
</span>
<span className="slash-item-body">
<span className="slash-item-row">
<code className="slash-item-label">{cmd.label}</code>
{cmd.argHint ? (
<span className="slash-item-arg">{cmd.argHint}</span>
) : null}
</span>
<span className="slash-item-desc">{t(cmd.descKey)}</span>
</span>
</button>
);
})}
</div>
);
}
function MentionPopover({
files,
onPick,
}: {
files: ProjectFile[];
onPick: (path: string) => void;
}) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (ref.current) ref.current.scrollTop = 0;
}, [files]);
return (
<div className="mention-popover" data-testid="mention-popover" ref={ref}>
{files.map((f) => {
const key = f.path ?? f.name;
return (
<button
key={key}
className="mention-item"
onClick={() => onPick(key)}
>
<code>{key}</code>
{f.size != null ? (
<span className="mention-meta">{prettySize(f.size)}</span>
) : null}
</button>
);
})}
</div>
);
}
function looksLikeImage(name: string): boolean {
return /\.(png|jpe?g|gif|webp|svg|avif|bmp)$/i.test(name);
}
function prettySize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
+761
View File
@@ -0,0 +1,761 @@
import { Fragment, useEffect, useRef, useState } from 'react';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { projectRawUrl } from '../providers/registry';
import type { TodoItem } from '../runtime/todos';
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Conversation, PreviewComment, ProjectFile } from '../types';
import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
import { commentsToAttachments, simplePositionLabel } from '../comments';
import { AssistantMessage } from './AssistantMessage';
import { ChatComposer, type ChatComposerHandle } from './ChatComposer';
import { Icon } from './Icon';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
// Featured starter prompts shown on the empty chat. Clicking one fills
// the composer (does not auto-send) so users can tweak before sending.
// Each prompt is intentionally dense — it should showcase ambitious
// layout, typographic, and information-design moves rather than a
// generic landing page.
const EXAMPLE_PROMPT_KEYS: Array<{
icon: string;
titleKey: keyof Dict;
tagKey: keyof Dict;
promptKey: keyof Dict;
}> = [
{
icon: '▤',
titleKey: 'chat.example1Title',
tagKey: 'chat.example1Tag',
promptKey: 'chat.example1Prompt',
},
{
icon: '▦',
titleKey: 'chat.example2Title',
tagKey: 'chat.example2Tag',
promptKey: 'chat.example2Prompt',
},
{
icon: '◈',
titleKey: 'chat.example3Title',
tagKey: 'chat.example3Tag',
promptKey: 'chat.example3Prompt',
},
];
interface Props {
messages: ChatMessage[];
streaming: boolean;
error: string | null;
projectId: string | null;
projectFiles: ProjectFile[];
// Names that exist in the project folder. Tool cards and chips use this
// set to decide whether a path can be opened as a tab.
projectFileNames?: Set<string>;
onEnsureProject: () => Promise<string | null>;
previewComments?: PreviewComment[];
attachedComments?: PreviewComment[];
onAttachComment?: (comment: PreviewComment) => void;
onDetachComment?: (commentId: string) => void;
onDeleteComment?: (commentId: string) => void;
onSend: (prompt: string, attachments: ChatAttachment[], commentAttachments: ChatCommentAttachment[]) => void;
onStop: () => void;
// Click-to-open chain: passes a basename up to ProjectView, which sets
// FileWorkspace's openRequest. Tool cards, attachment chips, and
// produced-file chips all call this.
onRequestOpenFile?: (name: string) => void;
initialDraft?: string;
// Question-form submissions become a normal user message; the parent
// routes that text through onSend (no attachments).
onSubmitForm?: (text: string) => void;
onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void;
// Header "+" button — kicks off ProjectView's create-conversation flow.
onNewConversation?: () => void;
// Conversation list that used to live in the topbar. The chat tab now
// owns the list so users can browse + switch conversations without
// leaving the pane.
conversations: Conversation[];
activeConversationId: string | null;
onSelectConversation: (id: string) => void;
onDeleteConversation: (id: string) => void;
onRenameConversation?: (id: string, title: string) => void;
// Composer settings/CLI button forwards to here. The dialog lives in App
// (it owns the AppConfig lifecycle) so we just pass the open trigger.
onOpenSettings?: () => void;
// Optional pet wiring forwarded straight through to ChatComposer's
// /pet button. When omitted the composer hides the button entirely.
petConfig?: AppConfig['pet'];
onAdoptPet?: (petId: string) => void;
onTogglePet?: () => void;
onOpenPetSettings?: () => void;
}
type Tab = 'chat' | 'comments';
export function ChatPane({
messages,
streaming,
error,
projectId,
projectFiles,
projectFileNames,
onEnsureProject,
previewComments = [],
attachedComments = [],
onAttachComment,
onDetachComment,
onDeleteComment,
onSend,
onStop,
onRequestOpenFile,
initialDraft,
onSubmitForm,
onContinueRemainingTasks,
onNewConversation,
conversations,
activeConversationId,
onSelectConversation,
onDeleteConversation,
onRenameConversation,
onOpenSettings,
petConfig,
onAdoptPet,
onTogglePet,
onOpenPetSettings,
}: Props) {
const t = useT();
const logRef = useRef<HTMLDivElement | null>(null);
const historyWrapRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<ChatComposerHandle | null>(null);
const didInitialScrollRef = useRef(false);
const [tab, setTab] = useState<Tab>('chat');
const [showConvList, setShowConvList] = useState(false);
const [scrolledFromBottom, setScrolledFromBottom] = useState(false);
const lastAssistantId = [...messages].reverse().find((m) => m.role === 'assistant')?.id;
const hasActiveRunMessage = messages.some(
(m) => m.role === 'assistant' && isActiveRunStatus(m.runStatus),
);
// Map each assistant message id to the user message that follows it
// (if any) so QuestionFormView can render its locked "answered" state
// with the user's picks.
const nextUserContentByAssistantId = (() => {
const map = new Map<string, string>();
for (let i = 0; i < messages.length - 1; i++) {
const m = messages[i]!;
const next = messages[i + 1]!;
if (m.role === 'assistant' && next.role === 'user') {
map.set(m.id, next.content);
}
}
return map;
})();
useEffect(() => {
didInitialScrollRef.current = false;
}, [activeConversationId]);
useEffect(() => {
const el = logRef.current;
if (!el || didInitialScrollRef.current || messages.length === 0) return;
didInitialScrollRef.current = true;
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
setScrolledFromBottom(false);
});
}, [activeConversationId, messages.length]);
useEffect(() => {
const el = logRef.current;
if (!el) return;
// Auto-scroll only when we're already pinned near the bottom — preserves
// a user's scrollback position when they're reading earlier output while
// a new turn streams in.
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distance < 80) {
el.scrollTop = el.scrollHeight;
}
}, [messages, error]);
useEffect(() => {
const el = logRef.current;
if (!el) return;
function onScroll() {
const target = logRef.current;
if (!target) return;
const distance =
target.scrollHeight - target.scrollTop - target.clientHeight;
setScrolledFromBottom(distance > 120);
}
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, []);
// Close the conversation history dropdown on outside click / Escape.
useEffect(() => {
if (!showConvList) return;
function onPointer(e: MouseEvent) {
const target = e.target as Node;
if (historyWrapRef.current?.contains(target)) return;
setShowConvList(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setShowConvList(false);
}
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [showConvList]);
const activeConversation =
conversations.find((c) => c.id === activeConversationId) ?? null;
function jumpToBottom() {
const el = logRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}
return (
<div className="pane">
<div className="chat-header">
<div className="chat-header-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={tab === 'chat'}
className={`chat-header-tab${tab === 'chat' ? ' active' : ''}`}
onClick={() => setTab('chat')}
>
{t('chat.tabChat')}
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'comments'}
className={`chat-header-tab${tab === 'comments' ? ' active' : ''}`}
onClick={() => setTab('comments')}
>
{t('chat.tabComments')}
</button>
</div>
<div className="chat-header-actions">
<div
className={`chat-history-wrap${showConvList ? ' open' : ''}`}
ref={historyWrapRef}
>
<button
type="button"
className="icon-only"
data-testid="conversation-history-trigger"
title={
activeConversation?.title
? `${t('chat.conversationsTitle')} · ${activeConversation.title}`
: t('chat.conversationsTitle')
}
aria-label={t('chat.conversationsAria')}
aria-haspopup="menu"
aria-expanded={showConvList}
onClick={() => setShowConvList((v) => !v)}
>
<Icon name="history" size={15} />
{conversations.length > 1 ? (
<span className="chat-history-badge">{conversations.length}</span>
) : null}
</button>
{showConvList ? (
<div className="chat-history-menu" role="menu" data-testid="conversation-history-menu">
<div className="chat-history-menu-head">
<span className="chat-history-menu-title">
{t('chat.conversationsHeading')}
</span>
{onNewConversation ? (
<button
type="button"
className="chat-history-new"
data-testid="conversation-history-new"
onClick={() => {
onNewConversation();
setShowConvList(false);
}}
>
<Icon name="plus" size={11} />
<span>{t('chat.new')}</span>
</button>
) : null}
</div>
<div className="chat-history-list" data-testid="conversation-list">
{conversations.length === 0 ? (
<div className="chat-history-empty">
{t('chat.emptyConversations')}
</div>
) : (
conversations.map((c) => (
<ConversationRow
key={c.id}
conversation={c}
active={c.id === activeConversationId}
onSelect={() => {
onSelectConversation(c.id);
setShowConvList(false);
}}
onDelete={() => onDeleteConversation(c.id)}
onRename={onRenameConversation}
t={t}
/>
))
)}
</div>
</div>
) : null}
</div>
<button
type="button"
className="icon-only"
data-testid="new-conversation"
title={t('chat.newConversationsTitle')}
aria-label={t('chat.newConversation')}
onClick={onNewConversation}
disabled={!onNewConversation}
>
<Icon name="plus" size={16} />
</button>
</div>
</div>
{tab === 'chat' ? (
<>
<div className="chat-log-wrap">
<div className="chat-log" ref={logRef}>
{messages.length === 0 ? (
<div className="chat-empty-wrap">
<div className="chat-empty">
<span className="chat-empty-title">
{t('chat.startTitle')}
</span>
<span className="chat-empty-hint">
{t('chat.startHint')}
</span>
</div>
<div className="chat-examples" role="list">
{EXAMPLE_PROMPT_KEYS.map((ex, i) => {
const title = t(ex.titleKey);
const tag = t(ex.tagKey);
const prompt = t(ex.promptKey);
return (
<button
key={ex.titleKey}
type="button"
role="listitem"
className="chat-example"
style={{ animationDelay: `${i * 70}ms` }}
onClick={() => composerRef.current?.setDraft(prompt)}
title={t('chat.fillInputTitle')}
>
<span className="chat-example-icon" aria-hidden>
{ex.icon}
</span>
<span className="chat-example-body">
<span className="chat-example-head">
<span className="chat-example-title">{title}</span>
<span className="chat-example-tag">{tag}</span>
</span>
<span className="chat-example-prompt">{prompt}</span>
</span>
<span className="chat-example-cta" aria-hidden>
</span>
</button>
);
})}
</div>
</div>
) : null}
{messages.map((m, i) => {
const showDaySeparator = shouldShowDaySeparator(messages[i - 1], m);
const messageStreaming =
m.role === 'assistant' &&
((streaming && m.id === lastAssistantId) || isActiveRunStatus(m.runStatus));
return (
<Fragment key={m.id}>
{showDaySeparator ? <DaySeparator ts={messageTime(m)} /> : null}
{m.role === 'user' ? (
<UserMessage
message={m}
projectId={projectId}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
t={t}
/>
) : (
<AssistantMessage
message={m}
streaming={messageStreaming}
projectId={projectId}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
isLast={m.id === lastAssistantId}
nextUserContent={nextUserContentByAssistantId.get(m.id)}
onSubmitForm={onSubmitForm}
onContinueRemainingTasks={
m.id === lastAssistantId && onContinueRemainingTasks
? (todos) => onContinueRemainingTasks(m, todos)
: undefined
}
/>
)}
</Fragment>
);
})}
{error ? <div className="msg error">{error}</div> : null}
</div>
{scrolledFromBottom ? (
<button
type="button"
className="chat-jump-btn"
onClick={jumpToBottom}
title={t('chat.scrollToLatest')}
>
<Icon name="arrow-up" size={12} style={{ transform: 'rotate(180deg)' }} />
<span>{t('chat.jumpToLatest')}</span>
</button>
) : null}
</div>
<ChatComposer
ref={composerRef}
projectId={projectId}
projectFiles={projectFiles}
streaming={streaming || hasActiveRunMessage}
initialDraft={initialDraft}
onEnsureProject={onEnsureProject}
commentAttachments={commentsToAttachments(attachedComments)}
onRemoveCommentAttachment={onDetachComment}
onSend={onSend}
onStop={onStop}
onOpenSettings={onOpenSettings}
petConfig={petConfig}
onAdoptPet={onAdoptPet}
onTogglePet={onTogglePet}
onOpenPetSettings={onOpenPetSettings}
/>
</>
) : null}
{tab === 'comments' ? (
<CommentsPanel
comments={previewComments}
attachedComments={attachedComments}
onAttach={onAttachComment}
onDetach={onDetachComment}
onDelete={onDeleteComment}
t={t}
/>
) : null}
</div>
);
}
function CommentsPanel({
comments,
attachedComments,
onAttach,
onDetach,
onDelete,
t,
}: {
comments: PreviewComment[];
attachedComments: PreviewComment[];
onAttach?: (comment: PreviewComment) => void;
onDetach?: (commentId: string) => void;
onDelete?: (commentId: string) => void;
t: TranslateFn;
}) {
const attachedIds = new Set(attachedComments.map((comment) => comment.id));
const saved = comments.filter((comment) => !attachedIds.has(comment.id));
return (
<div className="comments-panel" data-testid="comments-panel">
<CommentSection
title={t('chat.comments.attached')}
empty={t('chat.comments.emptyAttached')}
comments={attachedComments}
actionLabel={t('chat.comments.remove')}
onAction={(comment) => onDetach?.(comment.id)}
attached
/>
<CommentSection
title={t('chat.comments.saved')}
empty={t('chat.comments.emptySaved')}
comments={saved}
actionLabel={t('chat.comments.add')}
onAction={(comment) => onAttach?.(comment)}
secondaryActionLabel={t('chat.comments.remove')}
onSecondaryAction={(comment) => onDelete?.(comment.id)}
/>
{saved.length > 0 ? (
<div className="comments-footer">
<button
type="button"
className="primary"
onClick={() => saved.forEach((comment) => onAttach?.(comment))}
>
{t('chat.comments.addAll')}
</button>
</div>
) : null}
</div>
);
}
function CommentSection({
title,
empty,
comments,
actionLabel,
onAction,
secondaryActionLabel,
onSecondaryAction,
attached,
}: {
title: string;
empty: string;
comments: PreviewComment[];
actionLabel: string;
onAction: (comment: PreviewComment) => void;
secondaryActionLabel?: string;
onSecondaryAction?: (comment: PreviewComment) => void;
attached?: boolean;
}) {
return (
<section className="comments-section">
<h3>{title}</h3>
{comments.length === 0 ? (
<p className="comments-empty">{empty}</p>
) : (
comments.map((comment) => (
<article
key={comment.id}
className={`comment-card${attached ? ' attached' : ''}`}
data-testid={`comment-card-${comment.elementId}`}
>
<div className="comment-card-top">
<strong>{comment.elementId}</strong>
<div className="comment-card-actions">
{secondaryActionLabel && onSecondaryAction ? (
<button
type="button"
className="comment-card-action danger"
onClick={() => onSecondaryAction(comment)}
>
{secondaryActionLabel}
</button>
) : null}
<button type="button" className="comment-card-action" onClick={() => onAction(comment)}>
{actionLabel}
</button>
</div>
</div>
<p>{comment.note}</p>
<div className="comment-card-meta">
<span>{comment.id}</span>
<span>{comment.filePath}</span>
<span>{comment.label}</span>
<span>{simplePositionLabel(comment.position)}</span>
</div>
</article>
))
)}
</section>
);
}
function isActiveRunStatus(status: ChatMessage['runStatus']): boolean {
return status === 'queued' || status === 'running';
}
function ConversationRow({
conversation,
active,
onSelect,
onDelete,
onRename,
t,
}: {
conversation: Conversation;
active: boolean;
onSelect: () => void;
onDelete: () => void;
onRename?: (id: string, title: string) => void;
t: TranslateFn;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(conversation.title ?? '');
const displayTitle =
conversation.title || t('chat.untitledConversation');
return (
<div
className={`chat-conv-item${active ? ' active' : ''}`}
data-testid={`conversation-item-${conversation.id}`}
>
{editing && onRename ? (
<input
autoFocus
className="chat-conv-rename-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
onRename(conversation.id, draft);
setEditing(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onRename(conversation.id, draft);
setEditing(false);
} else if (e.key === 'Escape') {
setEditing(false);
}
}}
style={{ flex: 1, padding: '2px 6px', fontSize: 12 }}
/>
) : (
<button
type="button"
className="chat-conv-item-name"
data-testid={`conversation-select-${conversation.id}`}
style={{ background: 'transparent', border: 'none', padding: 0, textAlign: 'left' }}
onClick={onSelect}
onDoubleClick={() => {
if (!onRename) return;
setDraft(conversation.title ?? '');
setEditing(true);
}}
>
{displayTitle}
</button>
)}
<span className="chat-conv-item-meta">{relTime(conversation.updatedAt, t)}</span>
<button
type="button"
className="chat-conv-item-del"
data-testid={`conversation-delete-${conversation.id}`}
title={t('chat.deleteConversation')}
onClick={(e) => {
e.stopPropagation();
if (
confirm(t('chat.deleteConversationConfirm', { title: displayTitle }))
) {
onDelete();
}
}}
>
<Icon name="close" size={12} />
</button>
</div>
);
}
function UserMessage({
message,
projectId,
projectFileNames,
onRequestOpenFile,
t,
}: {
message: ChatMessage;
projectId: string | null;
projectFileNames?: Set<string>;
onRequestOpenFile?: (name: string) => void;
t: TranslateFn;
}) {
const attachments = message.attachments ?? [];
const commentAttachments = message.commentAttachments ?? [];
return (
<div className="msg user">
<div className="role">
<span>{t('chat.you')}</span>
<MessageTimestamp message={message} t={t} />
</div>
{attachments.length > 0 ? (
<div className="user-attachments">
{attachments.map((a) => {
const baseName = a.path.split('/').pop() || a.path;
const openable =
!!onRequestOpenFile &&
(projectFileNames ? projectFileNames.has(baseName) : true);
const handleOpen = openable
? () => onRequestOpenFile?.(baseName)
: undefined;
return (
<button
type="button"
key={a.path}
className={`user-attachment staged-${a.kind}${openable ? ' openable' : ''}`}
onClick={handleOpen}
disabled={!openable}
title={openable ? t('chat.openFile', { name: baseName }) : a.path}
>
{a.kind === 'image' && projectId ? (
<img src={projectRawUrl(projectId, a.path)} alt={a.name} />
) : (
<Icon name="file" size={14} />
)}
<span className="staged-name">{a.name}</span>
</button>
);
})}
</div>
) : null}
{commentAttachments.length > 0 ? (
<div className="user-attachments comment-history-attachments">
{commentAttachments.map((a) => (
<span key={a.id} className="user-attachment staged-comment">
<span className="staged-name">
<strong>{a.elementId}</strong>
<span>{a.comment}</span>
</span>
</span>
))}
</div>
) : null}
{message.content ? <div className="user-text">{message.content}</div> : null}
</div>
);
}
function DaySeparator({ ts }: { ts: number | undefined }) {
if (!ts) return null;
return (
<div className="chat-day-separator" role="separator">
<time dateTime={new Date(ts).toISOString()}>{dayLabel(ts)}</time>
</div>
);
}
function MessageTimestamp({ message, t }: { message: ChatMessage; t: TranslateFn }) {
const ts = messageTime(message);
if (!ts) return null;
return (
<time className="msg-time" dateTime={new Date(ts).toISOString()} title={exactDateTime(ts)}>
{relativeTimeLong(ts, t)}
</time>
);
}
function shouldShowDaySeparator(prev: ChatMessage | undefined, curr: ChatMessage): boolean {
const currTime = messageTime(curr);
if (!currTime) return false;
const prevTime = prev ? messageTime(prev) : undefined;
if (!prevTime) return true;
return dayKey(prevTime) !== dayKey(currTime);
}
function relTime(ts: number, t: TranslateFn): string {
const diff = Date.now() - ts;
const min = 60_000;
const hr = 60 * min;
const day = 24 * hr;
if (diff < min) return t('common.now');
if (diff < hr) return t('common.minutesShort', { n: Math.floor(diff / min) });
if (diff < day) return t('common.hoursShort', { n: Math.floor(diff / hr) });
if (diff < 7 * day) return t('common.daysShort', { n: Math.floor(diff / day) });
return new Date(ts).toLocaleDateString();
}
@@ -0,0 +1,231 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useT } from '../i18n';
import type { Conversation } from '../types';
interface Props {
conversations: Conversation[];
activeId: string | null;
onSelect: (id: string) => void;
onCreate: () => void;
onDelete: (id: string) => void;
onRename: (id: string, title: string) => void;
}
// Pill + dropdown that lives in the project topbar. Click the pill to
// reveal the list of conversations for this project, with a "New" action
// at the top. Recency-ordered (server-side).
export function ConversationsMenu({
conversations,
activeId,
onSelect,
onCreate,
onDelete,
onRename,
}: Props) {
const t = useT();
const [open, setOpen] = useState(false);
const pillRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
function onDown(e: MouseEvent) {
const target = e.target as Node;
if (pillRef.current?.contains(target)) return;
if (menuRef.current?.contains(target)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const active = conversations.find((c) => c.id === activeId) ?? null;
return (
<>
<button
ref={pillRef}
type="button"
className={`conv-pill ${open ? 'open' : ''}`}
onClick={() => setOpen((v) => !v)}
title={t('conv.switch')}
>
<span className="conv-pill-icon" aria-hidden>
💬
</span>
<span className="conv-pill-label">
{active ? active.title || t('conv.label') : t('conv.heading')}
</span>
<span className="conv-pill-count">{conversations.length}</span>
</button>
{open
? createPortal(
<ConversationsDropdown
menuRef={menuRef}
anchor={pillRef.current}
conversations={conversations}
activeId={activeId}
onClose={() => setOpen(false)}
onSelect={(id) => {
setOpen(false);
onSelect(id);
}}
onCreate={() => {
setOpen(false);
onCreate();
}}
onDelete={onDelete}
onRename={onRename}
/>,
document.body,
)
: null}
</>
);
}
function ConversationsDropdown({
menuRef,
anchor,
conversations,
activeId,
onClose: _onClose,
onSelect,
onCreate,
onDelete,
onRename,
}: {
menuRef: React.MutableRefObject<HTMLDivElement | null>;
anchor: HTMLElement | null;
conversations: Conversation[];
activeId: string | null;
onClose: () => void;
onSelect: (id: string) => void;
onCreate: () => void;
onDelete: (id: string) => void;
onRename: (id: string, title: string) => void;
}) {
const t = useT();
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState('');
useLayoutEffect(() => {
if (!anchor) return;
function update() {
if (!anchor) return;
const r = anchor.getBoundingClientRect();
setPos({ top: r.bottom + 6, left: r.left });
}
update();
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, [anchor]);
if (!pos) return null;
return (
<div
ref={menuRef}
className="conv-menu"
style={{ top: pos.top, left: pos.left }}
>
<div className="conv-menu-header">
<span>{t('conv.heading')}</span>
<button className="ghost conv-add-btn" onClick={onCreate}>
{t('conv.new')}
</button>
</div>
{conversations.length === 0 ? (
<div className="conv-menu-empty">{t('conv.empty')}</div>
) : (
<ul className="conv-list">
{conversations.map((c) => (
<li
key={c.id}
className={`conv-item ${c.id === activeId ? 'active' : ''}`}
>
{editing === c.id ? (
<input
autoFocus
className="conv-rename-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
onRename(c.id, draft);
setEditing(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onRename(c.id, draft);
setEditing(null);
} else if (e.key === 'Escape') {
setEditing(null);
}
}}
/>
) : (
<button
className="conv-item-button"
onClick={() => onSelect(c.id)}
onDoubleClick={() => {
setEditing(c.id);
setDraft(c.title ?? '');
}}
title={t('conv.renameTooltip')}
>
<span className="conv-item-name">
{c.title || t('conv.untitled')}
</span>
<span className="conv-item-meta">{relTime(c.updatedAt, t)}</span>
</button>
)}
<button
className="conv-item-del"
title={t('conv.delete')}
onClick={(e) => {
e.stopPropagation();
if (
confirm(
t('conv.deleteConfirm', {
title: c.title || t('conv.untitled'),
}),
)
) {
onDelete(c.id);
}
}}
>
×
</button>
</li>
))}
</ul>
)}
</div>
);
}
function relTime(ts: number, t: ReturnType<typeof useT>): string {
const diff = Date.now() - ts;
const min = 60_000;
const hr = 60 * min;
const day = 24 * hr;
if (diff < min) return t('common.now');
if (diff < hr) return t('common.minutesShort', { n: Math.floor(diff / min) });
if (diff < day) return t('common.hoursShort', { n: Math.floor(diff / hr) });
if (diff < 7 * day) return t('common.daysShort', { n: Math.floor(diff / day) });
return new Date(ts).toLocaleDateString();
}
@@ -0,0 +1,610 @@
import { useEffect, useMemo, useRef, useState, useTransition } from 'react';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { projectFileUrl } from '../providers/registry';
import type { ProjectFile, ProjectFileKind } from '../types';
import { Icon } from './Icon';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
interface Props {
projectId: string;
files: ProjectFile[];
onRefreshFiles: () => Promise<void> | void;
onOpenFile: (name: string) => void;
onDeleteFile: (name: string) => void;
onUpload: () => void;
onUploadFiles: (files: File[]) => void;
onPaste: () => void;
onNewSketch: () => void;
}
type Section = 'pages' | 'scripts' | 'images' | 'sketches' | 'other';
const SECTION_LABEL_KEY: Record<Section, keyof Dict> = {
pages: 'designFiles.sectionPages',
scripts: 'designFiles.sectionScripts',
images: 'designFiles.sectionImages',
sketches: 'designFiles.sectionSketches',
other: 'designFiles.sectionOther',
};
const SECTION_ORDER: Section[] = ['pages', 'sketches', 'scripts', 'images', 'other'];
const INITIAL_SECTION_FILE_LIMIT = 30;
const SECTION_FILE_LIMIT_INCREMENT = 200;
/**
* Full-panel browser for a project's `.od/projects/<id>/` folder. Mirrors
* Claude Design's "Design Files" surface: grouped sections, hover-revealed
* row menu, drop-files footer, and (when a row is selected) a right-side
* preview pane. Triggered as a sticky first tab in FileWorkspace.
*/
export function DesignFilesPanel({
projectId,
files,
onRefreshFiles,
onOpenFile,
onDeleteFile,
onUpload,
onUploadFiles,
onPaste,
onNewSketch,
}: Props) {
const t = useT();
const [refreshing, setRefreshing] = useState(false);
const [draggingFiles, setDraggingFiles] = useState(false);
const dragDepthRef = useRef(0);
const [hover, setHover] = useState<string | null>(null);
const [menuPos, setMenuPos] = useState<{ name: string; top: number; left: number } | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [sectionLimits, setSectionLimits] = useState<Partial<Record<Section, number>>>({});
const [isSectionExpansionPending, startSectionExpansion] = useTransition();
const [selected, setSelected] = useState<Set<string>>(new Set());
const grouped = useMemo(() => {
const groups: Record<Section, ProjectFile[]> = {
pages: [],
sketches: [],
scripts: [],
images: [],
other: [],
};
const sorted = [...files].sort((a, b) => b.mtime - a.mtime);
for (const f of sorted) {
groups[sectionFor(f)].push(f);
}
return groups;
}, [files]);
// Prune stale selections when the file list or project changes.
useEffect(() => {
setSelected((prev) => {
if (prev.size === 0) return prev;
const names = new Set(files.map((f) => f.name));
const next = new Set(prev);
let changed = false;
for (const n of next) {
if (!names.has(n)) {
next.delete(n);
changed = true;
}
}
return changed ? next : prev;
});
}, [files, projectId]);
const previewFile = useMemo(
() => files.find((f) => f.name === preview) ?? null,
[preview, files],
);
// Close the row menu on outside click / escape.
useEffect(() => {
if (!menuPos) return;
const close = () => setMenuPos(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
window.addEventListener('mousedown', close);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousedown', close);
window.removeEventListener('keydown', onKey);
};
}, [menuPos]);
async function handleRefresh() {
setRefreshing(true);
try {
await onRefreshFiles();
} finally {
setRefreshing(false);
}
}
function toggleSelect(name: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
}
function selectAllInSection(sectionFiles: ProjectFile[]) {
setSelected((prev) => {
const next = new Set(prev);
for (const f of sectionFiles) next.add(f.name);
return next;
});
}
function clearSection(sectionFiles: ProjectFile[]) {
setSelected((prev) => {
const next = new Set(prev);
for (const f of sectionFiles) next.delete(f.name);
return next;
});
}
async function handleBatchDownload() {
const fileList = [...selected];
if (fileList.length === 0) return;
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/archive/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: fileList }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => null);
throw new Error(err?.message || `request failed (${resp.status})`);
}
const blob = await resp.blob();
const header = resp.headers.get('content-disposition') || '';
const star = /filename\*=UTF-8''([^;]+)/i.exec(header);
let filename = 'project.zip';
if (star && star[1]) {
try {
filename = decodeURIComponent(star[1]);
} catch {
filename = star[1];
}
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err) {
console.warn('[batchDownload] failed:', err);
}
}
function handleDrop(ev: React.DragEvent<HTMLDivElement>) {
ev.preventDefault();
dragDepthRef.current = 0;
setDraggingFiles(false);
const dropped = Array.from(ev.dataTransfer.files ?? []);
if (dropped.length > 0) onUploadFiles(dropped);
}
return (
<div className={`df-panel ${preview ? '' : 'no-preview'}`}>
<div className="df-main">
<div className="df-head">
<button
type="button"
className="icon-only"
onClick={() => setPreview(null)}
title={t('designFiles.up')}
aria-label={t('designFiles.back')}
>
</button>
<button
type="button"
className="icon-only"
onClick={() => void handleRefresh()}
disabled={refreshing}
title={t('designFiles.refresh')}
aria-label={t('designFiles.refresh')}
>
<Icon name={refreshing ? 'spinner' : 'reload'} size={14} />
</button>
<span className="crumbs">{t('designFiles.crumbs')}</span>
{selected.size > 0 ? (
<div className="df-actions">
<button type="button" onClick={() => void handleBatchDownload()}>
<Icon name="download" size={13} />
<span>{t('designFiles.downloadSelected', { n: selected.size })}</span>
</button>
</div>
) : (
<div className="df-actions">
<button type="button" onClick={onNewSketch} title={t('designFiles.newSketch')}>
<Icon name="pencil" size={13} />
<span>{t('designFiles.newSketch')}</span>
</button>
<button type="button" onClick={onPaste} title={t('designFiles.paste.title')}>
<Icon name="copy" size={13} />
<span>{t('designFiles.paste.label')}</span>
</button>
<button
type="button"
data-testid="design-files-upload-trigger"
onClick={onUpload}
title={t('designFiles.upload.title')}
>
<Icon name="upload" size={13} />
<span>{t('designFiles.upload.label')}</span>
</button>
</div>
)}
</div>
<div className="df-body">
{files.length === 0 ? (
<div className="df-empty">{t('designFiles.empty')}</div>
) : (
SECTION_ORDER.filter((s) => grouped[s].length > 0).map((section) => {
const sectionFiles = grouped[section];
const visibleLimit = sectionLimits[section] ?? INITIAL_SECTION_FILE_LIMIT;
const visibleFiles = sectionFiles.slice(0, visibleLimit);
const hiddenCount = sectionFiles.length - visibleFiles.length;
return (
<div className="df-section" key={section}>
<div className="df-section-label">
{t(SECTION_LABEL_KEY[section])}
<span className="df-section-count">{sectionFiles.length}</span>
<button
type="button"
className="df-select-all"
onClick={(e) => {
e.stopPropagation();
selectAllInSection(sectionFiles);
}}
>
{t('designFiles.selectAll')}
</button>
{sectionFiles.some((f) => selected.has(f.name)) ? (
<button
type="button"
className="df-select-all"
onClick={(e) => {
e.stopPropagation();
clearSection(sectionFiles);
}}
>
{t('designFiles.clearSelection')}
</button>
) : null}
</div>
{visibleFiles.map((f) => {
const active = preview === f.name;
const isHovered = hover === f.name;
return (
<button
key={f.name}
type="button"
data-testid={`design-file-row-${f.name}`}
className={`df-row ${active ? 'active' : ''} ${selected.has(f.name) ? 'selected' : ''}`}
onMouseEnter={() => setHover(f.name)}
onMouseLeave={() => setHover((c) => (c === f.name ? null : c))}
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
>
<span
className="df-row-check"
onClick={(e) => {
e.stopPropagation();
toggleSelect(f.name);
}}
role="checkbox"
aria-checked={selected.has(f.name)}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
toggleSelect(f.name);
}
}}
>
{selected.has(f.name) ? '☑' : '☐'}
</span>
<span className="df-row-icon" data-kind={f.kind} aria-hidden>
{kindGlyph(f.kind)}
</span>
<span className="df-row-name-wrap">
<span className="df-row-name">{f.name}</span>
<span className="df-row-sub">{kindLabel(f.kind, t)}</span>
</span>
<span className="df-row-time">{relativeTime(f.mtime, t)}</span>
<span
data-testid={`design-file-menu-${f.name}`}
className="df-row-menu"
style={isHovered || active ? { opacity: 1 } : undefined}
role="button"
aria-label={t('designFiles.rowMenu')}
onClick={(e) => {
e.stopPropagation();
const rect = (e.target as HTMLElement)
.closest('.df-row-menu')
?.getBoundingClientRect();
setMenuPos({
name: f.name,
top: (rect?.bottom ?? 0) + 4,
left: (rect?.right ?? 0) - 160,
});
}}
>
</span>
</button>
);
})}
{hiddenCount > 0 ? (
<button
type="button"
className="df-section-more"
disabled={isSectionExpansionPending}
aria-busy={isSectionExpansionPending}
onClick={() =>
startSectionExpansion(() => {
setSectionLimits((curr) => ({
...curr,
[section]: Math.min(
sectionFiles.length,
visibleLimit + SECTION_FILE_LIMIT_INCREMENT,
),
}));
})
}
>
<Icon name={isSectionExpansionPending ? 'spinner' : 'plus'} size={12} />
<span>
{t('designFiles.showMore', {
n: Math.min(hiddenCount, SECTION_FILE_LIMIT_INCREMENT),
})}
</span>
</button>
) : null}
</div>
);
})
)}
<div
className={`df-drop ${draggingFiles ? 'dragging' : ''}`}
onDragEnter={(ev) => {
ev.preventDefault();
dragDepthRef.current += 1;
setDraggingFiles(true);
}}
onDragOver={(ev) => {
ev.preventDefault();
ev.dataTransfer.dropEffect = 'copy';
}}
onDragLeave={(ev) => {
if (!ev.currentTarget.contains(ev.relatedTarget as Node | null)) {
dragDepthRef.current = 0;
setDraggingFiles(false);
return;
}
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setDraggingFiles(false);
}}
onDrop={handleDrop}
>
<span className="label">{t('designFiles.dropTitle')}</span>
<span className="desc">{t('designFiles.dropDesc')}</span>
</div>
</div>
</div>
{preview && previewFile ? (
<DfPreview
projectId={projectId}
file={previewFile}
onOpen={() => onOpenFile(previewFile.name)}
onClose={() => setPreview(null)}
/>
) : null}
{menuPos ? (
<div
data-testid="design-file-menu-popover"
className="df-row-popover"
style={{ top: menuPos.top, left: menuPos.left }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const name = menuPos.name;
setMenuPos(null);
onOpenFile(name);
}}
>
{t('designFiles.openInTab')}
</button>
<a
href={projectFileUrl(projectId, menuPos.name)}
download={menuPos.name}
style={{ textDecoration: 'none' }}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMenuPos(null);
}}
>
{t('designFiles.download')}
</button>
</a>
<button
type="button"
className="danger"
data-testid={`design-file-delete-${menuPos.name}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
const name = menuPos.name;
setMenuPos(null);
onDeleteFile(name);
}}
>
{t('designFiles.delete')}
</button>
</div>
) : null}
</div>
);
}
function DfPreview({
projectId,
file,
onOpen,
onClose,
}: {
projectId: string;
file: ProjectFile;
onOpen: () => void;
onClose: () => void;
}) {
const t = useT();
const url = projectFileUrl(projectId, file.name);
return (
<aside className="df-preview">
<div className="df-preview-thumb">
{file.kind === 'image' || file.kind === 'sketch' ? (
<img src={`${url}?v=${Math.round(file.mtime)}`} alt={file.name} />
) : file.kind === 'html' ? (
<iframe title={file.name} src={url} sandbox="allow-scripts" />
) : file.kind === 'video' ? (
<video
src={`${url}?v=${Math.round(file.mtime)}`}
controls
playsInline
preload="metadata"
/>
) : file.kind === 'audio' ? (
<audio src={`${url}?v=${Math.round(file.mtime)}`} controls preload="metadata" />
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-faint)',
fontSize: 38,
}}
>
{kindGlyph(file.kind)}
</div>
)}
</div>
<div className="df-preview-meta" data-testid="design-file-preview">
<button
type="button"
className="ghost"
onClick={onOpen}
style={{ alignSelf: 'flex-start' }}
>
<Icon name="eye" size={13} />
<span>{t('designFiles.previewOpen')}</span>
</button>
<div className="df-preview-name">{file.name}</div>
<div className="df-preview-kind">{kindLabel(file.kind, t)}</div>
<div className="df-preview-stats">
{t('designFiles.modified', {
time: relativeTime(file.mtime, t),
size: humanBytes(file.size),
})}
</div>
<div className="df-preview-actions">
<a
className="ghost-link"
href={url}
download={file.name}
style={{ textDecoration: 'none' }}
>
{t('designFiles.download')}
</a>
<button type="button" onClick={onClose}>
{t('designFiles.previewClose')}
</button>
</div>
</div>
</aside>
);
}
function sectionFor(file: ProjectFile): Section {
if (file.kind === 'html' || file.kind === 'text') return 'pages';
if (file.kind === 'sketch') return 'sketches';
if (file.kind === 'code') return 'scripts';
if (file.kind === 'image') return 'images';
if (
file.kind === 'pdf' ||
file.kind === 'document' ||
file.kind === 'presentation' ||
file.kind === 'spreadsheet'
) return 'pages';
return 'other';
}
function kindGlyph(kind: ProjectFileKind): string {
if (kind === 'html') return '⟨⟩';
if (kind === 'image') return '▣';
if (kind === 'sketch') return '✎';
if (kind === 'text') return '¶';
if (kind === 'code') return '{}';
if (kind === 'pdf') return 'PDF';
if (kind === 'document') return 'DOC';
if (kind === 'presentation') return 'PPT';
if (kind === 'spreadsheet') return 'XLS';
return '·';
}
function kindLabel(kind: ProjectFileKind, t: TranslateFn): string {
if (kind === 'html') return t('designFiles.kindHtml');
if (kind === 'image') return t('designFiles.kindImage');
if (kind === 'sketch') return t('designFiles.kindSketch');
if (kind === 'text') return t('designFiles.kindText');
if (kind === 'code') return t('designFiles.kindCode');
if (kind === 'pdf') return t('designFiles.kindPdf');
if (kind === 'document') return t('designFiles.kindDocument');
if (kind === 'presentation') return t('designFiles.kindPresentation');
if (kind === 'spreadsheet') return t('designFiles.kindSpreadsheet');
return t('designFiles.kindBinary');
}
function relativeTime(ts: number, t: TranslateFn): string {
const diff = Date.now() - ts;
const min = 60_000;
const hr = 60 * min;
const day = 24 * hr;
if (diff < min) return t('common.justNow');
if (diff < hr) return t('common.minutesAgo', { n: Math.floor(diff / min) });
if (diff < day) return t('common.hoursAgo', { n: Math.floor(diff / hr) });
if (diff < 7 * day) return t('common.daysAgo', { n: Math.floor(diff / day) });
if (diff < 30 * day)
return t('designFiles.weeksAgo', { n: Math.floor(diff / (7 * day)) });
return new Date(ts).toLocaleDateString();
}
function humanBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
@@ -0,0 +1,94 @@
import { useMemo } from 'react';
interface Props {
source: string | null | undefined;
loading?: boolean;
loadingLabel: string;
}
// Render a DESIGN.md as a lightly syntax-coloured monospace source view —
// the right-hand panel of the preview modal, mirroring the layout used by
// styles.refero.design where the rendered showcase sits next to the spec
// text. Highlights are CSS-class only; no innerHTML for untrusted text.
export function DesignSpecView({ source, loading, loadingLabel }: Props) {
const lines = useMemo(() => (source ? source.split(/\r?\n/) : []), [source]);
if (loading || source === undefined || source === null) {
return <div className="design-spec-empty">{loadingLabel}</div>;
}
return (
<pre className="design-spec-pre">
<code>
{lines.map((line, idx) => (
<span key={idx} className={`design-spec-line ${classifyLine(line)}`}>
{renderInline(line)}
{'\n'}
</span>
))}
</code>
</pre>
);
}
function classifyLine(line: string): string {
if (/^#{1,6}\s+/.test(line)) {
const hashes = /^(#+)\s/.exec(line)?.[1]?.length ?? 1;
return `is-h${Math.min(hashes, 4)}`;
}
if (/^>\s/.test(line)) return 'is-quote';
if (/^[-*+]\s/.test(line.trimStart())) return 'is-list';
if (/^\|.*\|\s*$/.test(line)) return 'is-table';
if (/^\s*```/.test(line)) return 'is-fence';
if (/^\s*$/.test(line)) return 'is-blank';
return '';
}
const TOKEN_RE = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|#[0-9a-fA-F]{3,8}\b)/g;
function renderInline(line: string) {
if (!line) return null;
const out: (string | JSX.Element)[] = [];
let last = 0;
let key = 0;
for (const match of line.matchAll(TOKEN_RE)) {
const start = match.index ?? 0;
if (start > last) out.push(line.slice(last, start));
const token = match[0];
if (token.startsWith('**')) {
out.push(
<span key={key++} className="md-tk-bold">
{token.slice(2, -2)}
</span>,
);
} else if (token.startsWith('*')) {
out.push(
<span key={key++} className="md-tk-em">
{token.slice(1, -1)}
</span>,
);
} else if (token.startsWith('`')) {
out.push(
<span key={key++} className="md-tk-code">
{token.slice(1, -1)}
</span>,
);
} else if (token.startsWith('#')) {
out.push(
<span key={key++} className="md-tk-color" style={{ color: 'inherit' }}>
<span
className="md-tk-color-swatch"
style={{ backgroundColor: token }}
aria-hidden
/>
{token}
</span>,
);
} else {
out.push(token);
}
last = start + token.length;
}
if (last < line.length) out.push(line.slice(last));
return out;
}
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useState } from 'react';
import { useT } from '../i18n';
import {
fetchDesignSystem,
fetchDesignSystemPreview,
fetchDesignSystemShowcase,
} from '../providers/registry';
import type { DesignSystemSummary } from '../types';
import { DesignSpecView } from './DesignSpecView';
import { PreviewModal } from './PreviewModal';
interface Props {
system: DesignSystemSummary;
onClose: () => void;
}
// Two-tab DS preview: a complete Showcase webpage rendered from the system's
// tokens, and the original Tokens view (palette / typography / components +
// rendered DESIGN.md prose). A toggleable side panel surfaces the raw
// DESIGN.md so users can compare spec to render at the same time, mirroring
// the styles.refero.design layout.
export function DesignSystemPreviewModal({ system, onClose }: Props) {
const t = useT();
const [showcaseHtml, setShowcaseHtml] = useState<string | null | undefined>(undefined);
const [tokensHtml, setTokensHtml] = useState<string | null | undefined>(undefined);
const [specBody, setSpecBody] = useState<string | null | undefined>(undefined);
// Lazy-load each view on first reveal. Both endpoints are cheap, but this
// keeps the network panel quiet when the user only opens one tab.
const handleView = useCallback(
(viewId: string) => {
if (viewId === 'showcase' && showcaseHtml === undefined) {
setShowcaseHtml(null);
void fetchDesignSystemShowcase(system.id).then((html) => setShowcaseHtml(html));
}
if (viewId === 'tokens' && tokensHtml === undefined) {
setTokensHtml(null);
void fetchDesignSystemPreview(system.id).then((html) => setTokensHtml(html));
}
},
[system.id, showcaseHtml, tokensHtml],
);
// Fetch DESIGN.md the first time the side panel opens. Once we have it we
// never re-fetch unless the underlying system swaps.
const handleSidebarToggle = useCallback(
(open: boolean) => {
if (!open || specBody !== undefined) return;
setSpecBody(null);
void fetchDesignSystem(system.id).then((detail) =>
setSpecBody(detail?.body ?? null),
);
},
[system.id, specBody],
);
// If the system swaps under us (rare but possible), wipe all caches.
useEffect(() => {
setShowcaseHtml(undefined);
setTokensHtml(undefined);
setSpecBody(undefined);
}, [system.id]);
return (
<PreviewModal
title={system.title}
subtitle={system.summary || system.category}
views={[
{ id: 'showcase', label: t('ds.showcase'), html: showcaseHtml },
{ id: 'tokens', label: t('ds.tokens'), html: tokensHtml },
]}
initialViewId="showcase"
onView={handleView}
exportTitleFor={(viewId) => `${system.title}${viewId}`}
onClose={onClose}
sidebar={{
label: t('ds.specToggle'),
defaultOpen: true,
onToggle: handleSidebarToggle,
// Re-fire onToggle when the system swaps under us so the new
// DESIGN.md fetch starts even if the sidebar never closed.
contentKey: system.id,
content: (
<DesignSpecView
source={specBody}
loadingLabel={t('ds.specLoading')}
/>
),
}}
/>
);
}
@@ -0,0 +1,304 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../i18n';
import {
localizeDesignSystemCategory,
localizeDesignSystemSummary,
} from '../i18n/content';
import { fetchDesignSystemShowcase } from '../providers/registry';
import { buildSrcdoc } from '../runtime/srcdoc';
import type { DesignSystemSummary, Surface } from '../types';
interface Props {
systems: DesignSystemSummary[];
selectedId: string | null;
onSelect: (id: string) => void;
onPreview: (id: string) => void;
}
const CATEGORY_ORDER = [
'Starter',
'AI & LLM',
'Developer Tools',
'Productivity & SaaS',
'Backend & Data',
'Design & Creative',
'Fintech & Crypto',
'E-Commerce & Retail',
'Media & Consumer',
'Automotive',
];
type SurfaceFilter = 'all' | Surface;
const SURFACE_PILLS: { value: SurfaceFilter; labelKey: 'examples.modeAll' | 'ds.surfaceWeb' | 'ds.surfaceImage' | 'ds.surfaceVideo' | 'ds.surfaceAudio' }[] = [
{ value: 'all', labelKey: 'examples.modeAll' },
{ value: 'web', labelKey: 'ds.surfaceWeb' },
{ value: 'image', labelKey: 'ds.surfaceImage' },
{ value: 'video', labelKey: 'ds.surfaceVideo' },
{ value: 'audio', labelKey: 'ds.surfaceAudio' },
];
function surfaceOf(system: DesignSystemSummary): Surface {
return system.surface ?? 'web';
}
export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: Props) {
const { locale, t } = useI18n();
const [filter, setFilter] = useState('');
const [surfaceFilter, setSurfaceFilter] = useState<SurfaceFilter>('all');
const [category, setCategory] = useState<string>('All');
// Cache fetched showcase HTML across re-renders so cards never re-flicker
// when the user filters / scrolls back. null = "in flight"; undefined =
// "not yet requested". Mirrors the pattern used by ExamplesTab.
const [thumbs, setThumbs] = useState<Record<string, string | null>>({});
const surfaceScoped = useMemo(
() => surfaceFilter === 'all' ? systems : systems.filter((s) => surfaceOf(s) === surfaceFilter),
[systems, surfaceFilter],
);
const surfaceCounts = useMemo(() => {
const counts: Record<SurfaceFilter, number> = { all: systems.length, web: 0, image: 0, video: 0, audio: 0 };
for (const s of systems) counts[surfaceOf(s)]++;
return counts;
}, [systems]);
const categories = useMemo(() => {
const cats = new Set<string>();
for (const s of surfaceScoped) cats.add(s.category || 'Uncategorized');
const ordered: string[] = [];
for (const c of CATEGORY_ORDER) if (cats.has(c)) ordered.push(c);
for (const c of [...cats].sort()) if (!ordered.includes(c)) ordered.push(c);
return ['All', ...ordered];
}, [surfaceScoped]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
return surfaceScoped.filter((s) => {
if (category !== 'All' && (s.category || 'Uncategorized') !== category) return false;
if (!q) return true;
const summary = localizeDesignSystemSummary(locale, s).toLowerCase();
const categoryLabel = localizeDesignSystemCategory(
locale,
s.category || 'Uncategorized',
).toLowerCase();
return (
s.title.toLowerCase().includes(q) ||
s.summary.toLowerCase().includes(q) ||
summary.includes(q) ||
categoryLabel.includes(q)
);
});
}, [surfaceScoped, filter, category, locale]);
// Category metadata is authored in English; keep raw values in state for
// filtering while localizing the visible labels for the current UI locale.
const renderCategory = (c: string) => {
if (c === 'All') return t('ds.categoryAll');
if (c === 'Uncategorized') return t('ds.categoryUncategorized');
return localizeDesignSystemCategory(locale, c);
};
function loadThumb(id: string) {
setThumbs((prev) => {
if (prev[id] !== undefined) return prev;
void fetchDesignSystemShowcase(id).then((html) => {
setThumbs((p) => ({ ...p, [id]: html }));
});
return { ...prev, [id]: null };
});
}
return (
<div className="tab-panel">
<div className="tab-panel-toolbar">
<input
placeholder={t('ds.searchPlaceholder')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
{categories.map((c) => (
<option key={c} value={c}>
{renderCategory(c)}
</option>
))}
</select>
</div>
<div
className="examples-filter-row"
role="tablist"
aria-label={t('ds.surfaceLabel')}
>
<span className="examples-filter-label">{t('ds.surfaceLabel')}</span>
{SURFACE_PILLS.map((p) => (
<button
key={p.value}
type="button"
role="tab"
aria-selected={surfaceFilter === p.value}
className={`filter-pill ${surfaceFilter === p.value ? 'active' : ''}`}
onClick={() => {
setSurfaceFilter(p.value);
setCategory('All');
}}
>
{t(p.labelKey)}
<span className="filter-pill-count">{surfaceCounts[p.value]}</span>
</button>
))}
</div>
{filtered.length === 0 ? (
<div className="tab-empty">{t('ds.emptyNoMatch')}</div>
) : (
<div className="ds-grid">
{filtered.map((s) => (
<DesignSystemCard
key={s.id}
system={s}
active={s.id === selectedId}
thumbHtml={thumbs[s.id]}
onIntersect={() => loadThumb(s.id)}
onSelect={() => onSelect(s.id)}
onPreview={() => onPreview(s.id)}
/>
))}
</div>
)}
</div>
);
}
interface CardProps {
system: DesignSystemSummary;
active: boolean;
thumbHtml: string | null | undefined;
onIntersect: () => void;
onSelect: () => void;
onPreview: () => void;
}
function DesignSystemCard({
system,
active,
thumbHtml,
onIntersect,
onSelect,
onPreview,
}: CardProps) {
const { locale, t } = useI18n();
const ref = useRef<HTMLDivElement | null>(null);
// Lazy-load the showcase iframe only when the card scrolls into the
// viewport. With ~120 design systems we can't afford to mount every
// iframe up front — even with `loading="lazy"`, srcDoc iframes ignore
// the native lazy hint, so we gate via IntersectionObserver.
useEffect(() => {
if (thumbHtml !== undefined) return;
const node = ref.current;
if (!node || typeof IntersectionObserver === 'undefined') {
onIntersect();
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
onIntersect();
observer.disconnect();
break;
}
}
},
{ rootMargin: '200px' },
);
observer.observe(node);
return () => observer.disconnect();
}, [thumbHtml, onIntersect]);
const localizedSummary = localizeDesignSystemSummary(locale, system);
const categoryLabel = localizeDesignSystemCategory(
locale,
system.category || 'Uncategorized',
);
return (
<div
ref={ref}
className={`ds-card ${active ? 'active' : ''}`}
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
}}
>
<div
className="ds-card-thumb"
onClick={(e) => {
e.stopPropagation();
onPreview();
}}
title={t('ds.previewTitle')}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onPreview();
}
}}
>
{thumbHtml ? (
<iframe
title={`${system.title} preview`}
sandbox="allow-scripts"
srcDoc={buildSrcdoc(thumbHtml)}
tabIndex={-1}
aria-hidden
/>
) : (
<div className="ds-card-thumb-fallback" aria-hidden>
{system.swatches && system.swatches.length > 0 ? (
<div className="ds-card-thumb-swatches">
{system.swatches.map((c, i) => (
<span key={i} style={{ background: c }} />
))}
</div>
) : (
<span className="ds-card-thumb-placeholder">
{thumbHtml === null ? '' : ''}
</span>
)}
</div>
)}
<span className="ds-card-thumb-overlay" aria-hidden>
{t('ds.preview')}
</span>
</div>
<div className="ds-card-meta">
<div className="ds-card-title-row">
<span className="ds-card-title">{system.title}</span>
{active ? (
<span className="ds-card-badge">{t('ds.badgeDefault')}</span>
) : null}
</div>
<div className="ds-card-summary">{localizedSummary}</div>
<div className="ds-card-footer">
<span className="ds-card-category">{categoryLabel}</span>
{system.swatches && system.swatches.length > 0 ? (
<div className="ds-card-swatches" aria-hidden>
{system.swatches.map((c, i) => (
<span key={i} style={{ background: c }} title={c} />
))}
</div>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { STATUS_LABEL_KEYS, STATUS_ORDER } from './DesignsTab';
describe('DesignsTab status metadata', () => {
it('places awaiting_input between running and succeeded', () => {
expect(STATUS_ORDER).toEqual([
'not_started',
'running',
'awaiting_input',
'succeeded',
'failed',
'canceled',
]);
});
it('maps awaiting_input to the i18n label key', () => {
expect(STATUS_LABEL_KEYS.awaiting_input).toBe('designs.status.awaitingInput');
});
});
+278
View File
@@ -0,0 +1,278 @@
import { useEffect, useMemo, useState } from 'react';
import { useT } from '../i18n';
import type { DesignSystemSummary, Project, ProjectDisplayStatus, SkillSummary } from '../types';
import { Icon } from './Icon';
type SubTab = 'recent' | 'yours';
const DESIGNS_VIEW_STORAGE_KEY = 'od:designs:view';
// Single source of truth for the order kanban columns are rendered in and the
// i18n key each status maps to. Keeping this typed as a tuple lets us derive
// both the column list and the `statusLabel` lookup without duplication.
export const STATUS_ORDER = [
'not_started',
'running',
'awaiting_input',
'succeeded',
'failed',
'canceled',
] as const satisfies readonly ProjectDisplayStatus[];
export const STATUS_LABEL_KEYS = {
not_started: 'designs.status.notStarted',
queued: 'designs.status.queued',
running: 'designs.status.running',
awaiting_input: 'designs.status.awaitingInput',
succeeded: 'designs.status.succeeded',
failed: 'designs.status.failed',
canceled: 'designs.status.canceled',
} as const satisfies Record<ProjectDisplayStatus, Parameters<ReturnType<typeof useT>>[0]>;
interface Props {
projects: Project[];
skills: SkillSummary[];
designSystems: DesignSystemSummary[];
onOpen: (id: string) => void;
onDelete: (id: string) => void;
}
export function DesignsTab({ projects, skills, designSystems, onOpen, onDelete }: Props) {
const t = useT();
const [filter, setFilter] = useState('');
const [sub, setSub] = useState<SubTab>('recent');
const [view, setView] = useState<'grid' | 'kanban'>(() => {
if (typeof window === 'undefined') {
return 'grid';
}
try {
const storedView = window.localStorage.getItem(DESIGNS_VIEW_STORAGE_KEY);
return storedView === 'grid' || storedView === 'kanban' ? storedView : 'grid';
} catch {
return 'grid';
}
});
useEffect(() => {
try {
window.localStorage.setItem(DESIGNS_VIEW_STORAGE_KEY, view);
} catch {}
}, [view]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
let list = projects;
if (sub === 'recent') {
list = [...list].sort((a, b) => b.updatedAt - a.updatedAt);
}
if (!q) return list;
return list.filter((p) => p.name.toLowerCase().includes(q));
}, [projects, filter, sub]);
const skillName = (id: string | null) => skills.find((s) => s.id === id)?.name ?? '';
const dsName = (id: string | null) => designSystems.find((d) => d.id === id)?.title ?? '';
return (
<div className={`tab-panel${view === 'kanban' ? ' design-kanban-view' : ''}`}>
<div className="tab-panel-toolbar">
<div className="toolbar-left">
<div
className="subtab-pill"
role="group"
aria-label={t('designs.filterAria')}
>
<button
aria-pressed={sub === 'recent'}
className={sub === 'recent' ? 'active' : ''}
onClick={() => setSub('recent')}
>
{t('designs.subRecent')}
</button>
<button
aria-pressed={sub === 'yours'}
className={sub === 'yours' ? 'active' : ''}
onClick={() => setSub('yours')}
>
{t('designs.subYours')}
</button>
</div>
</div>
<div className="toolbar-right">
<div className="toolbar-search">
<span className="search-icon" aria-hidden>
<Icon name="search" size={13} />
</span>
<input
placeholder={t('designs.searchPlaceholder')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div
className="subtab-pill"
role="group"
aria-label={t('designs.viewToggleAria')}
>
<button
aria-pressed={view === 'grid'}
className={view === 'grid' ? 'active' : ''}
onClick={() => setView('grid')}
title={t('designs.viewGrid')}
data-testid="designs-view-grid"
>
<Icon name="grid" size={14} />
</button>
<button
aria-pressed={view === 'kanban'}
className={view === 'kanban' ? 'active' : ''}
onClick={() => setView('kanban')}
title={t('designs.viewKanban')}
data-testid="designs-view-kanban"
>
<Icon name="kanban" size={14} />
</button>
</div>
</div>
</div>
{filtered.length === 0 ? (
<div className="tab-empty">
{projects.length === 0
? t('designs.emptyNoProjects')
: t('designs.emptyNoMatch')}
</div>
) : view === 'grid' ? (
<div className="design-grid">
{filtered.map((p) => {
const skill = skillName(p.skillId);
const ds = dsName(p.designSystemId);
const status = p.status?.value ?? 'not_started';
return (
<div
key={p.id}
className="design-card"
role="button"
tabIndex={0}
onClick={() => onOpen(p.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(p.id);
}
}}
>
<button
className="design-card-close"
title={t('designs.deleteTitle')}
aria-label={t('designs.deleteAria', { name: p.name })}
onClick={(e) => {
e.stopPropagation();
if (confirm(t('designs.deleteConfirm', { name: p.name }))) {
onDelete(p.id);
}
}}
>
<Icon name="close" size={12} />
</button>
<div className="design-card-thumb" aria-hidden />
<div className="design-card-meta-block">
<div className="design-card-name" title={p.name}>{p.name}</div>
<div className="design-card-meta">
{ds ? (
<span className="ds">{ds}</span>
) : (
<span>{t('designs.cardFreeform')}</span>
)}
{skill ? ` · ${skill}` : ''}
{' · '}
<span className={`design-card-status design-card-status-${status}`}>
{statusLabel(status, t)}
</span>
{p.status?.updatedAt ? ` · ${relativeTime(p.status.updatedAt, t)}` : ''}
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="design-kanban-board">
{STATUS_ORDER.map((status) => {
const colProjects = filtered.filter(
p => ((p.status?.value ?? 'not_started') === 'queued' ? 'running' : (p.status?.value ?? 'not_started')) === status,
);
return (
<div key={status} className="design-kanban-col">
<div className="design-kanban-header">
<span>{statusLabel(status, t)}</span>
<span className="design-kanban-count">{colProjects.length}</span>
</div>
<div className="design-kanban-list">
{colProjects.length === 0 ? (
<div className="design-kanban-empty">{t('designs.kanbanEmptyColumn')}</div>
) : (
colProjects.map((p) => {
const skill = skillName(p.skillId);
const ds = dsName(p.designSystemId);
return (
<div
key={p.id}
className={`design-kanban-card status-${status}`}
role="button"
tabIndex={0}
onClick={() => onOpen(p.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(p.id);
}
}}
>
<button
className="design-card-close"
title={t('designs.deleteTitle')}
aria-label={t('designs.deleteAria', { name: p.name })}
onClick={(e) => {
e.stopPropagation();
if (confirm(t('designs.deleteConfirm', { name: p.name }))) {
onDelete(p.id);
}
}}
>
<Icon name="close" size={12} />
</button>
<div className="design-kanban-card-name" title={p.name}>{p.name}</div>
<div className="design-kanban-card-meta">
{ds ? <span className="ds">{ds}</span> : <span>{t('designs.cardFreeform')}</span>}
{skill ? ` · ${skill}` : ''}
{p.status?.updatedAt ? ` · ${relativeTime(p.status.updatedAt, t)}` : ''}
</div>
</div>
);
})
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function statusLabel(status: ProjectDisplayStatus, t: ReturnType<typeof useT>): string {
return t(STATUS_LABEL_KEYS[status]);
}
function relativeTime(ts: number, t: ReturnType<typeof useT>): string {
const diff = Date.now() - ts;
const min = 60_000;
const hr = 60 * min;
const day = 24 * hr;
if (diff < min) return t('common.justNow');
if (diff < hr) return t('common.minutesAgo', { n: Math.floor(diff / min) });
if (diff < day) return t('common.hoursAgo', { n: Math.floor(diff / hr) });
if (diff < 7 * day) return t('common.daysAgo', { n: Math.floor(diff / day) });
return new Date(ts).toLocaleDateString();
}
+552
View File
@@ -0,0 +1,552 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n';
import {
DEFAULT_AUDIO_MODEL,
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL,
} from '../media/models';
import type {
AgentInfo,
AppConfig,
DesignSystemSummary,
Project,
ProjectKind,
ProjectMetadata,
ProjectTemplate,
PromptTemplateSummary,
SkillSummary,
} from '../types';
import { DesignsTab } from './DesignsTab';
import { DesignSystemPreviewModal } from './DesignSystemPreviewModal';
import { DesignSystemsTab } from './DesignSystemsTab';
import { ExamplesTab } from './ExamplesTab';
import { Icon } from './Icon';
import { LanguageMenu } from './LanguageMenu';
import { CenteredLoader } from './Loading';
import { NewProjectPanel, type CreateInput } from './NewProjectPanel';
import { PetRail } from './pet/PetRail';
import { PromptTemplatePreviewModal } from './PromptTemplatePreviewModal';
import { PromptTemplatesTab } from './PromptTemplatesTab';
import { apiProtocolLabel } from '../utils/apiProtocol';
type TopTab = 'designs' | 'examples' | 'design-systems' | 'image-templates' | 'video-templates';
interface Props {
skills: SkillSummary[];
designSystems: DesignSystemSummary[];
projects: Project[];
templates: ProjectTemplate[];
promptTemplates: PromptTemplateSummary[];
defaultDesignSystemId: string | null;
config: AppConfig;
agents: AgentInfo[];
loading?: boolean;
onCreateProject: (input: CreateInput & { pendingPrompt?: string }) => void;
onImportClaudeDesign: (file: File) => Promise<void> | void;
onOpenProject: (id: string) => void;
onDeleteProject: (id: string) => void;
onChangeDefaultDesignSystem: (id: string) => void;
onOpenSettings: () => void;
// Deep-link into Settings → Pets so the entry view's "Adopt a pet"
// pill drops the user straight onto the catalog instead of asking
// them to hunt for the section.
onAdoptPet: () => void;
// Inline adopt from the right-side rail — picks a pet by id and
// wakes the overlay without leaving the entry view.
onAdoptPetInline: (petId: string) => void;
// Toggle the overlay visibility (wake / tuck) from the rail.
onTogglePet: () => void;
}
const SIDEBAR_MIN = 320;
const SIDEBAR_MAX = 560;
const SIDEBAR_DEFAULT = 380;
const SIDEBAR_STORAGE_KEY = 'od-entry-sidebar-width';
// Lets the user fully remove the right-side pet rail from the entry
// layout. They re-summon it from the entry-view avatar dropdown — the
// PetRail's own collapse toggle only narrows the column, so this state
// is the "the rail isn't there at all" escape hatch.
const PET_RAIL_HIDDEN_KEY = 'open-design:pet-rail-hidden';
function loadSidebarWidth(): number {
try {
const raw = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (!raw) return SIDEBAR_DEFAULT;
const n = parseInt(raw, 10);
if (Number.isNaN(n)) return SIDEBAR_DEFAULT;
return Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, n));
} catch {
return SIDEBAR_DEFAULT;
}
}
function loadPetRailHidden(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem(PET_RAIL_HIDDEN_KEY) === '1';
} catch {
return false;
}
}
export function EntryView({
skills,
designSystems,
projects,
templates,
promptTemplates,
defaultDesignSystemId,
config,
agents,
loading = false,
onCreateProject,
onImportClaudeDesign,
onOpenProject,
onDeleteProject,
onChangeDefaultDesignSystem,
onOpenSettings,
onAdoptPet,
onAdoptPetInline,
onTogglePet,
}: Props) {
const t = useT();
const [topTab, setTopTab] = useState<TopTab>('designs');
const [previewSystemId, setPreviewSystemId] = useState<string | null>(null);
const [previewPromptTemplate, setPreviewPromptTemplate] =
useState<PromptTemplateSummary | null>(null);
const [sidebarWidth, setSidebarWidth] = useState<number>(() => loadSidebarWidth());
const [resizing, setResizing] = useState(false);
const [petRailHidden, setPetRailHiddenState] = useState<boolean>(() => loadPetRailHidden());
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false);
const avatarMenuRef = useRef<HTMLDivElement | null>(null);
function setPetRailHidden(next: boolean) {
setPetRailHiddenState(next);
try {
window.localStorage.setItem(PET_RAIL_HIDDEN_KEY, next ? '1' : '0');
} catch {
/* ignore */
}
}
const currentAgent = useMemo(
() => agents.find((a) => a.id === config.agentId) ?? null,
[agents, config.agentId],
);
const envMetaLine = useMemo(() => {
if (config.mode === 'api') {
try {
return `${config.model} · ${new URL(config.baseUrl).host}`;
} catch {
return config.model;
}
}
return currentAgent
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}`
: t('settings.noAgentSelected');
}, [config.mode, config.model, config.baseUrl, currentAgent, t]);
// 'Use this prompt' on an example card is a fast path — skip the form and
// create the project immediately with sane defaults derived from the skill,
// seeding the chat composer with the example prompt via pendingPrompt.
function usePromptFromSkill(skill: SkillSummary) {
onCreateProject({
name: skill.name,
skillId: skill.id,
designSystemId: null,
metadata: metadataForSkill(skill),
pendingPrompt: skill.examplePrompt || skill.description,
});
}
function previewDesignSystem(id: string) {
setPreviewSystemId(id);
}
const previewSystem = useMemo(
() => (previewSystemId ? designSystems.find((d) => d.id === previewSystemId) ?? null : null),
[designSystems, previewSystemId],
);
function handleCreate(input: CreateInput) {
onCreateProject(input);
}
const startWidthRef = useRef(0);
const startXRef = useRef(0);
useEffect(() => {
if (!resizing) return;
function onMove(e: MouseEvent) {
const dx = e.clientX - startXRef.current;
const next = Math.max(
SIDEBAR_MIN,
Math.min(SIDEBAR_MAX, startWidthRef.current + dx),
);
setSidebarWidth(next);
}
function onUp() {
setResizing(false);
}
document.body.classList.add('entry-resizing');
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
document.body.classList.remove('entry-resizing');
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [resizing]);
useEffect(() => {
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth));
} catch {
/* ignore */
}
}, [sidebarWidth]);
// Dismiss the avatar dropdown on outside-click / Escape so it behaves
// like the project-view AvatarMenu (which uses the same shell CSS).
useEffect(() => {
if (!avatarMenuOpen) return;
const onClick = (e: MouseEvent) => {
if (!avatarMenuRef.current) return;
if (!avatarMenuRef.current.contains(e.target as Node)) {
setAvatarMenuOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setAvatarMenuOpen(false);
};
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [avatarMenuOpen]);
// The right rail tracks its own collapse state internally and tells
// us its preferred column width via a CSS variable on the wrapper —
// we keep both the expanded and collapsed widths declarative here so
// the grid stays in sync with whatever the rail decides to render.
return (
<div
className={`entry${petRailHidden ? '' : ' has-pet-rail'}`}
style={{
gridTemplateColumns: petRailHidden
? `${sidebarWidth}px 1fr`
: `${sidebarWidth}px 1fr auto`,
}}
>
<aside className="entry-side" style={{ width: sidebarWidth }}>
<div className="entry-brand">
<span className="entry-brand-mark" aria-hidden>
<img src="/logo.svg" alt="" className="brand-mark-img" draggable={false} />
</span>
<div className="entry-brand-text">
<div className="entry-brand-title-row">
<span className="entry-brand-title">{t('app.brand')}</span>
<span className="entry-brand-pill">{t('app.brandPill')}</span>
</div>
<div className="entry-brand-subtitle">{t('app.brandSubtitle')}</div>
</div>
</div>
<NewProjectPanel
skills={skills}
designSystems={designSystems}
defaultDesignSystemId={defaultDesignSystemId}
templates={templates}
promptTemplates={promptTemplates}
onCreate={handleCreate}
onImportClaudeDesign={onImportClaudeDesign}
mediaProviders={config.mediaProviders}
loading={loading}
/>
<div className="entry-side-foot">
<button
type="button"
className={`foot-pill pet-pill${config.pet?.adopted ? '' : ' pet-pill-fresh'}`}
onClick={onAdoptPet}
title={
config.pet?.adopted
? t('pet.changePet')
: t('pet.adoptCallout')
}
>
<span className="pet-pill-glyph" aria-hidden>
{config.pet?.adopted
? config.pet.petId === 'custom'
? config.pet.custom.glyph || '🦄'
: '🐾'
: '🐾'}
</span>
<span>
{config.pet?.adopted
? t('pet.changePet')
: t('pet.adoptCallout')}
</span>
{!config.pet?.adopted ? <span className="pet-pill-dot" aria-hidden /> : null}
</button>
<button
type="button"
className="foot-pill"
onClick={onOpenSettings}
title={t('settings.envConfigure')}
>
<Icon name="settings" size={12} />
<span>
{config.mode === 'daemon'
? t('settings.localCli')
: apiProtocolLabel(config.apiProtocol)}
</span>
<span style={{ color: 'var(--text-faint)' }}>·</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>
{envMetaLine}
</span>
</button>
<LanguageMenu />
</div>
<button
type="button"
aria-label={t('entry.resizeAria')}
className={`entry-side-resizer${resizing ? ' dragging' : ''}`}
onMouseDown={(e) => {
e.preventDefault();
startWidthRef.current = sidebarWidth;
startXRef.current = e.clientX;
setResizing(true);
}}
/>
</aside>
<main className="entry-main">
<div className="entry-header">
<div className="entry-tabs" role="tablist">
<TopTabButton current={topTab} value="designs" label={t('entry.tabDesigns')} onClick={setTopTab} />
<TopTabButton current={topTab} value="examples" label={t('entry.tabExamples')} onClick={setTopTab} />
<TopTabButton
current={topTab}
value="design-systems"
label={t('entry.tabDesignSystems')}
onClick={setTopTab}
/>
<TopTabButton
current={topTab}
value="image-templates"
label={t('entry.tabImageTemplates')}
onClick={setTopTab}
/>
<TopTabButton
current={topTab}
value="video-templates"
label={t('entry.tabVideoTemplates')}
onClick={setTopTab}
/>
</div>
<div className="entry-header-right">
{/* Avatar dropdown — mirrors the project-view AvatarMenu so
users get the same anchor for cross-cutting options
(open settings, hide / show the pet rail). */}
<div className="avatar-menu" ref={avatarMenuRef}>
<button
type="button"
className="avatar-btn"
onClick={() => setAvatarMenuOpen((v) => !v)}
title={t('entry.openSettingsTitle')}
aria-label={t('entry.openSettingsAria')}
aria-haspopup="menu"
aria-expanded={avatarMenuOpen}
>
<img
src="/avatar.png"
alt=""
aria-hidden
draggable={false}
className="avatar-btn-photo"
/>
</button>
{avatarMenuOpen ? (
<div className="avatar-popover" role="menu">
<button
type="button"
className="avatar-item"
onClick={() => {
setPetRailHidden(!petRailHidden);
setAvatarMenuOpen(false);
}}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name={petRailHidden ? 'sparkles' : 'eye'} size={14} />
</span>
<span>
{petRailHidden
? t('pet.railShow')
: t('pet.railHide')}
</span>
</button>
<div style={{ height: 1, background: 'var(--border-soft)', margin: '4px 6px' }} />
<button
type="button"
className="avatar-item"
onClick={() => {
setAvatarMenuOpen(false);
onOpenSettings();
}}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="settings" size={14} />
</span>
<span>{t('avatar.settings')}</span>
</button>
</div>
) : null}
</div>
</div>
</div>
<div className="entry-tab-content">
{loading ? (
<CenteredLoader label={t('entry.loadingWorkspace')} />
) : (
<>
{topTab === 'designs' ? (
<DesignsTab
projects={projects}
skills={skills}
designSystems={designSystems}
onOpen={onOpenProject}
onDelete={onDeleteProject}
/>
) : null}
{topTab === 'examples' ? (
<ExamplesTab skills={skills} onUsePrompt={usePromptFromSkill} />
) : null}
{topTab === 'design-systems' ? (
<DesignSystemsTab
systems={designSystems}
selectedId={defaultDesignSystemId}
onSelect={onChangeDefaultDesignSystem}
onPreview={previewDesignSystem}
/>
) : null}
{topTab === 'image-templates' ? (
<PromptTemplatesTab
surface="image"
templates={promptTemplates}
onPreview={setPreviewPromptTemplate}
/>
) : null}
{topTab === 'video-templates' ? (
<PromptTemplatesTab
surface="video"
templates={promptTemplates}
onPreview={setPreviewPromptTemplate}
/>
) : null}
</>
)}
</div>
</main>
{petRailHidden ? null : (
<PetRail
config={config}
onAdoptInline={onAdoptPetInline}
onOpenPetSettings={onAdoptPet}
onTuck={onTogglePet}
onHide={() => setPetRailHidden(true)}
/>
)}
{previewSystem ? (
<DesignSystemPreviewModal
system={previewSystem}
onClose={() => setPreviewSystemId(null)}
/>
) : null}
{previewPromptTemplate ? (
<PromptTemplatePreviewModal
summary={previewPromptTemplate}
onClose={() => setPreviewPromptTemplate(null)}
/>
) : null}
</div>
);
}
function TopTabButton({
current,
value,
label,
onClick,
}: {
current: TopTab;
value: TopTab;
label: string;
onClick: (v: TopTab) => void;
}) {
return (
<button
role="tab"
data-testid={`entry-tab-${value}`}
aria-selected={current === value}
className={`entry-tab ${current === value ? 'active' : ''}`}
onClick={() => onClick(value)}
>
{label}
</button>
);
}
// Map a skill's declared mode to project metadata. Falls back to the same
// defaults the new-project form would apply (high-fidelity prototype, no
// speaker notes on decks, no template animations) so 'Use this prompt'
// produces a project indistinguishable from one created via the form. Per-
// skill hints in SKILL.md frontmatter (od.fidelity, od.speaker_notes,
// od.animations) override the defaults so each example reproduces the
// shipped example.html — e.g. wireframe-sketch declares fidelity:wireframe.
function metadataForSkill(skill: SkillSummary): ProjectMetadata {
const kind = kindForSkill(skill);
if (kind === 'prototype') {
return { kind, fidelity: skill.fidelity ?? 'high-fidelity' };
}
if (kind === 'deck') {
return {
kind,
speakerNotes:
typeof skill.speakerNotes === 'boolean' ? skill.speakerNotes : false,
};
}
if (kind === 'template') {
return {
kind,
animations:
typeof skill.animations === 'boolean' ? skill.animations : false,
};
}
if (kind === 'image') {
return { kind, imageModel: DEFAULT_IMAGE_MODEL, imageAspect: '1:1' };
}
if (kind === 'video') {
return { kind, videoModel: DEFAULT_VIDEO_MODEL, videoAspect: '16:9', videoLength: 5 };
}
if (kind === 'audio') {
return {
kind,
audioKind: 'speech',
audioModel: DEFAULT_AUDIO_MODEL.speech,
audioDuration: 10,
};
}
return { kind: 'other' };
}
function kindForSkill(skill: SkillSummary): ProjectKind {
if (skill.mode === 'deck') return 'deck';
if (skill.mode === 'prototype') return 'prototype';
if (skill.mode === 'template') return 'template';
if (skill.mode === 'image' || skill.surface === 'image') return 'image';
if (skill.mode === 'video' || skill.surface === 'video') return 'video';
if (skill.mode === 'audio' || skill.surface === 'audio') return 'audio';
return 'other';
}
+543
View File
@@ -0,0 +1,543 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../i18n';
import {
localizeSkillDescription,
localizeSkillPrompt,
} from '../i18n/content';
import type { Dict } from '../i18n/types';
import { fetchSkillExample } from '../providers/registry';
import { exportAsHtml, exportAsPdf, exportAsZip } from '../runtime/exports';
import { buildSrcdoc } from '../runtime/srcdoc';
import type { SkillSummary, Surface } from '../types';
import { Icon } from './Icon';
import { PreviewModal } from './PreviewModal';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
interface Props {
skills: SkillSummary[];
onUsePrompt: (skill: SkillSummary) => void;
}
type ModeFilter = 'all' | 'prototype-desktop' | 'prototype-mobile' | 'deck' | 'document';
type SurfaceFilter = 'all' | Surface;
type ScenarioFilter = string;
const SURFACE_PILLS: { value: SurfaceFilter; labelKey: keyof Dict }[] = [
{ value: 'all', labelKey: 'examples.modeAll' },
{ value: 'web', labelKey: 'examples.surfaceWeb' },
{ value: 'image', labelKey: 'examples.surfaceImage' },
{ value: 'video', labelKey: 'examples.surfaceVideo' },
{ value: 'audio', labelKey: 'examples.surfaceAudio' },
];
const MODE_PILLS: { value: ModeFilter; labelKey: keyof Dict }[] = [
{ value: 'all', labelKey: 'examples.modeAll' },
{ value: 'prototype-desktop', labelKey: 'examples.modePrototypeDesktop' },
{ value: 'prototype-mobile', labelKey: 'examples.modePrototypeMobile' },
{ value: 'deck', labelKey: 'examples.modeDeck' },
{ value: 'document', labelKey: 'examples.modeDocument' },
];
const SCENARIO_LABEL_KEY: Record<string, keyof Dict> = {
general: 'examples.scenarioGeneral',
engineering: 'examples.scenarioEngineering',
product: 'examples.scenarioProduct',
design: 'examples.scenarioDesign',
marketing: 'examples.scenarioMarketing',
sales: 'examples.scenarioSales',
finance: 'examples.scenarioFinance',
hr: 'examples.scenarioHr',
operations: 'examples.scenarioOperations',
support: 'examples.scenarioSupport',
legal: 'examples.scenarioLegal',
education: 'examples.scenarioEducation',
personal: 'examples.scenarioPersonal',
};
function scenarioLabel(t: TranslateFn, tag: string): string {
const key = SCENARIO_LABEL_KEY[tag];
if (key) return t(key);
return tag.charAt(0).toUpperCase() + tag.slice(1);
}
const SCENARIO_ORDER = [
'engineering',
'product',
'design',
'marketing',
'sales',
'finance',
'hr',
'operations',
'support',
'legal',
'education',
'personal',
'general',
];
function matchesMode(skill: SkillSummary, filter: ModeFilter): boolean {
if (filter === 'all') return true;
if (filter === 'deck') return skill.mode === 'deck';
if (filter === 'prototype-desktop')
return skill.mode === 'prototype' && (skill.platform ?? 'desktop') === 'desktop';
if (filter === 'prototype-mobile')
return skill.mode === 'prototype' && skill.platform === 'mobile';
if (filter === 'document') return skill.mode === 'template';
return true;
}
function surfaceOf(skill: SkillSummary): Surface {
if (skill.surface) return skill.surface;
if (skill.mode === 'image' || skill.mode === 'video' || skill.mode === 'audio') return skill.mode;
return 'web';
}
function matchesSurface(skill: SkillSummary, filter: SurfaceFilter): boolean {
return filter === 'all' || surfaceOf(skill) === filter;
}
function quotePrompt(locale: string, text: string): string {
return locale === 'de' ? `${text}` : `${text}`;
}
export function ExamplesTab({ skills, onUsePrompt }: Props) {
const { locale, t } = useI18n();
// Hold preview HTML per skill across re-renders so cards never re-flicker.
const [previews, setPreviews] = useState<Record<string, string | null>>({});
const [surfaceFilter, setSurfaceFilter] = useState<SurfaceFilter>('all');
const [modeFilter, setModeFilter] = useState<ModeFilter>('all');
const [scenarioFilter, setScenarioFilter] = useState<ScenarioFilter>('all');
// Free-text search filters by skill name + description + prompt so users
// can find a known example by typing any associated word ("airbnb",
// "wireframe", "deck") without having to click through filter pills first.
const [search, setSearch] = useState('');
const [previewSkillId, setPreviewSkillId] = useState<string | null>(null);
const loadPreview = useCallback(
async (id: string) => {
if (previews[id] !== undefined) return;
const html = await fetchSkillExample(id);
setPreviews((prev) => ({ ...prev, [id]: html }));
},
[previews],
);
// Open the modal for a card. We always trigger a preview fetch even if
// the card hasn't been hovered yet — the modal needs the HTML.
const openPreview = useCallback(
(id: string) => {
setPreviewSkillId(id);
void loadPreview(id);
},
[loadPreview],
);
const previewSkill = useMemo(
() => (previewSkillId ? skills.find((s) => s.id === previewSkillId) ?? null : null),
[skills, previewSkillId],
);
const modeCounts = useMemo(() => {
const surfaceScoped = skills.filter((skill) => matchesSurface(skill, surfaceFilter));
const c: Record<ModeFilter, number> = {
all: surfaceScoped.length,
'prototype-desktop': 0,
'prototype-mobile': 0,
deck: 0,
document: 0,
};
for (const s of surfaceScoped) {
if (matchesMode(s, 'prototype-desktop')) c['prototype-desktop']++;
if (matchesMode(s, 'prototype-mobile')) c['prototype-mobile']++;
if (matchesMode(s, 'deck')) c.deck++;
if (matchesMode(s, 'document')) c.document++;
}
return c;
}, [skills, surfaceFilter]);
const surfaceCounts = useMemo(() => {
const counts: Record<SurfaceFilter, number> = { all: skills.length, web: 0, image: 0, video: 0, audio: 0 };
for (const s of skills) counts[surfaceOf(s)]++;
return counts;
}, [skills]);
const scenarioCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const s of skills) {
if (!matchesSurface(s, surfaceFilter) || !matchesMode(s, modeFilter)) continue;
const tag = s.scenario || 'general';
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
return counts;
}, [skills, surfaceFilter, modeFilter]);
const scenarioOptions = useMemo(() => {
const have = new Set(scenarioCounts.keys());
const ordered: string[] = [];
for (const k of SCENARIO_ORDER) if (have.has(k)) ordered.push(k);
for (const k of [...have].sort()) if (!ordered.includes(k)) ordered.push(k);
return ordered;
}, [scenarioCounts]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const matched = skills.filter((s) => {
if (!matchesSurface(s, surfaceFilter) || !matchesMode(s, modeFilter)) return false;
if (scenarioFilter !== 'all' && (s.scenario || 'general') !== scenarioFilter) return false;
if (!q) return true;
const desc = localizeSkillDescription(locale, s);
const prompt = localizeSkillPrompt(locale, s) || '';
const haystack = `${s.name} ${desc} ${prompt} ${s.scenario ?? ''}`.toLowerCase();
return haystack.includes(q);
});
// Featured magazine-style examples float to the top (lower priority
// number wins). Non-featured skills keep their server-side order so
// contributors can still author SKILL.md alphabetically.
return matched
.map((s, idx) => ({ s, idx }))
.sort((a, b) => {
const aRank = typeof a.s.featured === 'number' ? a.s.featured : Number.POSITIVE_INFINITY;
const bRank = typeof b.s.featured === 'number' ? b.s.featured : Number.POSITIVE_INFINITY;
if (aRank !== bRank) return aRank - bRank;
return a.idx - b.idx;
})
.map(({ s }) => s);
}, [skills, surfaceFilter, modeFilter, scenarioFilter, search, locale]);
if (skills.length === 0) {
return <div className="tab-empty">{t('examples.emptyNoSkills')}</div>;
}
return (
<div className="tab-panel examples-panel">
<div className="examples-toolbar">
<div className="examples-search">
<span className="search-icon" aria-hidden>
<Icon name="search" size={13} />
</span>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('examples.searchPlaceholder')}
aria-label={t('examples.searchAria')}
/>
</div>
<div
className="examples-filter-row"
role="tablist"
aria-label={t('examples.surfaceLabel')}
>
<span className="examples-filter-label">{t('examples.surfaceLabel')}</span>
{SURFACE_PILLS.map((p) => (
<button
key={p.value}
type="button"
role="tab"
aria-selected={surfaceFilter === p.value}
className={`filter-pill ${surfaceFilter === p.value ? 'active' : ''}`}
onClick={() => {
setSurfaceFilter(p.value);
setModeFilter('all');
setScenarioFilter('all');
}}
>
{t(p.labelKey)}
<span className="filter-pill-count">{surfaceCounts[p.value]}</span>
</button>
))}
</div>
<div
className="examples-filter-row"
role="tablist"
aria-label={t('examples.typeLabel')}
>
<span className="examples-filter-label">{t('examples.typeLabel')}</span>
{MODE_PILLS.map((p) => (
<button
key={p.value}
type="button"
role="tab"
aria-selected={modeFilter === p.value}
className={`filter-pill ${modeFilter === p.value ? 'active' : ''}`}
onClick={() => {
setModeFilter(p.value);
setScenarioFilter('all');
}}
>
{t(p.labelKey)}
<span className="filter-pill-count">{modeCounts[p.value]}</span>
</button>
))}
</div>
{scenarioOptions.length > 1 ? (
<div
className="examples-filter-row"
role="tablist"
aria-label={t('examples.scenarioLabel')}
>
<span className="examples-filter-label">
{t('examples.scenarioLabel')}
</span>
<button
type="button"
className={`filter-pill ${scenarioFilter === 'all' ? 'active' : ''}`}
onClick={() => setScenarioFilter('all')}
>
{t('examples.modeAll')}
<span className="filter-pill-count">{filtered.length}</span>
</button>
{scenarioOptions.map((tag) => (
<button
key={tag}
type="button"
className={`filter-pill ${scenarioFilter === tag ? 'active' : ''}`}
onClick={() => setScenarioFilter(tag)}
>
{scenarioLabel(t, tag)}
<span className="filter-pill-count">{scenarioCounts.get(tag) ?? 0}</span>
</button>
))}
</div>
) : null}
</div>
{filtered.length === 0 ? (
<div className="tab-empty">{t('examples.emptyNoMatch')}</div>
) : (
filtered.map((skill) => (
<ExampleCard
key={skill.id}
skill={skill}
html={previews[skill.id]}
onLoad={() => void loadPreview(skill.id)}
onUsePrompt={() => onUsePrompt(skill)}
onOpenPreview={() => openPreview(skill.id)}
/>
))
)}
{previewSkill ? (
<PreviewModal
title={previewSkill.name}
subtitle={
localizeSkillPrompt(locale, previewSkill)
?? localizeSkillDescription(locale, previewSkill).slice(0, 160)
}
views={[
{
id: 'preview',
label: t('examples.previewLabel'),
html: previews[previewSkill.id],
deck: previewSkill.mode === 'deck',
},
]}
exportTitleFor={() => previewSkill.name}
onClose={() => setPreviewSkillId(null)}
/>
) : null}
</div>
);
}
function ExampleCard({
skill,
html,
onLoad,
onUsePrompt,
onOpenPreview,
}: {
skill: SkillSummary;
html: string | null | undefined;
onLoad: () => void;
onUsePrompt: () => void;
onOpenPreview: () => void;
}) {
const { locale, t } = useI18n();
const [hovered, setHovered] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const shareRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!shareOpen) return;
const onDoc = (e: MouseEvent) => {
if (!shareRef.current) return;
if (!shareRef.current.contains(e.target as Node)) setShareOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShareOpen(false);
};
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [shareOpen]);
const exportTitle = skill.name;
const isMobile = skill.platform === 'mobile';
const isDeck = skill.mode === 'deck';
const displayPrompt = localizeSkillPrompt(locale, skill);
const displayDescription = localizeSkillDescription(locale, skill).slice(0, 240);
return (
<div
className="example-card"
data-testid={`example-card-${skill.id}`}
onMouseEnter={() => {
setHovered(true);
onLoad();
}}
onMouseLeave={() => setHovered(false)}
>
<div
className="example-preview"
role="button"
tabIndex={0}
title={t('common.openPreview')}
onClick={onOpenPreview}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpenPreview();
}
}}
>
{html ? (
<>
<iframe
title={`${skill.name} ${t('examples.previewLabel').toLowerCase()}`}
sandbox="allow-scripts"
srcDoc={buildSrcdoc(html)}
tabIndex={-1}
/>
<span className="example-preview-overlay" aria-hidden="true">
{t('examples.openPreview')}
</span>
</>
) : (
<div className="example-preview-placeholder">
{hovered
? t('examples.loadingPreview')
: t('examples.hoverPreview')}
</div>
)}
</div>
<div className="example-meta">
<div className="example-name">{skill.name}</div>
<div className="example-tags">
<span className={`example-tag ${isMobile ? 'platform-mobile' : ''} ${isDeck ? 'mode-deck' : ''}`}>
{tagForSkill(skill, t)}
</span>
{skill.scenario && skill.scenario !== 'general' ? (
<span className="example-tag">
{scenarioLabel(t, skill.scenario)}
</span>
) : null}
</div>
<div className="example-prompt">
{displayPrompt ? quotePrompt(locale, displayPrompt) : displayDescription}
</div>
<div className="example-card-actions">
<button
className="primary example-cta"
data-testid={`example-use-prompt-${skill.id}`}
onClick={onUsePrompt}
>
{t('examples.usePrompt')}
</button>
<button
className="ghost"
onClick={onOpenPreview}
title={t('examples.previewModalTitle')}
>
{t('examples.openPreview')}
</button>
<div className="share-menu" ref={shareRef}>
<button
className="ghost"
aria-haspopup="menu"
aria-expanded={shareOpen}
disabled={!html}
title={
html
? t('examples.shareTitle')
: t('examples.shareLoadFirst')
}
onClick={() => setShareOpen((v) => !v)}
>
{t('examples.shareMenu')}
</button>
{shareOpen && html ? (
<div className="share-menu-popover" role="menu">
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareOpen(false);
exportAsPdf(html, exportTitle, { deck: isDeck });
}}
>
<span className="share-menu-icon">📄</span>
<span>
{isDeck
? t('examples.exportPdfAllSlides')
: t('common.exportPdf')}
</span>
</button>
{isDeck ? (
<button
type="button"
className="share-menu-item"
role="menuitem"
title={t('examples.exportPptxLocked')}
disabled
>
<span className="share-menu-icon">📊</span>
<span>{t('examples.exportPptxLocked')}</span>
</button>
) : null}
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareOpen(false);
exportAsZip(html, exportTitle);
}}
>
<span className="share-menu-icon">🗜</span>
<span>{t('common.exportZip')}</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareOpen(false);
exportAsHtml(html, exportTitle);
}}
>
<span className="share-menu-icon">🌐</span>
<span>{t('common.exportHtml')}</span>
</button>
</div>
) : null}
</div>
</div>
</div>
</div>
);
}
function tagForSkill(skill: SkillSummary, t: TranslateFn): string {
if (skill.mode === 'deck') return t('examples.tagSlideDeck');
if (skill.mode === 'template') return t('examples.tagTemplate');
if (skill.mode === 'design-system') return t('examples.tagDesignSystem');
if (skill.platform === 'mobile') return t('examples.tagMobilePrototype');
return t('examples.tagDesktopPrototype');
}
+188
View File
@@ -0,0 +1,188 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { FileViewer, SvgViewer } from './FileViewer';
import type { ProjectFile } from '../types';
function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
return {
name: 'asset.png',
path: 'asset.png',
type: 'file',
size: 1024,
mtime: 1710000000,
kind: 'image',
mime: 'image/png',
...overrides,
};
}
describe('FileViewer SVG artifacts', () => {
it('routes SVG artifacts to the SVG viewer instead of the generic image viewer', () => {
const file = baseFile({
name: 'diagram.svg',
path: 'diagram.svg',
mime: 'image/svg+xml',
artifactManifest: {
version: 1,
kind: 'svg',
title: 'Diagram',
entry: 'diagram.svg',
renderer: 'svg',
exports: ['svg'],
},
});
const markup = renderToStaticMarkup(<FileViewer projectId="project-1" file={file} />);
expect(markup).toContain('class="viewer svg-viewer"');
expect(markup).not.toContain('class="viewer image-viewer"');
expect(markup).toContain('Preview');
expect(markup).toContain('Source');
expect(markup).toContain('src="/api/projects/project-1/raw/diagram.svg?v=1710000000&amp;r=0"');
});
it('keeps normal image artifacts on the existing image viewer path', () => {
const file = baseFile({ name: 'photo.png', path: 'photo.png' });
const markup = renderToStaticMarkup(<FileViewer projectId="project-1" file={file} />);
expect(markup).toContain('class="viewer image-viewer"');
expect(markup).not.toContain('class="viewer svg-viewer"');
expect(markup).not.toContain('class="viewer-tabs"');
});
it('marks preview and source modes through the SVG viewer toggle controls', () => {
const file = baseFile({ name: 'diagram.svg', path: 'diagram.svg', mime: 'image/svg+xml' });
const previewMarkup = renderToStaticMarkup(
<SvgViewer projectId="project-1" file={file} initialMode="preview" />,
);
const sourceMarkup = renderToStaticMarkup(
<SvgViewer
projectId="project-1"
file={file}
initialMode="source"
initialSource="<svg><title>Diagram</title></svg>"
/>,
);
expect(previewMarkup).toContain('class="viewer-tab active" aria-pressed="true">Preview</button>');
expect(previewMarkup).toContain('aria-pressed="false">Source</button>');
expect(previewMarkup).toContain('<img');
expect(sourceMarkup).toContain('aria-pressed="false">Preview</button>');
expect(sourceMarkup).toContain('class="viewer-tab active" aria-pressed="true">Source</button>');
expect(sourceMarkup).toContain('class="viewer-source"');
expect(sourceMarkup).not.toContain('<img');
});
it('URL-loads a plain HTML preview iframe instead of inlining via srcDoc', () => {
const file = baseFile({
name: 'page.html',
path: 'page.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'page.html',
renderer: 'html',
exports: ['html'],
},
});
const markup = renderToStaticMarkup(
<FileViewer projectId="project-1" file={file} liveHtml="<html><body>hi</body></html>" />,
);
expect(markup).toContain('data-testid="artifact-preview-frame"');
expect(markup).toContain('data-od-render-mode="url-load"');
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&amp;r=0"');
expect(markup).not.toContain('data-od-render-mode="srcdoc"');
});
it('keeps decks on the srcDoc path so the deck postMessage bridge can run', () => {
const file = baseFile({
name: 'deck.html',
path: 'deck.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'deck',
title: 'Deck',
entry: 'deck.html',
renderer: 'deck-html',
exports: ['html'],
},
});
const markup = renderToStaticMarkup(
<FileViewer
projectId="project-1"
file={file}
isDeck
liveHtml={'<html><body><section class="slide">one</section></body></html>'}
/>,
);
expect(markup).toContain('data-testid="artifact-preview-frame"');
expect(markup).toContain('data-od-render-mode="srcdoc"');
expect(markup).not.toContain('data-od-render-mode="url-load"');
});
it('falls back to srcDoc when the HTML body looks deck-shaped even without an isDeck hint', () => {
const file = baseFile({
name: 'inferred.html',
path: 'inferred.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Inferred',
entry: 'inferred.html',
renderer: 'html',
exports: ['html'],
},
});
const markup = renderToStaticMarkup(
<FileViewer
projectId="project-1"
file={file}
liveHtml={'<html><body><section class="slide">one</section><section class="slide">two</section></body></html>'}
/>,
);
expect(markup).toContain('data-od-render-mode="srcdoc"');
expect(markup).not.toContain('data-od-render-mode="url-load"');
});
it('renders unsafe SVG source as escaped text instead of executable markup', () => {
const file = baseFile({ name: 'unsafe.svg', path: 'unsafe.svg', mime: 'image/svg+xml' });
const unsafeSource = [
'<svg onload="alert(1)"><script>alert(2)</script><text>Logo</text></svg>',
'<svg><![CDATA[<script>alert(3)</script>]]></svg>',
].join('\n');
const markup = renderToStaticMarkup(
<SvgViewer
projectId="project-1"
file={file}
initialMode="source"
initialSource={unsafeSource}
/>,
);
expect(markup).toContain('&lt;svg onload=&quot;alert(1)&quot;&gt;');
expect(markup).toContain('&lt;script&gt;alert(2)&lt;/script&gt;');
expect(markup).toContain('&lt;![CDATA[&lt;script&gt;alert(3)&lt;/script&gt;]]&gt;');
expect(markup).not.toContain('<svg onload');
expect(markup).not.toContain('<script>');
expect(markup).not.toContain('<![CDATA[');
expect(markup).not.toContain('dangerouslySetInnerHTML');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { FileWorkspace } from './FileWorkspace';
describe('FileWorkspace upload input', () => {
it('keeps the Design Files picker aligned with drag-and-drop file support', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
files={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
expect(markup).toContain('data-testid="design-files-upload-input"');
expect(markup).not.toContain('accept=');
});
});
+539
View File
@@ -0,0 +1,539 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n';
import {
deleteProjectFile,
fetchProjectFileText,
uploadProjectFiles,
writeProjectTextFile,
} from '../providers/registry';
import type { OpenTabsState, PreviewComment, PreviewCommentTarget, ProjectFile } from '../types';
import { DesignFilesPanel } from './DesignFilesPanel';
import { FileViewer } from './FileViewer';
import { Icon } from './Icon';
import { PasteTextDialog } from './PasteTextDialog';
import { SketchEditor, type SketchDocument, type SketchItem } from './SketchEditor';
interface Props {
projectId: string;
files: ProjectFile[];
onRefreshFiles: () => Promise<void> | void;
isDeck: boolean;
onExportAsPptx?: ((fileName: string) => void) | undefined;
streaming?: boolean;
openRequest?: { name: string; nonce: number } | null;
// Persisted set of open tabs + active tab. Owned by ProjectView so the
// daemon's SQLite store can hold the source of truth and survive reloads.
tabsState: OpenTabsState;
onTabsStateChange: (next: OpenTabsState) => void;
previewComments?: PreviewComment[];
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
onRemovePreviewComment?: (commentId: string) => Promise<void>;
}
interface SketchState {
items: SketchItem[];
dirty: boolean;
persisted: boolean;
loaded: boolean;
saving: boolean;
}
const DESIGN_FILES_TAB = '__design_files__';
export function FileWorkspace({
projectId,
files,
onRefreshFiles,
isDeck,
onExportAsPptx,
streaming,
openRequest,
tabsState,
onTabsStateChange,
previewComments = [],
onSavePreviewComment,
onRemovePreviewComment,
}: Props) {
const t = useT();
// Persisted tabs come from the parent. Active tab can transiently point
// at a pending sketch — pending sketches are not in tabsState.tabs.
const persistedTabs = tabsState.tabs;
const [activeTab, setActiveTab] = useState<string>(
tabsState.active ?? DESIGN_FILES_TAB,
);
const [showPasteDialog, setShowPasteDialog] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [sketches, setSketches] = useState<Record<string, SketchState>>({});
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Pull the persisted active tab in when the parent's hydration completes
// (or on project switch). Fall back to the Design Files browser so a
// fresh project lands in a useful place.
useEffect(() => {
setActiveTab(tabsState.active ?? DESIGN_FILES_TAB);
}, [tabsState.active]);
function setPersistedActive(name: string | null) {
setActiveTab(name ?? DESIGN_FILES_TAB);
onTabsStateChange({ tabs: persistedTabs, active: name });
}
function activatePending(name: string) {
// Pending sketches are not in tabsState.tabs — flip the local
// activeTab without round-tripping through the parent.
setActiveTab(name);
}
// When the persisted tab list changes and the active tab is gone, fall
// back to the last remaining tab. Skip transient activeTab values
// (DESIGN_FILES_TAB, pending sketches) since those aren't in persistedTabs.
useEffect(() => {
if (activeTab === DESIGN_FILES_TAB) return;
if (sketches[activeTab] && !sketches[activeTab]!.persisted) return;
if (!persistedTabs.includes(activeTab)) {
setPersistedActive(persistedTabs[persistedTabs.length - 1] ?? null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [persistedTabs, activeTab]);
// External open requests from chat (tool cards, produced-file chips,
// deep-linked URL, or the parent's auto-open after an agent Write) —
// add the file to the open-tabs set and focus it.
useEffect(() => {
if (!openRequest) return;
const name = openRequest.name;
if (!name) return;
onTabsStateChange({
tabs: persistedTabs.includes(name) ? persistedTabs : [...persistedTabs, name],
active: name,
});
setActiveTab(name);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openRequest]);
function openFile(name: string) {
onTabsStateChange({
tabs: persistedTabs.includes(name) ? persistedTabs : [...persistedTabs, name],
active: name,
});
setActiveTab(name);
}
function closeTab(name: string) {
const isPending = sketches[name] && !sketches[name]!.persisted;
if (isPending) {
setSketches((curr) => {
const next = { ...curr };
delete next[name];
return next;
});
if (activeTab === name) {
setPersistedActive(persistedTabs[persistedTabs.length - 1] ?? null);
}
return;
}
const nextTabs = persistedTabs.filter((n) => n !== name);
const nextActive =
tabsState.active === name
? nextTabs[nextTabs.length - 1] ?? null
: tabsState.active;
onTabsStateChange({ tabs: nextTabs, active: nextActive });
setActiveTab(nextActive ?? DESIGN_FILES_TAB);
setSketches((curr) => {
const next = { ...curr };
const entry = next[name];
if (entry && !entry.persisted) delete next[name];
return next;
});
}
async function handleFilePicked(ev: React.ChangeEvent<HTMLInputElement>) {
const picked = Array.from(ev.target.files ?? []);
ev.target.value = '';
await uploadFiles(picked);
}
async function uploadFiles(picked: File[]) {
if (picked.length === 0) return;
setUploadError(null);
const result = await uploadProjectFiles(projectId, picked);
if (result.uploaded.length > 0) {
await onRefreshFiles();
const lastUploaded = result.uploaded[result.uploaded.length - 1];
if (lastUploaded?.path) openFile(lastUploaded.path);
}
if (result.failed.length > 0) {
const failedCount = result.failed.length;
const uploadedCount = result.uploaded.length;
const detail = result.error ? ` (${result.error})` : '';
setUploadError(
uploadedCount > 0
? `Uploaded ${uploadedCount} file(s), but ${failedCount} failed${detail}.`
: `Upload failed for ${failedCount} file(s)${detail}.`,
);
console.warn('Project upload had failures', result.failed);
}
}
useEffect(() => {
const hasFiles = (e: DragEvent) =>
Array.from(e.dataTransfer?.types ?? []).includes('Files');
const isAllowedDropTarget = (target: EventTarget | null) => {
if (!(target instanceof Element)) return false;
return Boolean(target.closest('.df-drop, .composer'));
};
const onDragOver = (e: DragEvent) => {
if (!hasFiles(e) || isAllowedDropTarget(e.target)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'none';
};
const onDrop = (e: DragEvent) => {
if (!hasFiles(e) || isAllowedDropTarget(e.target)) return;
e.preventDefault();
};
window.addEventListener('dragover', onDragOver);
window.addEventListener('drop', onDrop);
return () => {
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('drop', onDrop);
};
}, []);
async function handleDelete(name: string) {
if (!confirm(t('workspace.deleteFileConfirm', { name }))) return;
const ok = await deleteProjectFile(projectId, name);
if (ok) {
await onRefreshFiles();
const nextTabs = persistedTabs.filter((n) => n !== name);
if (activeTab === name) {
// User is viewing the file being deleted: fall back to another
// open tab (or the Design Files panel if none remain).
const nextActive = nextTabs[nextTabs.length - 1] ?? null;
onTabsStateChange({ tabs: nextTabs, active: nextActive });
setActiveTab(nextActive ?? DESIGN_FILES_TAB);
} else {
// Deletion was triggered from the Design Files panel (or another
// tab). We preserve `activeTab` because the user is viewing a
// different context (Design Files or another tab) and shouldn't
// be navigated away. Only clear the persisted active reference
// when it points at the deleted file so we don't leave a dangling
// pointer behind.
const nextActive = tabsState.active === name ? null : tabsState.active;
onTabsStateChange({ tabs: nextTabs, active: nextActive });
}
setSketches((curr) => {
const next = { ...curr };
delete next[name];
return next;
});
}
}
function startNewSketch() {
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const name = `sketch-${stamp}.sketch.json`;
setSketches((curr) => ({
...curr,
[name]: { items: [], dirty: false, persisted: false, loaded: true, saving: false },
}));
activatePending(name);
}
// When the active tab is a sketch we don't have items for yet, load from
// disk. Pending sketches start with loaded=true and skip this path.
useEffect(() => {
if (activeTab === DESIGN_FILES_TAB) return;
if (!isSketchName(activeTab)) return;
if (sketches[activeTab]?.loaded) return;
let cancelled = false;
void fetchProjectFileText(projectId, activeTab).then((text) => {
if (cancelled) return;
const items = parseSketchDocument(text);
setSketches((curr) => ({
...curr,
[activeTab]: {
items,
dirty: false,
persisted: true,
loaded: true,
saving: false,
},
}));
});
return () => {
cancelled = true;
};
}, [activeTab, projectId, sketches]);
function setSketchItems(name: string, items: SketchItem[]) {
setSketches((curr) => ({
...curr,
[name]: {
...(curr[name] ?? { persisted: false, loaded: true, saving: false }),
items,
dirty: true,
} as SketchState,
}));
}
async function saveSketch(name: string) {
const entry = sketches[name];
if (!entry) return;
setSketches((curr) => ({ ...curr, [name]: { ...curr[name]!, saving: true } }));
const doc: SketchDocument = { version: 1, items: entry.items };
const file = await writeProjectTextFile(projectId, name, JSON.stringify(doc, null, 2));
if (file) {
setSketches((curr) => ({
...curr,
[name]: { ...curr[name]!, dirty: false, persisted: true, saving: false },
}));
// Promote the previously-pending sketch into the persisted tab list.
onTabsStateChange({
tabs: persistedTabs.includes(name) ? persistedTabs : [...persistedTabs, name],
active: name,
});
setActiveTab(name);
await onRefreshFiles();
} else {
setSketches((curr) => ({ ...curr, [name]: { ...curr[name]!, saving: false } }));
}
}
const activeFile = useMemo<ProjectFile | null>(() => {
if (activeTab === DESIGN_FILES_TAB) return null;
const onDisk = files.find((f) => f.name === activeTab);
if (onDisk) return onDisk;
if (isSketchName(activeTab) && sketches[activeTab]) {
return {
name: activeTab,
size: 0,
mtime: Date.now(),
kind: 'sketch',
mime: 'application/json',
};
}
return null;
}, [activeTab, files, sketches]);
// Tabs rendered are persisted tabs plus any pending (un-saved) sketches.
const tabNames = useMemo(() => {
const seen = new Set(persistedTabs);
const extras: string[] = [];
for (const name of Object.keys(sketches)) {
if (!sketches[name]?.persisted && !seen.has(name)) {
extras.push(name);
seen.add(name);
}
}
return [...persistedTabs, ...extras];
}, [persistedTabs, sketches]);
const isActiveSketch = activeFile?.kind === 'sketch' && isSketchName(activeFile.name);
const activeSketch = activeFile && isActiveSketch ? sketches[activeFile.name] : null;
return (
<div className="workspace" data-testid="file-workspace">
<div className="ws-tabs-bar" role="tablist" aria-label={t('workspace.designFiles')}>
<button
type="button"
className={`ws-tab design-files-tab ${activeTab === DESIGN_FILES_TAB ? 'active' : ''}`}
role="tab"
aria-selected={activeTab === DESIGN_FILES_TAB}
tabIndex={0}
data-testid="design-files-tab"
onClick={() => setActiveTab(DESIGN_FILES_TAB)}
title={t('workspace.designFiles')}
>
<span className="tab-icon" aria-hidden>
<Icon name="grid" size={13} />
</span>
<span className="ws-tab-label">{t('workspace.designFiles')}</span>
</button>
{tabNames.map((name) => {
const sketchEntry = sketches[name];
const dirtyMark =
sketchEntry && (sketchEntry.dirty || !sketchEntry.persisted) ? ' •' : '';
const isPending = sketchEntry && !sketchEntry.persisted;
const onDisk = files.find((f) => f.name === name);
const kind = onDisk?.kind ?? (isSketchName(name) ? 'sketch' : 'text');
return (
<Tab
key={name}
label={`${name}${dirtyMark}`}
active={activeTab === name}
onActivate={() =>
isPending ? activatePending(name) : setPersistedActive(name)
}
onClose={() => closeTab(name)}
kind={kind}
/>
);
})}
</div>
<div className="ws-body">
{uploadError ? <div className="viewer-empty">{uploadError}</div> : null}
{activeTab === DESIGN_FILES_TAB ? (
<DesignFilesPanel
projectId={projectId}
files={files}
onRefreshFiles={onRefreshFiles}
onOpenFile={openFile}
onDeleteFile={(name) => void handleDelete(name)}
onUpload={() => fileInputRef.current?.click()}
onUploadFiles={(picked) => void uploadFiles(picked)}
onPaste={() => setShowPasteDialog(true)}
onNewSketch={startNewSketch}
/>
) : isActiveSketch && activeSketch && activeFile ? (
activeSketch.loaded ? (
<SketchEditor
fileName={activeFile.name}
items={activeSketch.items}
onItemsChange={(items) => setSketchItems(activeFile.name, items)}
onSave={() => saveSketch(activeFile.name)}
saving={activeSketch.saving}
dirty={activeSketch.dirty || !activeSketch.persisted}
onCancel={() => closeTab(activeFile.name)}
/>
) : (
<div className="viewer-empty">{t('workspace.loadingSketch')}</div>
)
) : activeFile ? (
<FileViewer
projectId={projectId}
file={activeFile}
isDeck={isDeck}
onExportAsPptx={onExportAsPptx}
streaming={streaming}
previewComments={previewComments.filter((comment) => comment.filePath === activeFile.name)}
onSavePreviewComment={onSavePreviewComment}
onRemovePreviewComment={onRemovePreviewComment}
/>
) : (
<div className="viewer-empty">
{t('workspace.openFromDesignFiles')}{' '}
<a
className="link"
href="#"
onClick={(e) => {
e.preventDefault();
setActiveTab(DESIGN_FILES_TAB);
}}
>
{t('workspace.designFilesLink')}
</a>
.
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
multiple
data-testid="design-files-upload-input"
style={{ display: 'none' }}
onChange={handleFilePicked}
/>
{showPasteDialog ? (
<PasteTextDialog
onClose={() => setShowPasteDialog(false)}
onSave={async (name, content) => {
setShowPasteDialog(false);
const file = await writeProjectTextFile(projectId, name, content);
if (file) {
await onRefreshFiles();
openFile(file.name);
}
}}
/>
) : null}
</div>
);
}
function Tab({
label,
active,
onActivate,
onClose,
closable = true,
kind,
}: {
label: string;
active: boolean;
onActivate: () => void;
onClose?: () => void;
closable?: boolean;
kind?: ProjectFile['kind'];
}) {
const t = useT();
const iconName = kindIconName(kind);
return (
<div
className={`ws-tab ${active ? 'active' : ''}`}
onClick={onActivate}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onActivate();
}
}}
role="tab"
aria-selected={active}
tabIndex={0}
>
{iconName ? (
<span className="tab-icon" aria-hidden>
<Icon name={iconName} size={13} />
</span>
) : null}
<span className="ws-tab-label">{label}</span>
{closable && onClose ? (
<button
type="button"
className="ws-tab-close"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
title={t('workspace.closeTab')}
>
<Icon name="close" size={11} />
</button>
) : null}
</div>
);
}
function kindIconName(
kind?: string,
):
| 'file-code'
| 'image'
| 'pencil'
| 'file'
| null {
if (kind === 'html') return 'file-code';
if (kind === 'image') return 'image';
if (kind === 'sketch') return 'pencil';
if (kind === 'code') return 'file-code';
if (kind === 'text') return 'file';
return 'file';
}
function isSketchName(name: string): boolean {
return name.endsWith('.sketch.json');
}
function parseSketchDocument(text: string | null): SketchItem[] {
if (!text) return [];
try {
const parsed = JSON.parse(text) as SketchDocument | { items?: SketchItem[] };
return Array.isArray(parsed.items) ? parsed.items : [];
} catch {
return [];
}
}
+434
View File
@@ -0,0 +1,434 @@
import type { SVGProps } from 'react';
type IconName =
| 'arrow-left'
| 'arrow-up'
| 'attach'
| 'bell'
| 'check'
| 'chevron-down'
| 'chevron-left'
| 'chevron-right'
| 'close'
| 'copy'
| 'comment'
| 'download'
| 'draw'
| 'edit'
| 'eye'
| 'file'
| 'file-code'
| 'folder'
| 'grid'
| 'history'
| 'image'
| 'import'
| 'kanban'
| 'languages'
| 'link'
| 'mic'
| 'minus'
| 'pencil'
| 'plus'
| 'play'
| 'present'
| 'refresh'
| 'reload'
| 'search'
| 'send'
| 'settings'
| 'share'
| 'sliders'
| 'spinner'
| 'sparkles'
| 'stop'
| 'sun-moon'
| 'tweaks'
| 'upload'
| 'zoom-in'
| 'zoom-out';
interface Props extends Omit<SVGProps<SVGSVGElement>, 'name'> {
name: IconName;
size?: number | string;
}
/**
* Lightweight inline-SVG icon set tuned to the design system. Stroke-based
* (Feather/Lucide style) so they pair cleanly with `currentColor` and adopt
* the local text color. Use sparingly inside buttons that already have
* accessible labels — set `aria-hidden` by default.
*/
export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) {
const common = {
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
'aria-hidden': true,
focusable: 'false' as const,
...rest,
};
switch (name) {
case 'arrow-left':
return (
<svg {...common}>
<path d="M19 12H5" />
<path d="m12 19-7-7 7-7" />
</svg>
);
case 'arrow-up':
return (
<svg {...common}>
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
</svg>
);
case 'attach':
return (
<svg {...common}>
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
);
case 'bell':
return (
<svg {...common}>
<path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
);
case 'check':
return (
<svg {...common}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
case 'chevron-down':
return (
<svg {...common}>
<path d="m6 9 6 6 6-6" />
</svg>
);
case 'chevron-left':
return (
<svg {...common}>
<path d="m15 18-6-6 6-6" />
</svg>
);
case 'chevron-right':
return (
<svg {...common}>
<path d="m9 18 6-6-6-6" />
</svg>
);
case 'close':
return (
<svg {...common}>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
case 'copy':
return (
<svg {...common}>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
case 'comment':
return (
<svg {...common}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
case 'download':
return (
<svg {...common}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m7 10 5 5 5-5" />
<path d="M12 15V3" />
</svg>
);
case 'draw':
return (
<svg {...common}>
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25z" />
<path d="m14.06 6.19 3.75 3.75" />
</svg>
);
case 'edit':
return (
<svg {...common}>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
case 'eye':
return (
<svg {...common}>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
case 'file':
return (
<svg {...common}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</svg>
);
case 'file-code':
return (
<svg {...common}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="m10 13-2 2 2 2" />
<path d="m14 17 2-2-2-2" />
</svg>
);
case 'folder':
return (
<svg {...common}>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
);
case 'grid':
return (
<svg {...common}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
);
case 'history':
return (
<svg {...common}>
<path d="M3 12a9 9 0 1 0 3-6.7" />
<path d="M3 4v5h5" />
<path d="M12 7v5l3 2" />
</svg>
);
case 'image':
return (
<svg {...common}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-4.5-4.5L7 20" />
</svg>
);
case 'import':
return (
<svg {...common}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m17 8-5-5-5 5" />
<path d="M12 3v12" />
</svg>
);
case 'kanban':
return (
<svg {...common}>
<rect x="3" y="4" width="5" height="16" rx="1" />
<rect x="10" y="4" width="5" height="10" rx="1" />
<rect x="17" y="4" width="4" height="13" rx="1" />
</svg>
);
case 'languages':
return (
<svg {...common}>
<path d="m5 8 6 6" />
<path d="m4 14 6-6 2-3" />
<path d="M2 5h12" />
<path d="M7 2h1" />
<path d="m22 22-5-10-5 10" />
<path d="M14 18h6" />
</svg>
);
case 'link':
return (
<svg {...common}>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 1 0-7.07-7.07L11.75 5.18" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 1 0 7.07 7.07l1.71-1.71" />
</svg>
);
case 'mic':
return (
<svg {...common}>
<rect x="9" y="2" width="6" height="11" rx="3" />
<path d="M19 10v1a7 7 0 0 1-14 0v-1" />
<path d="M12 18v3" />
</svg>
);
case 'minus':
return (
<svg {...common}>
<path d="M5 12h14" />
</svg>
);
case 'pencil':
return (
<svg {...common}>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z" />
</svg>
);
case 'plus':
return (
<svg {...common}>
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
);
case 'play':
return (
<svg {...common}>
<path d="M6 4v16l14-8z" />
</svg>
);
case 'present':
return (
<svg {...common}>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8" />
<path d="M12 17v4" />
</svg>
);
case 'refresh':
return (
<svg {...common}>
<path d="M3 12a9 9 0 0 1 15.9-5.7L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-15.9 5.7L3 16" />
<path d="M3 21v-5h5" />
</svg>
);
case 'reload':
return (
<svg {...common}>
<path d="M21 12a9 9 0 1 1-3-6.7" />
<path d="M21 4v5h-5" />
</svg>
);
case 'search':
return (
<svg {...common}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
);
case 'send':
return (
<svg {...common}>
<path d="M22 2 11 13" />
<path d="m22 2-7 20-4-9-9-4z" />
</svg>
);
case 'settings':
return (
<svg {...common}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 0 1-2.82 2.83l-.06-.07a1.7 1.7 0 0 0-1.88-.33 1.7 1.7 0 0 0-1.04 1.56V21a2 2 0 0 1-4 0v-.1A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.82l.07-.06a1.7 1.7 0 0 0 .33-1.88 1.7 1.7 0 0 0-1.56-1.04H3a2 2 0 0 1 0-4h.1a1.7 1.7 0 0 0 1.56-1.04 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.07A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1.04-1.56V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1.04 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.07.06a1.7 1.7 0 0 0-.33 1.87V9a1.7 1.7 0 0 0 1.56 1.04H21a2 2 0 0 1 0 4h-.1a1.7 1.7 0 0 0-1.56 1.04Z" />
</svg>
);
case 'share':
return (
<svg {...common}>
<path d="M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7" />
<path d="m16 6-4-4-4 4" />
<path d="M12 2v13" />
</svg>
);
case 'sliders':
return (
<svg {...common}>
<path d="M4 21v-7" />
<path d="M4 10V3" />
<path d="M12 21v-9" />
<path d="M12 8V3" />
<path d="M20 21v-5" />
<path d="M20 12V3" />
<path d="M1 14h6" />
<path d="M9 8h6" />
<path d="M17 16h6" />
</svg>
);
case 'spinner':
return (
<svg {...common} className={`icon-spin ${rest.className ?? ''}`.trim()}>
<path d="M21 12a9 9 0 1 1-6.22-8.56" />
</svg>
);
case 'sparkles':
return (
<svg {...common}>
<path d="m12 3 1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5z" />
<path d="M19 14v3" />
<path d="M19 21v-1" />
<path d="M22 17h-3" />
<path d="M16 17h-1" />
</svg>
);
case 'stop':
return (
<svg {...common}>
<rect x="6" y="6" width="12" height="12" rx="1.5" />
</svg>
);
case 'sun-moon':
return (
<svg {...common}>
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.9 4.9 1.4 1.4" />
<path d="m17.7 17.7 1.4 1.4" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.3 17.7-1.4 1.4" />
<path d="m19.1 4.9-1.4 1.4" />
</svg>
);
case 'tweaks':
return (
<svg {...common}>
<path d="M4 6h13" />
<circle cx="19" cy="6" r="2" />
<path d="M4 18h7" />
<circle cx="13" cy="18" r="2" />
<path d="M17 12H4" />
<circle cx="19" cy="12" r="2" />
</svg>
);
case 'upload':
return (
<svg {...common}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m17 8-5-5-5 5" />
<path d="M12 3v12" />
</svg>
);
case 'zoom-in':
return (
<svg {...common}>
<circle cx="11" cy="11" r="7" />
<path d="M11 8v6" />
<path d="M8 11h6" />
<path d="m21 21-4.3-4.3" />
</svg>
);
case 'zoom-out':
return (
<svg {...common}>
<circle cx="11" cy="11" r="7" />
<path d="M8 11h6" />
<path d="m21 21-4.3-4.3" />
</svg>
);
default:
return null;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useRef, useState } from 'react';
import { LOCALE_LABEL, LOCALES, useI18n, type Locale } from '../i18n';
import { Icon } from './Icon';
/**
* Compact language switcher rendered as a foot-pill in the entry view's
* lower-left corner. Mirrors the "Local CLI · agent" pill so it doesn't
* fight for visual weight, but remains discoverable for first-time users
* who'd rather not dig into the settings dialog just to swap languages.
*/
export function LanguageMenu() {
const { locale, setLocale } = useI18n();
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
function onDown(e: MouseEvent) {
if (!wrapRef.current) return;
if (wrapRef.current.contains(e.target as Node)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className="lang-menu-wrap" ref={wrapRef}>
<button
type="button"
className="foot-pill lang-pill"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
title={LOCALE_LABEL[locale]}
>
<Icon name="languages" size={12} />
<span>{LOCALE_LABEL[locale]}</span>
<Icon name="chevron-down" size={11} />
</button>
{open ? (
<div className="lang-menu-popover" role="menu">
{LOCALES.map((code) => {
const active = locale === code;
return (
<button
key={code}
type="button"
role="menuitemradio"
aria-checked={active}
className={`lang-menu-item${active ? ' active' : ''}`}
onClick={() => {
setLocale(code as Locale);
setOpen(false);
}}
>
<span className="lang-menu-label">{LOCALE_LABEL[code]}</span>
<span className="lang-menu-code">{code}</span>
{active ? (
<span className="lang-menu-check" aria-hidden>
<Icon name="check" size={12} />
</span>
) : null}
</button>
);
})}
</div>
) : null}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { Icon } from './Icon';
interface SpinnerProps {
size?: number;
label?: string;
}
export function Spinner({ size = 14, label }: SpinnerProps) {
return (
<span className="loading-spinner" role="status" aria-live="polite">
<Icon name="spinner" size={size} />
{label ? <span className="loading-spinner-label">{label}</span> : null}
</span>
);
}
interface SkeletonProps {
width?: number | string;
height?: number | string;
radius?: number | string;
className?: string;
}
export function Skeleton({ width, height = 14, radius = 6, className }: SkeletonProps) {
return (
<span
className={`skeleton-block${className ? ` ${className}` : ''}`}
style={{ width, height, borderRadius: radius }}
aria-hidden
/>
);
}
/**
* Card-shaped skeleton tuned for the DesignsTab grid. Renders a thumb area
* over the row of meta lines so the empty grid feels like content is
* arriving rather than missing.
*/
export function DesignCardSkeleton() {
return (
<div className="design-card design-card-skeleton" aria-hidden>
<div className="design-card-thumb skeleton-shimmer" />
<div className="design-card-meta-block">
<Skeleton height={13} width="65%" />
<Skeleton height={11} width="45%" />
</div>
</div>
);
}
/**
* Centered overlay used while bootstrap data loads (agents, skills, design
* systems, project list). Sits inside a flex/grid parent and grows with it.
*/
export function CenteredLoader({ label }: { label?: string }) {
return (
<div className="centered-loader">
<Spinner size={20} />
{label ? <span className="centered-loader-label">{label}</span> : null}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { useT } from '../i18n';
interface Props {
onSave: (name: string, content: string) => void;
onClose: () => void;
}
export function PasteTextDialog({ onSave, onClose }: Props) {
const t = useT();
const [name, setName] = useState('');
const [content, setContent] = useState('');
function commit() {
const trimmed = content.trim();
if (!trimmed) return;
const finalName = name.trim() || `paste-${Date.now()}.txt`;
onSave(ensureExtension(finalName, '.txt'), content);
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>{t('pasteDialog.title')}</h2>
<p className="hint">{t('pasteDialog.hint')}</p>
<label>
{t('pasteDialog.fileNameLabel')}
<input
type="text"
value={name}
placeholder={t('pasteDialog.namePlaceholder')}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</label>
<label>
{t('pasteDialog.contentLabel')}
<textarea
rows={10}
value={content}
placeholder={t('pasteDialog.contentPlaceholder')}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<div className="row">
<button onClick={onClose}>{t('pasteDialog.cancel')}</button>
<button className="primary" onClick={commit} disabled={!content.trim()}>
{t('pasteDialog.save')}
</button>
</div>
</div>
</div>
);
}
function ensureExtension(name: string, ext: string): string {
if (/\.[a-z0-9]+$/i.test(name)) return name;
return `${name}${ext}`;
}
@@ -0,0 +1,49 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { PreviewModal } from './PreviewModal';
describe('PreviewModal sandbox isolation', () => {
it('renders generated previews without same-origin sandbox access', () => {
const markup = renderToStaticMarkup(
<PreviewModal
title="Unsafe preview"
views={[
{
id: 'preview',
label: 'Preview',
html: '<script>window.parent.document.body.innerHTML="owned"</script>',
},
]}
exportTitleFor={() => 'unsafe-preview'}
onClose={() => {}}
/>,
);
expect(markup).toContain('sandbox="allow-scripts"');
expect(markup).not.toContain('allow-same-origin');
expect(markup).toContain('srcDoc=');
});
it('keeps deck srcdoc handling for deck preview views', () => {
const markup = renderToStaticMarkup(
<PreviewModal
title="Deck preview"
views={[
{
id: 'deck',
label: 'Deck',
html: '<section class="slide">one</section><section class="slide">two</section>',
deck: true,
},
]}
exportTitleFor={() => 'deck-preview'}
onClose={() => {}}
/>,
);
expect(markup).toContain('sandbox="allow-scripts"');
expect(markup).not.toContain('allow-same-origin');
expect(markup).toContain('od:slide');
});
});
+427
View File
@@ -0,0 +1,427 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useT } from '../i18n';
import { exportAsHtml, exportAsPdf, exportAsZip, openSandboxedPreviewInNewTab } from '../runtime/exports';
import { buildSrcdoc } from '../runtime/srcdoc';
export interface PreviewView {
id: string;
label: string;
// Null means "still loading" — modal renders the loading affordance.
// Undefined means "not yet requested" — parent should react to onView and
// begin a fetch. Both states keep the iframe blank.
html: string | null | undefined;
// Deck previews need deck-aware srcdoc/PDF handling so slide navigation and
// print-all-slides behavior survive the sandboxed export path.
deck?: boolean;
}
export interface PreviewSidebar {
// Header label and toggle button label.
label: string;
// Side-pane content — caller renders whatever it likes (markdown source
// view, swatch grid, etc.). Always optional; when absent the toggle is
// not shown.
content: ReactNode;
// Default open state on first mount. Defaults to false.
defaultOpen?: boolean;
// Called whenever the open state changes — useful so the parent can
// lazy-fetch the side content the first time it is revealed.
onToggle?: (open: boolean) => void;
// Stable identity for the side-panel source. When this changes while the
// sidebar is open, the lazy-load `onToggle` callback re-fires so the parent
// can prime a fresh fetch — e.g. swapping between design systems while the
// DESIGN.md panel stays open.
contentKey?: string | number;
}
interface Props {
title: string;
subtitle?: string;
views: PreviewView[];
initialViewId?: string;
// Per-view filename hint for the share menu — receives the active view id
// so DS can produce e.g. "Airtable — showcase" while Examples stay flat.
exportTitleFor: (viewId: string) => string;
// Fired whenever the active view changes — including on first mount with
// initialViewId. Lets the parent drive lazy fetches without prop drilling
// a loader callback in.
onView?: (viewId: string) => void;
onClose: () => void;
// Optional split-view companion pane shown to the right of the iframe.
// Used by the design-system preview to surface the raw DESIGN.md beside
// the rendered showcase, matching the styles.refero.design layout.
sidebar?: PreviewSidebar;
// Logical viewport width the iframe content is rendered at. The iframe is
// then visually scaled (transform: scale) to fit the actual stage width
// so squeezing the preview behind a sidebar never reflows the inner page
// into a half-broken responsive breakpoint. Defaults to 1280 — wide
// enough that desktop-shaped showcases keep their intended layout.
designWidth?: number;
}
// A full-screen overlay that renders an iframe of arbitrary HTML, with an
// optional tab bar for multiple views, a Share menu (PDF / HTML / ZIP /
// open-in-new-tab), and a Fullscreen toggle. Used by both the design-system
// preview and the example card preview, so the two paths feel identical.
export function PreviewModal({
title,
subtitle,
views,
initialViewId,
exportTitleFor,
onView,
onClose,
sidebar,
designWidth = 1280,
}: Props) {
const t = useT();
const initial = initialViewId && views.some((v) => v.id === initialViewId)
? initialViewId
: views[0]?.id ?? '';
const [activeId, setActiveId] = useState<string>(initial);
const [shareOpen, setShareOpen] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState<boolean>(
sidebar?.defaultOpen ?? false,
);
const shareRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const stageFrameRef = useRef<HTMLDivElement | null>(null);
const [stageSize, setStageSize] = useState<{ w: number; h: number }>({
w: 0,
h: 0,
});
// Capture the toggle handler in a ref so the lazy-load effect below
// depends only on sidebarOpen — without this, a new `sidebar` object on
// every parent render would re-fire the load on each render.
const sidebarToggleRef = useRef(sidebar?.onToggle);
sidebarToggleRef.current = sidebar?.onToggle;
// Tell the parent every time the side pane toggles so it can lazy-load
// the spec body the first time it is revealed. Also re-fires when
// `sidebar.contentKey` changes so the parent can prime a fresh fetch when
// its underlying source swaps (e.g. another design system) while the
// sidebar stays open. `sidebar` itself is a fresh object on every parent
// render so we can't depend on it.
const sidebarContentKey = sidebar?.contentKey;
useEffect(() => {
sidebarToggleRef.current?.(sidebarOpen);
}, [sidebarOpen, sidebarContentKey]);
// Tell the parent the initial view id so it can prime a fetch. Re-fires on
// tab change. Guarded against re-firing while the same id is active to
// avoid noisy effects in the parent.
useEffect(() => {
onView?.(activeId);
}, [activeId, onView]);
// Close on Escape. If we're in fullscreen, exit fullscreen first instead
// of dismissing the whole modal in one keystroke.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (fullscreen) {
setFullscreen(false);
return;
}
onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose, fullscreen]);
// Mirror native fullscreen state into React. Without this, a user in
// browser fullscreen has to press Esc twice: the first Esc exits the
// native fullscreen element (consumed by the browser; in some browsers no
// keydown is delivered) while our `fullscreen` state stays true and the
// overlay keeps its `ds-modal-fullscreen` class. Listening to
// fullscreenchange lets one Esc dismiss both layers in lock-step.
useEffect(() => {
const onFsChange = () => {
if (!document.fullscreenElement) {
setFullscreen(false);
}
};
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
// Close share popover on outside click / Escape.
useEffect(() => {
if (!shareOpen) return;
const onDoc = (e: MouseEvent) => {
if (!shareRef.current) return;
if (!shareRef.current.contains(e.target as Node)) setShareOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShareOpen(false);
};
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [shareOpen]);
// Lock body scroll while open.
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, []);
// Track the iframe stage size so we can render the document at a fixed
// logical width and visually scale it down to fit. Without this, opening
// the side panel squeezes the iframe to ~60% width and triggers awkward
// mid-breakpoint reflows in the showcase HTML.
// ResizeObserver is missing from jsdom and from some older embedded
// WebViews — guard the constructor and fall back to a window resize
// listener so the modal still mounts and just loses element-level
// resize tracking.
useEffect(() => {
const el = stageFrameRef.current;
if (!el) return;
const measure = () => {
const r = el.getBoundingClientRect();
setStageSize({ w: r.width, h: r.height });
};
measure();
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
const activeView = views.find((v) => v.id === activeId) ?? views[0];
const activeHtml = activeView?.html ?? null;
const activeDeck = activeView?.deck ?? false;
const srcDoc = useMemo(
() => (activeHtml ? buildSrcdoc(activeHtml, { deck: activeDeck }) : ''),
[activeHtml, activeDeck],
);
const exportTitle = exportTitleFor(activeView?.id ?? '');
// Only down-scale: when the stage is wider than the design viewport we
// render the iframe at native size instead of upscaling pixels.
const scale = stageSize.w > 0 ? Math.min(1, stageSize.w / designWidth) : 1;
const scalerStyle = useMemo(() => {
if (scale >= 1 || stageSize.w === 0) {
return {
width: '100%',
height: '100%',
transform: 'none',
} as const;
}
return {
width: designWidth,
height: stageSize.h / scale,
transform: `scale(${scale})`,
} as const;
}, [scale, stageSize.w, stageSize.h, designWidth]);
function openInNewTab() {
if (!activeHtml) return;
openSandboxedPreviewInNewTab(activeHtml, exportTitle, { deck: activeDeck });
}
function enterFullscreen() {
const el = stageRef.current;
if (el && typeof el.requestFullscreen === 'function') {
el.requestFullscreen()
.then(() => setFullscreen(true))
.catch(() => setFullscreen(true));
} else {
setFullscreen(true);
}
}
function exitFullscreen() {
if (document.fullscreenElement && document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
}
setFullscreen(false);
}
const showTabs = views.length > 1;
return (
<div className="ds-modal-backdrop" role="dialog" aria-modal="true" aria-label={`${title} preview`}>
<div className={`ds-modal ${fullscreen ? 'ds-modal-fullscreen' : ''}`}>
<header className="ds-modal-header">
<div className="ds-modal-title-block">
<div className="ds-modal-title">{title}</div>
{subtitle ? <div className="ds-modal-subtitle">{subtitle}</div> : null}
</div>
{showTabs ? (
<div className="ds-modal-tabs" role="tablist">
{views.map((v) => (
<button
key={v.id}
role="tab"
aria-selected={activeId === v.id}
className={`ds-modal-tab ${activeId === v.id ? 'active' : ''}`}
onClick={() => setActiveId(v.id)}
>
{v.label}
</button>
))}
</div>
) : (
<span aria-hidden="true" />
)}
<div className="ds-modal-actions">
{sidebar ? (
<button
className={`ghost ${sidebarOpen ? 'is-active' : ''}`}
onClick={() => setSidebarOpen((v) => !v)}
aria-pressed={sidebarOpen}
title={sidebar.label}
>
{sidebar.label}
</button>
) : null}
<button
className="ghost"
onClick={fullscreen ? exitFullscreen : enterFullscreen}
title={
fullscreen
? t('common.exitFullscreen')
: t('common.fullscreen')
}
>
{fullscreen ? t('preview.exit') : t('preview.fullscreen')}
</button>
<div className="share-menu" ref={shareRef}>
<button
className="ghost"
aria-haspopup="menu"
aria-expanded={shareOpen}
onClick={() => setShareOpen((v) => !v)}
disabled={!activeHtml}
>
{t('preview.shareMenu')}
</button>
{shareOpen ? (
<div className="share-menu-popover" role="menu">
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareOpen(false);
if (activeHtml) exportAsPdf(activeHtml, exportTitle, { deck: activeDeck });
}}
>
<span className="share-menu-icon">📄</span>
<span>{t('common.exportPdf')}</span>
</button>
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareOpen(false);
if (activeHtml) exportAsZip(activeHtml, exportTitle);
}}
>
<span className="share-menu-icon">🗜</span>
<span>{t('common.exportZip')}</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareOpen(false);
if (activeHtml) exportAsHtml(activeHtml, exportTitle);
}}
>
<span className="share-menu-icon">🌐</span>
<span>{t('common.exportHtml')}</span>
</button>
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareOpen(false);
openInNewTab();
}}
>
<span className="share-menu-icon"></span>
<span>{t('preview.openInNewTab')}</span>
</button>
</div>
) : null}
</div>
<button
className="ghost"
onClick={onClose}
title={t('preview.closeTitle')}
aria-label={t('common.close')}
>
</button>
</div>
</header>
<div
className={`ds-modal-stage ${sidebar && sidebarOpen ? 'has-sidebar' : ''}`}
ref={stageRef}
>
<div className="ds-modal-stage-iframe" ref={stageFrameRef}>
{activeHtml === null || activeHtml === undefined ? (
<div className="ds-modal-empty">
{t('preview.loading', {
label:
activeView?.label.toLowerCase() ?? t('common.preview').toLowerCase(),
})}
</div>
) : (
<div className="ds-modal-stage-iframe-scaler" style={scalerStyle}>
<iframe
key={activeView?.id ?? 'view'}
title={`${title} ${activeView?.label ?? ''}`}
sandbox="allow-scripts"
srcDoc={srcDoc}
/>
</div>
)}
{sidebar && !sidebarOpen ? (
<button
type="button"
className="ds-modal-stage-handle is-expand"
onClick={() => setSidebarOpen(true)}
title={t('preview.showSidebar', { label: sidebar.label })}
aria-label={t('preview.showSidebar', { label: sidebar.label })}
>
<span aria-hidden="true"></span>
</button>
) : null}
</div>
{sidebar && sidebarOpen ? (
<aside className="ds-modal-sidebar" aria-label={sidebar.label}>
<button
type="button"
className="ds-modal-stage-handle is-collapse"
onClick={() => setSidebarOpen(false)}
title={t('preview.hideSidebar', { label: sidebar.label })}
aria-label={t('preview.hideSidebar', { label: sidebar.label })}
>
<span aria-hidden="true"></span>
</button>
{sidebar.content}
</aside>
) : null}
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,252 @@
import { useEffect, useState } from 'react';
import { useT } from '../i18n';
import { fetchPromptTemplate } from '../providers/registry';
import type {
PromptTemplateDetail,
PromptTemplateSummary,
} from '../types';
import { Icon } from './Icon';
interface Props {
summary: PromptTemplateSummary;
onClose: () => void;
}
// Modal preview for a curated prompt template. The summary payload from
// /api/prompt-templates carries enough to render the header (title,
// description, category, tags, attribution) and the preview asset; the
// prompt body is fetched lazily so the gallery list stays cheap.
export function PromptTemplatePreviewModal({ summary, onClose }: Props) {
const t = useT();
const [detail, setDetail] = useState<PromptTemplateDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// Immersive fullscreen preview state. Layered ABOVE the modal so the
// user can dive into the asset without losing the prompt context they
// came from — closing the lightbox restores the modal underneath.
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
let cancelled = false;
setDetail(null);
setError(null);
setCopied(false);
setLightboxOpen(false);
void fetchPromptTemplate(summary.surface, summary.id).then((d) => {
if (cancelled) return;
if (!d) {
setError(t('promptTemplates.fetchError'));
return;
}
setDetail(d);
});
return () => {
cancelled = true;
};
}, [summary.id, summary.surface, t]);
// Close on Escape — when the lightbox is open, ESC closes only the
// lightbox (preserving the modal beneath); otherwise it closes the
// modal itself. Mirrors the design-system preview modal's pattern so
// the two gallery views feel consistent.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (lightboxOpen) {
setLightboxOpen(false);
return;
}
onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose, lightboxOpen]);
function handleCopy() {
if (!detail) return;
void navigator.clipboard.writeText(detail.prompt).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
});
}
const sourceLabel = summary.source.author
? `${summary.source.author} · ${summary.source.repo}`
: summary.source.repo;
const hasAsset = !!(summary.previewVideoUrl || summary.previewImageUrl);
const fullscreenLabel = t('promptTemplates.openFullscreen');
const closeFullscreenLabel = t('promptTemplates.closeFullscreen');
return (
<>
<div
className="prompt-template-modal-backdrop"
role="dialog"
aria-modal="true"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="prompt-template-modal">
<header className="prompt-template-modal-head">
<div className="prompt-template-modal-titles">
<h2>{summary.title}</h2>
<p>{summary.summary}</p>
</div>
<button
type="button"
className="ghost"
onClick={onClose}
aria-label={t('common.close')}
>
<Icon name="close" size={14} />
</button>
</header>
<div className="prompt-template-modal-tags">
<span className="prompt-template-category">{summary.category}</span>
{(summary.tags ?? []).map((tag) => (
<span key={tag} className="prompt-template-tag">
{tag}
</span>
))}
{summary.model ? (
<span className="prompt-template-model">
{t('promptTemplates.modelHint', { model: summary.model })}
</span>
) : null}
{summary.aspect ? (
<span className="prompt-template-model">{summary.aspect}</span>
) : null}
</div>
<div className="prompt-template-modal-body">
{hasAsset ? (
<div className="prompt-template-modal-asset">
{summary.previewVideoUrl ? (
<video
src={summary.previewVideoUrl}
poster={summary.previewImageUrl}
controls
preload="none"
playsInline
/>
) : summary.previewImageUrl ? (
// Image is click-to-expand — the whole thumbnail acts as
// the trigger so it feels natural (cursor: zoom-in). The
// floating pill below also opens fullscreen and is the
// primary path for video previews where clicks land on
// the native <video controls> instead.
<button
type="button"
className="prompt-template-modal-asset-image-trigger"
onClick={() => setLightboxOpen(true)}
aria-label={fullscreenLabel}
>
<img
src={summary.previewImageUrl}
alt={summary.title}
loading="lazy"
/>
</button>
) : null}
<button
type="button"
className="prompt-template-modal-asset-expand"
onClick={() => setLightboxOpen(true)}
aria-label={fullscreenLabel}
title={fullscreenLabel}
>
<Icon name="eye" size={12} />
<span>{fullscreenLabel}</span>
</button>
</div>
) : null}
<div className="prompt-template-modal-prompt">
<div className="prompt-template-modal-prompt-head">
<span className="prompt-template-modal-prompt-label">
{t('promptTemplates.promptLabel')}
</span>
<button
type="button"
className="ghost"
onClick={handleCopy}
disabled={!detail}
>
<Icon name="copy" size={12} />
{copied
? t('promptTemplates.copyDone')
: t('promptTemplates.copyPrompt')}
</button>
</div>
<pre className="prompt-template-modal-prompt-body">
{detail
? detail.prompt
: error
? error
: t('common.loading')}
</pre>
</div>
</div>
<footer className="prompt-template-modal-foot">
<span>
{t('promptTemplates.sourcePrefix')} {sourceLabel} ·{' '}
<span className="prompt-template-license">
{summary.source.license}
</span>
</span>
{summary.source.url ? (
<a
href={summary.source.url}
target="_blank"
rel="noopener noreferrer"
>
{t('promptTemplates.openSource')}
</a>
) : null}
</footer>
</div>
</div>
{lightboxOpen && hasAsset ? (
// Immersive lightbox — full viewport, dark backdrop, centered
// media. Rendered as a sibling of the modal backdrop so its
// backdrop click is independent (clicking the lightbox backdrop
// closes only the lightbox, not the modal beneath).
<div
className="prompt-template-lightbox-backdrop"
role="dialog"
aria-modal="true"
aria-label={fullscreenLabel}
onClick={(e) => {
if (e.target === e.currentTarget) setLightboxOpen(false);
}}
>
{summary.previewVideoUrl ? (
<video
className="prompt-template-lightbox-media"
src={summary.previewVideoUrl}
poster={summary.previewImageUrl}
controls
autoPlay
playsInline
/>
) : summary.previewImageUrl ? (
<img
className="prompt-template-lightbox-media"
src={summary.previewImageUrl}
alt={summary.title}
/>
) : null}
<button
type="button"
className="prompt-template-lightbox-close"
onClick={() => setLightboxOpen(false)}
aria-label={closeFullscreenLabel}
title={closeFullscreenLabel}
>
<Icon name="close" size={18} />
</button>
</div>
) : null}
</>
);
}
@@ -0,0 +1,162 @@
import { useMemo, useState } from 'react';
import { useI18n, useT } from '../i18n';
import {
localizePromptTemplateCategory,
localizePromptTemplateSummary,
} from '../i18n/content';
import type { PromptTemplateSummary } from '../types';
import { Icon } from './Icon';
interface Props {
surface: 'image' | 'video';
templates: PromptTemplateSummary[];
onPreview: (tpl: PromptTemplateSummary) => void;
}
// Curated prompt-template gallery — one tab per surface (image / video).
// Layout mirrors the Examples tab: a category filter row + a responsive
// card grid that lazy-loads remote thumbnails (the upstream README hosts
// images on CMS / Cloudflare Stream, both public). Each card opens a
// preview modal with the full prompt body and attribution.
export function PromptTemplatesTab({ surface, templates, onPreview }: Props) {
const { locale, t } = useI18n();
const [filter, setFilter] = useState('');
const [category, setCategory] = useState<string>('All');
const surfaceScoped = useMemo(
() => templates.filter((tpl) => tpl.surface === surface),
[templates, surface],
);
const categories = useMemo(() => {
const set = new Set<string>();
for (const tpl of surfaceScoped) set.add(tpl.category || 'General');
return ['All', ...Array.from(set).sort()];
}, [surfaceScoped]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
return surfaceScoped.filter((tpl) => {
if (category !== 'All' && (tpl.category || 'General') !== category) {
return false;
}
if (!q) return true;
const localized = localizePromptTemplateSummary(locale, tpl);
return (
tpl.title.toLowerCase().includes(q)
|| tpl.summary.toLowerCase().includes(q)
|| (tpl.tags ?? []).some((tag) => tag.toLowerCase().includes(q))
|| localized.title.toLowerCase().includes(q)
|| localized.summary.toLowerCase().includes(q)
|| localized.category.toLowerCase().includes(q)
|| (localized.tags ?? []).some((tag) => tag.toLowerCase().includes(q))
);
});
}, [surfaceScoped, filter, category, locale]);
if (surfaceScoped.length === 0) {
return (
<div className="tab-empty">
{surface === 'image'
? t('promptTemplates.emptyImage')
: t('promptTemplates.emptyVideo')}
</div>
);
}
return (
<div className="tab-panel prompt-templates-panel">
<div className="tab-panel-toolbar">
<input
placeholder={t('promptTemplates.searchPlaceholder')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
{categories.map((c) => (
<option key={c} value={c}>
{c === 'All' ? t('common.all') : localizePromptTemplateCategory(locale, c)}
</option>
))}
</select>
<span className="prompt-templates-count">
{t('promptTemplates.countLabel', { n: filtered.length })}
</span>
</div>
{filtered.length === 0 ? (
<div className="tab-empty">{t('promptTemplates.emptyNoMatch')}</div>
) : (
<div className="prompt-templates-grid">
{filtered.map((tpl) => {
const localized = localizePromptTemplateSummary(locale, tpl);
return (
<PromptTemplateCard
key={tpl.id}
tpl={localized}
onPreview={() => onPreview(localized)}
/>
);
})}
</div>
)}
<div className="prompt-templates-footer">
{t('promptTemplates.attributionFooter')}
</div>
</div>
);
}
function PromptTemplateCard({
tpl,
onPreview,
}: {
tpl: PromptTemplateSummary;
onPreview: () => void;
}) {
const t = useT();
const sourceLabel = tpl.source.author
? `${tpl.source.author} · ${tpl.source.repo.split('/').pop()}`
: tpl.source.repo.split('/').pop();
return (
<button
type="button"
className="prompt-template-card"
onClick={onPreview}
title={t('promptTemplates.openPreviewTitle')}
>
<span className="prompt-template-thumb">
{tpl.previewImageUrl ? (
<img src={tpl.previewImageUrl} alt="" loading="lazy" draggable={false} />
) : tpl.surface === 'video' ? (
<span className="prompt-template-thumb-fallback" aria-hidden>
<Icon name="play" size={28} />
</span>
) : (
<span className="prompt-template-thumb-fallback" aria-hidden>
<Icon name="image" size={28} />
</span>
)}
{tpl.surface === 'video' && tpl.previewVideoUrl ? (
<span className="prompt-template-thumb-play" aria-hidden>
</span>
) : null}
</span>
<span className="prompt-template-meta">
<span className="prompt-template-title">{tpl.title}</span>
<span className="prompt-template-summary">{tpl.summary}</span>
<span className="prompt-template-tags">
<span className="prompt-template-category">{tpl.category}</span>
{(tpl.tags ?? []).slice(0, 3).map((tag) => (
<span key={tag} className="prompt-template-tag">
{tag}
</span>
))}
</span>
<span className="prompt-template-source">
{t('promptTemplates.sourcePrefix')} {sourceLabel}
</span>
</span>
</button>
);
}
+348
View File
@@ -0,0 +1,348 @@
import { useMemo, useState } from 'react';
import { useT } from '../i18n';
import type { DirectionCard, QuestionForm } from '../artifacts/question-form';
import { formatFormAnswers } from '../artifacts/question-form';
interface Props {
form: QuestionForm;
// Whether the user can still submit answers. The owning AssistantMessage
// disables the form when the assistant turn is no longer the most recent
// one (i.e. the user has already moved past it).
interactive: boolean;
// Pre-existing answers — when we detect a follow-up user message that
// begins with "[form answers — <id>]", we parse it back out and pass it
// here so the rendered form reflects what was sent.
submittedAnswers?: Record<string, string | string[]>;
onSubmit?: (text: string, answers: Record<string, string | string[]>) => void;
}
export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit }: Props) {
const t = useT();
const initial = useMemo(() => buildInitialState(form, submittedAnswers), [form, submittedAnswers]);
const [answers, setAnswers] = useState<Record<string, string | string[]>>(initial);
const locked = !interactive || !onSubmit || submittedAnswers !== undefined;
function update(id: string, value: string | string[]) {
if (locked) return;
setAnswers((prev) => ({ ...prev, [id]: value }));
}
function toggleCheckbox(id: string, option: string, maxSelections?: number) {
if (locked) return;
setAnswers((prev) => {
const current = Array.isArray(prev[id]) ? (prev[id] as string[]) : [];
const has = current.includes(option);
if (!has && maxSelections !== undefined && current.length >= maxSelections) {
return prev;
}
const next = has ? current.filter((v) => v !== option) : [...current, option];
return { ...prev, [id]: next };
});
}
function missingRequired(): string | null {
for (const q of form.questions) {
if (!q.required) continue;
const v = answers[q.id];
if (Array.isArray(v) ? v.length === 0 : !(typeof v === 'string' && v.trim().length > 0)) {
return q.label;
}
}
return null;
}
function handleSubmit() {
if (locked || !onSubmit) return;
if (!withinSelectionLimits) return;
const missing = missingRequired();
if (missing) {
// Soft inline guard — surface via aria but don't alert; the disabled
// state of the submit button covers most cases.
return;
}
onSubmit(formatFormAnswers(form, answers), answers);
}
const required = form.questions.filter((q) => q.required);
const withinSelectionLimits = form.questions.every((q) => {
if (q.type !== 'checkbox' || q.maxSelections === undefined) return true;
const v = answers[q.id];
return !Array.isArray(v) || v.length <= q.maxSelections;
});
const ready = withinSelectionLimits && required.every((q) => {
const v = answers[q.id];
return Array.isArray(v) ? v.length > 0 : typeof v === 'string' && v.trim().length > 0;
});
return (
<div className={`question-form${locked ? ' question-form-locked' : ''}`}>
<div className="question-form-head">
<span className="question-form-icon" aria-hidden>?</span>
<div className="question-form-titles">
<div className="question-form-title">{form.title}</div>
{form.description ? (
<div className="question-form-desc">{form.description}</div>
) : null}
</div>
{locked ? <span className="question-form-pill">{t('qf.answered')}</span> : null}
</div>
<div className="question-form-body">
{form.questions.map((q) => {
const value = answers[q.id];
return (
<div key={q.id} className="qf-field">
<label className="qf-label">
<span>{q.label}</span>
{q.required ? (
<span className="qf-required" aria-label={t('qf.required')}>*</span>
) : null}
</label>
{q.help ? <div className="qf-help">{q.help}</div> : null}
{q.type === 'radio' && q.options ? (
<div className="qf-options">
{q.options.map((opt) => (
<label key={opt} className={`qf-chip${value === opt ? ' qf-chip-on' : ''}`}>
<input
type="radio"
name={`${form.id}-${q.id}`}
value={opt}
checked={value === opt}
disabled={locked}
onChange={() => update(q.id, opt)}
/>
<span>{opt}</span>
</label>
))}
</div>
) : null}
{q.type === 'checkbox' && q.options ? (
<div className="qf-options">
{q.options.map((opt) => {
const arr = Array.isArray(value) ? value : [];
const on = arr.includes(opt);
const maxed =
q.maxSelections !== undefined && !on && arr.length >= q.maxSelections;
return (
<label
key={opt}
className={`qf-chip${on ? ' qf-chip-on' : ''}${maxed ? ' qf-chip-disabled' : ''}`}
>
<input
type="checkbox"
value={opt}
checked={on}
disabled={locked || maxed}
onChange={() => toggleCheckbox(q.id, opt, q.maxSelections)}
/>
<span>{opt}</span>
</label>
);
})}
</div>
) : null}
{q.type === 'select' && q.options ? (
<select
className="qf-select"
value={typeof value === 'string' ? value : ''}
disabled={locked}
onChange={(e) => update(q.id, e.target.value)}
>
<option value="" disabled>
{t('qf.choose')}
</option>
{q.options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : null}
{q.type === 'text' ? (
<input
type="text"
className="qf-input"
value={typeof value === 'string' ? value : ''}
placeholder={q.placeholder}
disabled={locked}
onChange={(e) => update(q.id, e.target.value)}
/>
) : null}
{q.type === 'textarea' ? (
<textarea
className="qf-textarea"
value={typeof value === 'string' ? value : ''}
placeholder={q.placeholder}
disabled={locked}
rows={3}
onChange={(e) => update(q.id, e.target.value)}
/>
) : null}
{q.type === 'direction-cards' && q.cards && q.cards.length > 0 ? (
<div className="qf-direction-cards">
{q.cards.map((card) => (
<DirectionCardView
key={card.id}
card={card}
formId={form.id}
questionId={q.id}
selected={value === card.id || value === card.label}
disabled={locked}
onSelect={() => update(q.id, card.id)}
/>
))}
</div>
) : null}
</div>
);
})}
</div>
<div className="question-form-foot">
{locked ? (
<span className="qf-locked-note">
{submittedAnswers ? t('qf.lockedSubmitted') : t('qf.lockedPrev')}
</span>
) : (
<span className="qf-hint">{t('qf.hint')}</span>
)}
{!locked ? (
<button
type="button"
className="primary"
onClick={handleSubmit}
disabled={!ready}
title={ready ? t('qf.submitTitle') : t('qf.submitDisabledTitle')}
>
{form.submitLabel ?? t('qf.submitDefault')}
</button>
) : null}
</div>
</div>
);
}
function DirectionCardView({
card,
formId,
questionId,
selected,
disabled,
onSelect,
}: {
card: DirectionCard;
formId: string;
questionId: string;
selected: boolean;
disabled: boolean;
onSelect: () => void;
}) {
const t = useT();
return (
<label
className={`qf-card${selected ? ' qf-card-on' : ''}${disabled ? ' qf-card-disabled' : ''}`}
>
<input
type="radio"
name={`${formId}-${questionId}`}
value={card.id}
checked={selected}
disabled={disabled}
onChange={() => onSelect()}
/>
<div className="qf-card-head">
<div className="qf-card-title">{card.label}</div>
{selected ? <span className="qf-card-pill">{t('qf.cardSelected')}</span> : null}
</div>
{card.palette.length > 0 ? (
<div className="qf-card-swatches" aria-hidden>
{card.palette.slice(0, 6).map((c, i) => (
<span
key={i}
className="qf-card-swatch"
style={{ background: c }}
title={c}
/>
))}
</div>
) : null}
<div className="qf-card-types" aria-hidden>
<span className="qf-card-type-display" style={{ fontFamily: card.displayFont }}>
Aa
</span>
<span className="qf-card-type-body" style={{ fontFamily: card.bodyFont }}>
{t('qf.cardSampleText')}
</span>
</div>
{card.mood ? <p className="qf-card-mood">{card.mood}</p> : null}
{card.references.length > 0 ? (
<p className="qf-card-refs">
<span className="qf-card-refs-label">{t('qf.cardRefs')}</span>{' '}
{card.references.slice(0, 4).join(' · ')}
</p>
) : null}
</label>
);
}
function buildInitialState(
form: QuestionForm,
submitted: Record<string, string | string[]> | undefined,
): Record<string, string | string[]> {
const out: Record<string, string | string[]> = {};
for (const q of form.questions) {
if (submitted && submitted[q.id] !== undefined) {
out[q.id] = submitted[q.id]!;
continue;
}
if (q.defaultValue !== undefined) {
out[q.id] = q.defaultValue;
continue;
}
if (q.type === 'checkbox') {
out[q.id] = [];
} else {
out[q.id] = '';
}
}
return out;
}
/**
* Reverse of formatFormAnswers — when we render an old assistant message
* that contained a form, look at the next user message in the conversation
* to see if the form was already answered. If so, return the answers map
* so the form renders in the locked "answered" state with the user's
* picks visible.
*/
export function parseSubmittedAnswers(
form: QuestionForm,
userMessageContent: string,
): Record<string, string | string[]> | null {
const lines = userMessageContent.split('\n').map((l) => l.trim());
if (lines.length === 0) return null;
const header = lines[0] ?? '';
// We accept any "form answers" header so the agent can paraphrase.
if (!/^\[form answers/i.test(header)) return null;
const answers: Record<string, string | string[]> = {};
const labelToId = new Map<string, string>();
for (const q of form.questions) labelToId.set(q.label.toLowerCase(), q.id);
for (let i = 1; i < lines.length; i++) {
const line = lines[i] ?? '';
const m = /^[-*]\s*([^:]+):\s*(.*)$/.exec(line);
if (!m) continue;
const labelKey = m[1]!.trim().toLowerCase();
const value = m[2]!.trim();
const id = labelToId.get(labelKey);
if (!id) continue;
const q = form.questions.find((x) => x.id === id);
if (!q) continue;
if (q.type === 'checkbox') {
answers[id] = value
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0 && s.toLowerCase() !== '(skipped)');
} else {
answers[id] = value.toLowerCase() === '(skipped)' ? '' : value;
}
}
return Object.keys(answers).length > 0 ? answers : null;
}
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import {
isValidApiBaseUrl,
switchApiProtocolConfig,
updateCurrentApiProtocolConfig,
} from './SettingsDialog';
import type { AppConfig } from '../types';
const baseConfig: AppConfig = {
mode: 'api',
apiKey: 'sk-test',
apiProtocol: 'anthropic',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
agentId: null,
skillId: null,
designSystemId: null,
};
describe('SettingsDialog API protocol switching', () => {
it('stores the current custom protocol config before loading another protocol', () => {
const config: AppConfig = {
...baseConfig,
apiKey: 'anthropic-key',
apiProviderBaseUrl: null,
baseUrl: 'https://my-proxy.example.com',
model: 'my-model',
};
const next = switchApiProtocolConfig(config, 'openai');
expect(next).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
});
expect(next.apiProtocolConfigs?.anthropic).toMatchObject({
apiKey: 'anthropic-key',
baseUrl: 'https://my-proxy.example.com',
model: 'my-model',
apiProviderBaseUrl: null,
});
});
it('restores each protocol draft instead of leaking shared field values', () => {
const openai = switchApiProtocolConfig(baseConfig, 'openai');
const openaiEdited = updateCurrentApiProtocolConfig(openai, {
apiKey: 'openai-key',
baseUrl: 'https://openai-proxy.example.com',
model: 'openai-model',
apiProviderBaseUrl: null,
});
const google = switchApiProtocolConfig(openaiEdited, 'google');
const googleEdited = updateCurrentApiProtocolConfig(google, {
apiKey: 'google-key',
baseUrl: 'https://google-proxy.example.com',
model: 'google-model',
apiProviderBaseUrl: null,
});
const restoredOpenai = switchApiProtocolConfig(googleEdited, 'openai');
expect(restoredOpenai).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'openai-key',
baseUrl: 'https://openai-proxy.example.com',
model: 'openai-model',
apiProviderBaseUrl: null,
});
expect(restoredOpenai.apiProtocolConfigs?.google).toMatchObject({
apiKey: 'google-key',
baseUrl: 'https://google-proxy.example.com',
model: 'google-model',
apiProviderBaseUrl: null,
});
});
it('loads the new protocol default on first visit', () => {
expect(switchApiProtocolConfig(baseConfig, 'openai')).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
});
});
it('auto-fills Google defaults when switching from a selected known provider', () => {
expect(switchApiProtocolConfig(baseConfig, 'google')).toMatchObject({
mode: 'api',
apiProtocol: 'google',
apiKey: '',
baseUrl: 'https://generativelanguage.googleapis.com',
model: 'gemini-2.0-flash',
apiProviderBaseUrl: 'https://generativelanguage.googleapis.com',
});
});
it('keeps Azure API version in the Azure draft only', () => {
const config: AppConfig = {
...baseConfig,
apiProtocol: 'azure',
apiKey: 'azure-key',
model: 'deployment-one',
apiVersion: '2024-10-21',
};
const next = switchApiProtocolConfig(config, 'openai');
expect(next).toMatchObject({
apiProtocol: 'openai',
apiKey: '',
apiVersion: '',
});
expect(next.apiProtocolConfigs?.azure).toMatchObject({
apiKey: 'azure-key',
model: 'deployment-one',
apiVersion: '2024-10-21',
});
});
});
describe('SettingsDialog API Base URL validation', () => {
it('accepts public http/https URLs and loopback local providers', () => {
expect(isValidApiBaseUrl('https://api.openai.com/v1')).toBe(true);
expect(isValidApiBaseUrl('http://localhost:11434/v1')).toBe(true);
expect(isValidApiBaseUrl('http://127.0.0.1:11434/v1')).toBe(true);
expect(isValidApiBaseUrl('http://[::1]:11434/v1')).toBe(true);
expect(isValidApiBaseUrl(' https://resource.openai.azure.com ')).toBe(true);
expect(isValidApiBaseUrl('ddddd')).toBe(false);
expect(isValidApiBaseUrl('api.openai.com/v1')).toBe(false);
expect(isValidApiBaseUrl('ftp://api.example.com')).toBe(false);
expect(isValidApiBaseUrl('http:api.example.com')).toBe(false);
expect(isValidApiBaseUrl('https://')).toBe(false);
expect(isValidApiBaseUrl('http://10.0.0.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://169.254.1.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://172.16.0.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://192.168.1.5:11434/v1')).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+327
View File
@@ -0,0 +1,327 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../i18n';
export type Tool = 'select' | 'pen' | 'text' | 'rect' | 'arrow' | 'eraser';
interface Stroke {
kind: 'pen';
points: Array<{ x: number; y: number }>;
color: string;
size: number;
}
interface RectShape {
kind: 'rect';
x: number;
y: number;
w: number;
h: number;
color: string;
size: number;
}
interface ArrowShape {
kind: 'arrow';
x1: number;
y1: number;
x2: number;
y2: number;
color: string;
size: number;
}
interface TextItem {
kind: 'text';
x: number;
y: number;
text: string;
color: string;
size: number;
}
export type SketchItem = Stroke | RectShape | ArrowShape | TextItem;
export interface SketchDocument {
version: 1;
items: SketchItem[];
}
interface Props {
// Controlled items — the parent owns the strokes so switching to a different
// tab and back doesn't lose the in-progress sketch. The editor only reports
// changes via onItemsChange.
items: SketchItem[];
onItemsChange: (items: SketchItem[]) => void;
onSave: () => Promise<void> | void;
onCancel?: () => void;
saving?: boolean;
dirty?: boolean;
fileName: string;
}
export function SketchEditor({
items,
onItemsChange,
onSave,
onCancel,
saving = false,
dirty = false,
fileName,
}: Props) {
const t = useT();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
const [tool, setTool] = useState<Tool>('pen');
const [color, setColor] = useState('#1c1b1a');
const [size, setSize] = useState(2);
const drawingRef = useRef<SketchItem | null>(null);
const [, force] = useState(0);
// Resize canvas to its container while keeping a high DPR for crisp lines.
useEffect(() => {
const wrap = wrapRef.current;
const cvs = canvasRef.current;
if (!wrap || !cvs) return;
const dpr = window.devicePixelRatio || 1;
const ro = new ResizeObserver(() => {
const rect = wrap.getBoundingClientRect();
cvs.width = Math.max(1, Math.round(rect.width * dpr));
cvs.height = Math.max(1, Math.round(rect.height * dpr));
cvs.style.width = `${rect.width}px`;
cvs.style.height = `${rect.height}px`;
const ctx = cvs.getContext('2d');
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
redraw();
});
ro.observe(wrap);
return () => ro.disconnect();
// redraw is closure-fresh each render via the items dep below
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const redraw = useCallback(() => {
const cvs = canvasRef.current;
if (!cvs) return;
const ctx = cvs.getContext('2d');
if (!ctx) return;
const w = cvs.clientWidth;
const h = cvs.clientHeight;
ctx.clearRect(0, 0, w, h);
drawGrid(ctx, w, h);
const all = drawingRef.current ? [...items, drawingRef.current] : items;
for (const it of all) drawItem(ctx, it);
}, [items]);
useEffect(() => {
redraw();
}, [redraw]);
function pointerPos(e: React.PointerEvent<HTMLCanvasElement>) {
const rect = canvasRef.current!.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function handlePointerDown(e: React.PointerEvent<HTMLCanvasElement>) {
if (tool === 'select') return;
const cvs = canvasRef.current;
if (!cvs) return;
cvs.setPointerCapture(e.pointerId);
const pos = pointerPos(e);
if (tool === 'text') {
const text = window.prompt(t('sketch.textPrompt'));
if (text) {
onItemsChange([
...items,
{ kind: 'text', x: pos.x, y: pos.y, text, color, size: 16 + size * 4 },
]);
}
return;
}
if (tool === 'pen' || tool === 'eraser') {
drawingRef.current = {
kind: 'pen',
points: [pos],
color: tool === 'eraser' ? '#fafaf9' : color,
size: tool === 'eraser' ? size * 6 : size,
};
} else if (tool === 'rect') {
drawingRef.current = { kind: 'rect', x: pos.x, y: pos.y, w: 0, h: 0, color, size };
} else if (tool === 'arrow') {
drawingRef.current = {
kind: 'arrow',
x1: pos.x,
y1: pos.y,
x2: pos.x,
y2: pos.y,
color,
size,
};
}
force((n) => n + 1);
}
function handlePointerMove(e: React.PointerEvent<HTMLCanvasElement>) {
const cur = drawingRef.current;
if (!cur) return;
const pos = pointerPos(e);
if (cur.kind === 'pen') {
cur.points.push(pos);
} else if (cur.kind === 'rect') {
cur.w = pos.x - cur.x;
cur.h = pos.y - cur.y;
} else if (cur.kind === 'arrow') {
cur.x2 = pos.x;
cur.y2 = pos.y;
}
redraw();
}
function handlePointerUp() {
const cur = drawingRef.current;
drawingRef.current = null;
if (!cur) return;
onItemsChange([...items, cur]);
}
function handleUndo() {
onItemsChange(items.slice(0, -1));
}
function handleClear() {
onItemsChange([]);
}
return (
<div className="sketch-editor">
<div className="sketch-toolbar">
<ToolBtn cur={tool} v="select" onClick={setTool} title={t('sketch.toolSelect')} label="↖" />
<ToolBtn cur={tool} v="pen" onClick={setTool} title={t('sketch.toolPen')} label="✎" />
<ToolBtn cur={tool} v="text" onClick={setTool} title={t('sketch.toolText')} label="T" />
<ToolBtn cur={tool} v="rect" onClick={setTool} title={t('sketch.toolRect')} label="▭" />
<ToolBtn cur={tool} v="arrow" onClick={setTool} title={t('sketch.toolArrow')} label="↗" />
<ToolBtn cur={tool} v="eraser" onClick={setTool} title={t('sketch.toolEraser')} label="◌" />
<span className="sketch-divider" />
<input
type="color"
className="sketch-color"
value={color}
onChange={(e) => setColor(e.target.value)}
title={t('sketch.color')}
/>
<input
type="range"
min={1}
max={8}
value={size}
onChange={(e) => setSize(Number(e.target.value))}
title={t('sketch.strokeSize')}
className="sketch-size"
/>
<span className="sketch-divider" />
<button className="ghost" onClick={handleUndo} disabled={items.length === 0}>
{t('sketch.undo')}
</button>
<button className="ghost" onClick={handleClear} disabled={items.length === 0}>
{t('sketch.clear')}
</button>
<span className="sketch-spacer" />
<span className="sketch-name" title={fileName}>
{fileName}
{dirty ? ' •' : ''}
</span>
{onCancel ? (
<button className="ghost" onClick={onCancel}>
{t('sketch.close')}
</button>
) : null}
<button
className="primary"
onClick={() => void onSave()}
disabled={saving || items.length === 0}
>
{saving ? t('sketch.saving') : t('common.save')}
</button>
</div>
<div className="sketch-canvas-wrap" ref={wrapRef}>
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{ touchAction: 'none' }}
/>
</div>
</div>
);
}
function ToolBtn({
cur,
v,
onClick,
label,
title,
}: {
cur: Tool;
v: Tool;
onClick: (v: Tool) => void;
label: string;
title: string;
}) {
return (
<button
className={`sketch-tool ${cur === v ? 'active' : ''}`}
onClick={() => onClick(v)}
title={title}
>
{label}
</button>
);
}
function drawGrid(ctx: CanvasRenderingContext2D, w: number, h: number) {
ctx.save();
ctx.fillStyle = '#bfbcb6';
for (let y = 12; y < h; y += 16) {
for (let x = 12; x < w; x += 16) {
ctx.fillRect(x, y, 1, 1);
}
}
ctx.restore();
}
function drawItem(ctx: CanvasRenderingContext2D, it: SketchItem) {
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = it.color;
ctx.fillStyle = it.color;
ctx.lineWidth = it.size;
if (it.kind === 'pen') {
if (it.points.length < 2) return ctx.restore();
ctx.beginPath();
ctx.moveTo(it.points[0]!.x, it.points[0]!.y);
for (let i = 1; i < it.points.length; i++) {
ctx.lineTo(it.points[i]!.x, it.points[i]!.y);
}
ctx.stroke();
} else if (it.kind === 'rect') {
ctx.strokeRect(it.x, it.y, it.w, it.h);
} else if (it.kind === 'arrow') {
ctx.beginPath();
ctx.moveTo(it.x1, it.y1);
ctx.lineTo(it.x2, it.y2);
ctx.stroke();
const ang = Math.atan2(it.y2 - it.y1, it.x2 - it.x1);
const len = 10 + it.size * 2;
ctx.beginPath();
ctx.moveTo(it.x2, it.y2);
ctx.lineTo(it.x2 - len * Math.cos(ang - Math.PI / 6), it.y2 - len * Math.sin(ang - Math.PI / 6));
ctx.moveTo(it.x2, it.y2);
ctx.lineTo(it.x2 - len * Math.cos(ang + Math.PI / 6), it.y2 - len * Math.sin(ang + Math.PI / 6));
ctx.stroke();
} else if (it.kind === 'text') {
ctx.font = `${it.size}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
ctx.fillText(it.text, it.x, it.y);
}
ctx.restore();
}
+355
View File
@@ -0,0 +1,355 @@
/**
* Renders a single tool_use (optionally paired with its tool_result) as an
* inline card in the assistant message stream. Lookup order:
*
* 1. user-registered renderer in `tool-renderers` (the extension point
* analogous to CopilotKit's `useCopilotAction({ render })`)
* 2. hardcoded family card for tools we ship with (TodoWrite / Write /
* Edit / Read / Bash / Glob / Grep / WebFetch / WebSearch)
* 3. generic command/output fallback
*/
import { useState } from 'react';
import { useT } from '../i18n';
import { parseTodoWriteInput } from '../runtime/todos';
import { getToolRenderer, toRenderProps } from '../runtime/tool-renderers';
import type { AgentEvent } from '../types';
interface Props {
use: Extract<AgentEvent, { kind: 'tool_use' }>;
result?: Extract<AgentEvent, { kind: 'tool_result' }> | undefined;
// True while the parent run is still streaming. Forwarded to registered
// renderers via `status` so they can distinguish "executing" (run alive)
// from "inProgress" (run dead before result arrived).
runStreaming?: boolean;
// Set of file names that exist in the project folder. When the tool's
// `file_path`/`path` argument's basename appears in this set we surface
// an "open" button on the card. Pass `undefined` to skip the existence
// check (the button is then always shown for file-shaped tools).
projectFileNames?: Set<string>;
// Lifts a basename up to ProjectView so it can focus the matching tab
// in FileWorkspace.
onRequestOpenFile?: (name: string) => void;
}
export function ToolCard({
use,
result,
runStreaming,
projectFileNames,
onRequestOpenFile,
}: Props) {
const name = use.name;
const custom = getToolRenderer(name);
if (custom) {
// A misbehaving third-party renderer must not take down the whole
// assistant message — catch synchronous throws and fall through to the
// built-in family card. (React's own error boundaries still cover
// throws raised inside the returned tree once it's mounted.)
try {
const node = custom(toRenderProps(use, result, runStreaming ?? false));
if (node !== undefined && node !== null && node !== false) return <>{node}</>;
} catch (err) {
console.error(`[ToolCard] custom renderer for "${name}" threw; falling back`, err);
}
}
const ctx: FileToolCtx = { projectFileNames, onRequestOpenFile };
if (name === 'TodoWrite') return <TodoCard input={use.input} />;
if (name === 'Write' || name === 'create_file')
return <FileWriteCard input={use.input} result={result} ctx={ctx} />;
if (name === 'Edit' || name === 'str_replace_edit')
return <FileEditCard input={use.input} result={result} ctx={ctx} />;
if (name === 'Read' || name === 'read_file')
return <FileReadCard input={use.input} result={result} ctx={ctx} />;
if (name === 'Bash') return <BashCard input={use.input} result={result} />;
if (name === 'Glob' || name === 'list_files') return <GlobCard input={use.input} result={result} />;
if (name === 'Grep') return <GrepCard input={use.input} result={result} />;
if (name === 'WebFetch' || name === 'web_fetch') return <WebFetchCard input={use.input} />;
if (name === 'WebSearch' || name === 'web_search') return <WebSearchCard input={use.input} />;
return <GenericCard name={name} input={use.input} result={result} />;
}
interface FileToolCtx {
projectFileNames?: Set<string> | undefined;
onRequestOpenFile?: ((name: string) => void) | undefined;
}
function OpenInTabButton({ filePath, ctx }: { filePath: string; ctx: FileToolCtx }) {
const t = useT();
if (!ctx.onRequestOpenFile) return null;
if (!filePath || filePath === '(unnamed)') return null;
// The agent uses absolute paths; the project-file API keys on basename.
const baseName = filePath.split('/').pop() ?? filePath;
if (!baseName) return null;
if (ctx.projectFileNames && !ctx.projectFileNames.has(baseName)) return null;
const open = ctx.onRequestOpenFile;
return (
<button
type="button"
className="op-open"
onClick={() => open(baseName)}
title={t('tool.openInTab', { name: baseName })}
>
{t('tool.open')}
</button>
);
}
function TodoCard({ input }: { input: unknown }) {
const t = useT();
const todos = parseTodoWriteInput(input);
if (todos.length === 0) return <GenericCard name="TodoWrite" input={input} />;
const done = todos.filter((todo) => todo.status === 'completed').length;
return (
<div className="op-card op-todo">
<div className="op-card-head">
<span className="op-icon" aria-hidden></span>
<span className="op-title">{t('tool.todos')}</span>
<span className="op-meta">
{done}/{todos.length}
</span>
</div>
<ul className="todo-list">
{todos.map((todo, i) => (
<li key={i} className={`todo-item todo-${todo.status}`}>
<span className="todo-check" aria-hidden>
{todo.status === 'completed' ? '✓' : todo.status === 'in_progress' ? '◐' : '○'}
</span>
<span className="todo-text">
{todo.status === 'in_progress' && todo.activeForm ? todo.activeForm : todo.content}
</span>
</li>
))}
</ul>
</div>
);
}
function FileWriteCard({
input,
result,
ctx,
}: {
input: unknown;
result?: Props['result'];
ctx: FileToolCtx;
}) {
const t = useT();
const obj = (input ?? {}) as { file_path?: string; path?: string; content?: string };
const file = obj.file_path ?? obj.path ?? '(unnamed)';
const lines = typeof obj.content === 'string' ? obj.content.split('\n').length : null;
return (
<div className="op-card op-file">
<div className="op-card-head">
<span className="op-icon op-icon-write" aria-hidden>+</span>
<span className="op-title">{t('tool.write')}</span>
<code className="op-path">{file}</code>
{lines !== null ? (
<span className="op-meta">{t('tool.lines', { n: lines })}</span>
) : null}
<ResultBadge result={result} />
<OpenInTabButton filePath={file} ctx={ctx} />
</div>
</div>
);
}
function FileEditCard({
input,
result,
ctx,
}: {
input: unknown;
result?: Props['result'];
ctx: FileToolCtx;
}) {
const t = useT();
const obj = (input ?? {}) as {
file_path?: string;
path?: string;
old_string?: string;
new_string?: string;
edits?: { old_string?: string; new_string?: string }[];
};
const file = obj.file_path ?? obj.path ?? '(unnamed)';
const editCount = Array.isArray(obj.edits) ? obj.edits.length : 1;
return (
<div className="op-card op-file">
<div className="op-card-head">
<span className="op-icon op-icon-edit" aria-hidden></span>
<span className="op-title">{t('tool.edit')}</span>
<code className="op-path">{file}</code>
<span className="op-meta">
{editCount} {editCount === 1 ? t('tool.changeSingular') : t('tool.changePlural')}
</span>
<ResultBadge result={result} />
<OpenInTabButton filePath={file} ctx={ctx} />
</div>
</div>
);
}
function FileReadCard({
input,
result,
ctx,
}: {
input: unknown;
result?: Props['result'];
ctx: FileToolCtx;
}) {
const t = useT();
const obj = (input ?? {}) as { file_path?: string; path?: string };
const file = obj.file_path ?? obj.path ?? '(unnamed)';
return (
<div className="op-card op-file">
<div className="op-card-head">
<span className="op-icon op-icon-read" aria-hidden></span>
<span className="op-title">{t('tool.read')}</span>
<code className="op-path">{file}</code>
<ResultBadge result={result} />
<OpenInTabButton filePath={file} ctx={ctx} />
</div>
</div>
);
}
function BashCard({ input, result }: { input: unknown; result?: Props['result'] }) {
const t = useT();
const obj = (input ?? {}) as { command?: string; description?: string };
const command = obj.command ?? '';
const desc = obj.description;
const [open, setOpen] = useState(false);
return (
<div className="op-card op-bash">
<div className="op-card-head">
<span className="op-icon" aria-hidden>$</span>
<span className="op-title">{t('tool.bash')}</span>
{desc ? <span className="op-meta op-desc">{desc}</span> : null}
<ResultBadge result={result} />
{result && result.content ? (
<button className="op-toggle" onClick={() => setOpen((o) => !o)}>
{open ? t('tool.hide') : t('tool.output')}
</button>
) : null}
</div>
<pre className="op-command">{truncate(command, 400)}</pre>
{open && result ? (
<pre className="op-output">{truncate(result.content, 4000)}</pre>
) : null}
</div>
);
}
function GlobCard({ input, result }: { input: unknown; result?: Props['result'] }) {
const t = useT();
const obj = (input ?? {}) as { pattern?: string; path?: string };
return (
<div className="op-card op-search">
<div className="op-card-head">
<span className="op-icon" aria-hidden></span>
<span className="op-title">{t('tool.glob')}</span>
<code className="op-path">{obj.pattern ?? '*'}</code>
{obj.path ? (
<span className="op-meta">{t('tool.in', { path: obj.path })}</span>
) : null}
<ResultBadge result={result} />
</div>
</div>
);
}
function GrepCard({ input, result }: { input: unknown; result?: Props['result'] }) {
const t = useT();
const obj = (input ?? {}) as { pattern?: string; path?: string; glob?: string };
return (
<div className="op-card op-search">
<div className="op-card-head">
<span className="op-icon" aria-hidden></span>
<span className="op-title">{t('tool.grep')}</span>
<code className="op-path">{obj.pattern ?? ''}</code>
{obj.path ? (
<span className="op-meta">{t('tool.in', { path: obj.path })}</span>
) : null}
<ResultBadge result={result} />
</div>
</div>
);
}
function WebFetchCard({ input }: { input: unknown }) {
const t = useT();
const obj = (input ?? {}) as { url?: string };
return (
<div className="op-card op-web">
<div className="op-card-head">
<span className="op-icon" aria-hidden></span>
<span className="op-title">{t('tool.fetch')}</span>
<code className="op-path">{obj.url ?? ''}</code>
</div>
</div>
);
}
function WebSearchCard({ input }: { input: unknown }) {
const t = useT();
const obj = (input ?? {}) as { query?: string };
return (
<div className="op-card op-web">
<div className="op-card-head">
<span className="op-icon" aria-hidden></span>
<span className="op-title">{t('tool.search')}</span>
<code className="op-path">{obj.query ?? ''}</code>
</div>
</div>
);
}
function GenericCard({
name,
input,
result,
}: {
name: string;
input: unknown;
result?: Props['result'];
}) {
const summary = describeInput(input);
return (
<div className="op-card op-generic">
<div className="op-card-head">
<span className="op-icon" aria-hidden>·</span>
<span className="op-title">{name}</span>
{summary ? <span className="op-meta">{truncate(summary, 200)}</span> : null}
<ResultBadge result={result} />
</div>
</div>
);
}
function ResultBadge({ result }: { result?: Props['result'] }) {
const t = useT();
if (!result) return <span className="op-status op-status-running">{t('tool.running')}</span>;
if (result.isError) return <span className="op-status op-status-error">{t('tool.error')}</span>;
return <span className="op-status op-status-ok">{t('tool.done')}</span>;
}
function describeInput(input: unknown): string {
if (input == null) return '';
if (typeof input === 'string') return input;
if (typeof input !== 'object') return String(input);
const obj = input as Record<string, unknown>;
for (const key of ['file_path', 'path', 'pattern', 'url', 'query', 'name', 'command']) {
const v = obj[key];
if (typeof v === 'string') return v;
}
try {
return JSON.stringify(obj);
} catch {
return '';
}
}
function truncate(s: string, n: number): string {
if (s.length <= n) return s;
return s.slice(0, n - 1) + '…';
}
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { decideAutoOpenAfterWrite } from './auto-open-file';
describe('decideAutoOpenAfterWrite', () => {
it('returns shouldOpen=false when filePath is empty', () => {
const result = decideAutoOpenAfterWrite('', [{ name: 'index.html' }]);
expect(result).toEqual({ shouldOpen: false, fileName: null });
});
it('returns shouldOpen=true when filePath equals a project file path', () => {
const result = decideAutoOpenAfterWrite('index.html', [
{ name: 'index.html', path: 'index.html' },
{ name: 'styles.css', path: 'styles.css' },
]);
expect(result).toEqual({ shouldOpen: true, fileName: 'index.html' });
});
it('returns shouldOpen=false when filePath has slashes but matches no project path', () => {
// Regression: this is the "rogue empty tab" case — the agent edited a
// file outside the project (e.g. an upstream repo's source file) and
// we must NOT open a placeholder tab for it. filePath has a slash, so
// the basename fallback is intentionally skipped.
const result = decideAutoOpenAfterWrite(
'/home/bryan/projects/open-design/apps/daemon/src/project-watchers.ts',
[
{ name: 'index.html', path: 'index.html' },
{ name: 'App.jsx', path: 'App.jsx' },
],
);
expect(result).toEqual({ shouldOpen: false, fileName: null });
});
it('falls back to basename match when filePath is just a basename', () => {
const result = decideAutoOpenAfterWrite('App.jsx', [
{ name: 'index.html', path: 'index.html' },
{ name: 'App.jsx', path: 'App.jsx' },
{ name: 'styles.css', path: 'styles.css' },
{ name: 'README.md', path: 'README.md' },
]);
expect(result).toEqual({ shouldOpen: true, fileName: 'App.jsx' });
});
it('matches an absolute filePath via path-suffix against a nested project file', () => {
// Real-world case: the agent passes an absolute file_path; the project
// file lives at "prototype/App.jsx". The decision must still resolve
// unambiguously, returning the project-relative file name.
const result = decideAutoOpenAfterWrite(
'/home/bryan/projects/open-design/.od/projects/abc/prototype/App.jsx',
[
{ name: 'index.html', path: 'index.html' },
{ name: 'prototype/App.jsx', path: 'prototype/App.jsx' },
],
);
expect(result).toEqual({ shouldOpen: true, fileName: 'prototype/App.jsx' });
});
it('declines when an absolute filePath could match multiple nested project files (ambiguous)', () => {
// Two project files share the basename "App.jsx" but live in different
// subdirs. The agent's filePath ends with "/App.jsx" only, with no
// disambiguating subdirectory match — refuse rather than open the wrong file.
const result = decideAutoOpenAfterWrite(
'/some/external/path/App.jsx',
[
{ name: 'src/App.jsx', path: 'src/App.jsx' },
{ name: 'lib/App.jsx', path: 'lib/App.jsx' },
],
);
expect(result).toEqual({ shouldOpen: false, fileName: null });
});
it('declines when filePath has a slash and no project path is a suffix match', () => {
// Agent edited /upstream/repo/App.jsx; project also has prototype/App.jsx.
// The previous (basename-only) implementation would have opened the
// wrong file; the path-suffix check leaves zero matches and the
// basename fallback is intentionally skipped because filePath has a slash.
const result = decideAutoOpenAfterWrite('/upstream/repo/App.jsx', [
{ name: 'prototype/App.jsx', path: 'prototype/App.jsx' },
]);
expect(result).toEqual({ shouldOpen: false, fileName: null });
});
it('still works when ProjectFile entries omit the optional path field', () => {
// Defensive: ProjectFile.path is optional in the API contract. Fall
// back to using `name` (which the daemon populates with the full
// project-relative path) when path is missing.
const result = decideAutoOpenAfterWrite('index.html', [
{ name: 'index.html' },
{ name: 'styles.css' },
]);
expect(result).toEqual({ shouldOpen: true, fileName: 'index.html' });
});
it('declines a basename fallback when multiple project files share the basename', () => {
const result = decideAutoOpenAfterWrite('App.jsx', [
{ name: 'src/App.jsx', path: 'src/App.jsx' },
{ name: 'lib/App.jsx', path: 'lib/App.jsx' },
]);
expect(result).toEqual({ shouldOpen: false, fileName: null });
});
});
+75
View File
@@ -0,0 +1,75 @@
// Decide whether to auto-open a file after an agent Write/Edit tool result.
// Only files that exist in the project's refreshed file list should open as
// tabs — out-of-project paths (upstream repo edits, system files) would
// otherwise create permanent placeholder tabs.
//
// Resolution order:
// 1) Path-suffix match. If the agent's `filePath` equals or ends with
// `/${file.path}` (full segment alignment), treat it as a positive
// identification of that project file. If exactly one file matches,
// open it. If multiple files share a path-suffix with `filePath`,
// decline as ambiguous rather than open the wrong one.
// 2) Basename fallback — only when `filePath` has no slash (it's already
// a basename) and exactly one project file has that basename. This
// preserves the golden path for short filePath inputs while still
// rejecting external edits that happen to share a basename with a
// project file (those will have a slash in `filePath` and reach this
// step with zero suffix matches → declined).
interface CandidateFile {
readonly name: string;
readonly path?: string;
}
function basenameOf(p: string): string {
return p.split('/').pop() ?? p;
}
export function decideAutoOpenAfterWrite(
filePath: string,
nextFiles: ReadonlyArray<CandidateFile>,
): { shouldOpen: boolean; fileName: string | null } {
if (!filePath) return { shouldOpen: false, fileName: null };
// 1) Path-suffix match against full project-relative paths.
const suffixMatches: CandidateFile[] = [];
for (const f of nextFiles) {
const rel = f.path ?? f.name;
if (!rel) continue;
if (filePath === rel) {
suffixMatches.push(f);
continue;
}
// Require segment alignment: filePath ends with "/${rel}" so that
// "subdir/App.jsx" matches ".../subdir/App.jsx" but not
// ".../notsubdir/App.jsx".
if (filePath.length > rel.length && filePath.endsWith('/' + rel)) {
suffixMatches.push(f);
}
}
if (suffixMatches.length === 1) {
return { shouldOpen: true, fileName: suffixMatches[0]!.name };
}
if (suffixMatches.length > 1) {
// Multiple project files plausibly correspond to this path — refuse
// rather than open the wrong one.
return { shouldOpen: false, fileName: null };
}
// 2) Basename fallback only when filePath itself is just a basename.
// If filePath contains a slash but didn't path-suffix-match anything,
// it's an external edit that happens to share a basename — declining
// is the whole point of the guard.
if (filePath.includes('/')) {
return { shouldOpen: false, fileName: null };
}
const basenameMatches = nextFiles.filter((f) => {
const rel = f.path ?? f.name;
return rel ? basenameOf(rel) === filePath : false;
});
if (basenameMatches.length === 1) {
return { shouldOpen: true, fileName: basenameMatches[0]!.name };
}
return { shouldOpen: false, fileName: null };
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { parseForceInline, shouldUrlLoadHtmlPreview } from './file-viewer-render-mode';
describe('shouldUrlLoadHtmlPreview', () => {
const base = { mode: 'preview' as const, isDeck: false, commentMode: false, forceInline: false };
it('URL-loads a plain HTML preview by default', () => {
expect(shouldUrlLoadHtmlPreview(base)).toBe(true);
});
it('falls back to srcDoc when the file is a deck (deck bridge required)', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true })).toBe(false);
});
it('falls back to srcDoc when comment mode is active (comment bridge required)', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true })).toBe(false);
});
it('falls back to srcDoc when the user opts in via forceInline', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, forceInline: true })).toBe(false);
});
it('does not URL-load while the source-code tab is active', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, mode: 'source' })).toBe(false);
});
it('treats any disqualifying flag as sufficient on its own', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, commentMode: true })).toBe(false);
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, forceInline: true })).toBe(false);
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true, forceInline: true })).toBe(false);
});
});
describe('parseForceInline', () => {
it('returns false when the parameter is absent', () => {
expect(parseForceInline('')).toBe(false);
expect(parseForceInline('?other=1')).toBe(false);
expect(parseForceInline(null)).toBe(false);
expect(parseForceInline(undefined)).toBe(false);
});
it('returns true for the documented opt-in values', () => {
expect(parseForceInline('?forceInline=1')).toBe(true);
expect(parseForceInline('?forceInline=true')).toBe(true);
expect(parseForceInline('?forceInline=TRUE')).toBe(true);
expect(parseForceInline('?forceInline=yes')).toBe(true);
expect(parseForceInline('?forceInline=on')).toBe(true);
});
it('returns false for explicit opt-out values and unrelated strings', () => {
expect(parseForceInline('?forceInline=0')).toBe(false);
expect(parseForceInline('?forceInline=false')).toBe(false);
expect(parseForceInline('?forceInline=no')).toBe(false);
expect(parseForceInline('?forceInline=off')).toBe(false);
expect(parseForceInline('?forceInline=banana')).toBe(false);
});
it('treats an empty value as absent (defensive: ?forceInline= shows up as "")', () => {
expect(parseForceInline('?forceInline=')).toBe(false);
});
it('accepts a pre-built URLSearchParams', () => {
const params = new URLSearchParams('forceInline=1&other=foo');
expect(parseForceInline(params)).toBe(true);
});
it('survives surrounding whitespace in the value', () => {
const params = new URLSearchParams();
params.set('forceInline', ' 1 ');
expect(parseForceInline(params)).toBe(true);
});
});
@@ -0,0 +1,61 @@
/**
* Decide between two HTML preview render strategies in FileViewer:
*
* - URL-load: <iframe src="/api/projects/:id/raw/:file"> — the browser
* fetches each <script src> / <link href> as its own request. Source
* maps work, DevTools shows real filenames, per-asset HTTP caching
* applies, and a single broken file no longer takes down the whole
* iframe. This is the right default for multi-file artifacts (e.g.
* React prototypes that ship dozens of `.jsx` files).
*
* - srcDoc inline: build a self-contained document (via buildSrcdoc),
* optionally with relative assets concatenated in by inlineRelative-
* Assets, and pass it via the iframe's srcDoc attribute. Required
* when we need to inject host-side bridges that have to run before
* user scripts (deck navigation, comment-mode targeting), and useful
* as an explicit opt-in for self-contained exports.
*
* The two helpers below isolate the decision so it's directly unit-
* testable without dragging the whole FileViewer React tree into a
* jsdom harness.
*/
export interface UrlLoadDecision {
/** Whether the viewer is showing the rendered preview vs. the raw source. */
mode: 'preview' | 'source';
/** Treat as a slide deck — needs the deck postMessage bridge. */
isDeck: boolean;
/** Comment mode is active — needs the comment bridge. */
commentMode: boolean;
/** User explicitly opted into the inline path via ?forceInline=1. */
forceInline: boolean;
}
/**
* Returns true when an HTML file's preview iframe should load directly
* from its raw URL (via `<iframe src=...>`) rather than through the
* srcDoc inline path. Pure function — caller is responsible for the
* non-HTML / source-mode early returns.
*/
export function shouldUrlLoadHtmlPreview(d: UrlLoadDecision): boolean {
if (d.mode !== 'preview') return false;
if (d.isDeck) return false;
if (d.commentMode) return false;
if (d.forceInline) return false;
return true;
}
/**
* Read the `forceInline` opt-out from a URL search string or an existing
* URLSearchParams. Accepts `1`, `true`, `yes`, `on` (case-insensitive).
* Anything else — including `0`, `false`, an unrelated value, or a
* missing parameter — returns false.
*/
export function parseForceInline(search: string | URLSearchParams | null | undefined): boolean {
if (!search) return false;
const params = typeof search === 'string' ? new URLSearchParams(search) : search;
const value = params.get('forceInline');
if (value === null) return false;
const normalized = value.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
}
+71
View File
@@ -0,0 +1,71 @@
import type { AgentModelOption } from '../types';
// Render the `<option>` children for a model `<select>`. When the list
// contains `provider/model` ids (opencode's listing has hundreds), we
// group them under `<optgroup>` so the dropdown is navigable. Flat lists
// (Claude, Codex, Gemini, Qwen) are emitted as plain options.
//
// `'default'` is always pinned first (no group), so the user can return
// to "let the CLI decide" with one click.
export function renderModelOptions(models: AgentModelOption[]) {
const groups = new Map<string, AgentModelOption[]>();
const flat: AgentModelOption[] = [];
for (const m of models) {
const slash = m.id.indexOf('/');
if (m.id === 'default' || slash <= 0) {
flat.push(m);
continue;
}
const provider = m.id.slice(0, slash);
const arr = groups.get(provider) ?? [];
arr.push(m);
groups.set(provider, arr);
}
if (groups.size === 0) {
return (
<>
{flat.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</>
);
}
return (
<>
{flat.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
{Array.from(groups.entries()).map(([provider, items]) => (
<optgroup key={provider} label={provider}>
{items.map((m) => (
<option key={m.id} value={m.id}>
{/* Strip the redundant `provider/` prefix from the label
inside its own optgroup; keep it in the value so the
CLI sees the fully-qualified id. */}
{m.label.startsWith(`${provider}/`)
? m.label.slice(provider.length + 1)
: m.label}
</option>
))}
</optgroup>
))}
</>
);
}
// True when the picked model id isn't one of the listed options — i.e.
// the user has typed a custom id and we should keep the custom input
// visible / the dropdown showing "Custom…".
export function isCustomModel(
modelId: string | null | undefined,
models: AgentModelOption[],
): boolean {
if (modelId == null) return false;
return !models.some((m) => m.id === modelId);
}
export const CUSTOM_MODEL_SENTINEL = '__custom__';
+373
View File
@@ -0,0 +1,373 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../../i18n';
import { Icon } from '../Icon';
import type { PetConfig } from '../../types';
import {
ambientLines,
pickAmbientRow,
preferredRowId,
resolveActivePet,
type PetInteraction,
} from './pets';
import { PetSpriteFace } from './PetSpriteFace';
interface Props {
pet: PetConfig | undefined;
onTuck: () => void;
onOpenSettings: () => void;
}
const STORAGE_KEY = 'open-design:pet-position';
interface Position {
// Distances from the right/bottom of the viewport so the overlay
// sticks to the corner across resizes. Saved in localStorage.
right: number;
bottom: number;
}
const DEFAULT_POSITION: Position = { right: 24, bottom: 24 };
// How long the pet has to sit untouched before the overlay flips to
// the "waiting" animation row. Sized to sit comfortably past a few
// ambient beats so the pet clearly feels alive before falling through
// to the more static "bored" cue.
const WAITING_AFTER_MS = 45000;
// Ambient idle choreography — while nobody is hovering / dragging, the
// overlay occasionally swaps the `idle` row for a random non-idle row
// from the atlas (wave, hop, look around) so the pet visibly has a
// life of its own instead of breathing in place forever. Each ambient
// "beat" plays for a chunk of time, then the pet returns to idle for
// a longer rest window before the next beat. Randomising both windows
// prevents the rhythm from feeling mechanical, and the rest window is
// intentionally generous so the pet reads as calm rather than fidgety.
const AMBIENT_PLAY_MIN_MS = 1400;
const AMBIENT_PLAY_VARIANCE_MS = 900;
const AMBIENT_REST_MIN_MS = 9000;
const AMBIENT_REST_VARIANCE_MS = 9000;
const AMBIENT_INITIAL_DELAY_MIN_MS = 4000;
const AMBIENT_INITIAL_DELAY_VARIANCE_MS = 3000;
// Filters pointer jitter and accidental nudges before the overlay
// commits to a directional running animation. Picked to feel
// responsive without flickering on small mouse wiggles.
const DRAG_GESTURE_MIN_PX = 14;
// Require one axis to clearly dominate before swapping running-* for
// jumping/waving so diagonal drags don't strobe between rows.
const DRAG_AXIS_BIAS = 1.18;
function loadPosition(): Position {
if (typeof window === 'undefined') return DEFAULT_POSITION;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_POSITION;
const parsed = JSON.parse(raw) as Partial<Position>;
return {
right: typeof parsed.right === 'number' ? parsed.right : DEFAULT_POSITION.right,
bottom: typeof parsed.bottom === 'number' ? parsed.bottom : DEFAULT_POSITION.bottom,
};
} catch {
return DEFAULT_POSITION;
}
}
function savePosition(p: Position) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
} catch {
/* ignore */
}
}
// Compact floating sprite + speech bubble. Rendered at the document
// root via App.tsx so it stays put when the user navigates between
// the entry and project views.
export function PetOverlay({ pet, onTuck, onOpenSettings }: Props) {
const t = useT();
const active = useMemo(() => resolveActivePet(pet), [pet]);
const [bubbleOpen, setBubbleOpen] = useState(false);
const [ambientIdx, setAmbientIdx] = useState(0);
const [position, setPosition] = useState<Position>(() => loadPosition());
// Interaction state drives which atlas row plays. Only meaningful
// for atlas-backed custom pets — the renderer ignores it for emoji
// / single-strip pets.
const [interaction, setInteraction] = useState<PetInteraction>('idle');
// Ambient row id that temporarily overrides the `idle` row. Null
// whenever the pet is resting on its baseline row so the user-facing
// interaction state wins as soon as a gesture fires.
const [ambientRowId, setAmbientRowId] = useState<string | null>(null);
const [hovered, setHovered] = useState(false);
const dragRef = useRef<{
startX: number;
startY: number;
startRight: number;
startBottom: number;
moved: boolean;
// Last classified gesture direction. Kept on the ref so we don't
// trigger a state update + render on every pointermove tick.
direction: 'right' | 'left' | 'up' | 'down' | null;
} | null>(null);
// Idle timer that flips the pet to the `waiting` row after a few
// seconds without hover or drag. Reset by every interaction.
const waitingTimerRef = useRef<number | null>(null);
// Show the greeting briefly the first time the overlay mounts after a
// wake. Auto-tuck the bubble after 4s so it does not linger forever.
useEffect(() => {
if (!active) return;
setBubbleOpen(true);
const id = window.setTimeout(() => setBubbleOpen(false), 4000);
return () => window.clearTimeout(id);
}, [active?.id]);
useEffect(() => {
savePosition(position);
}, [position]);
const lines = useMemo(
() => (active ? [active.greeting, ...ambientLines(active.name)] : []),
[active],
);
const visibleLine = lines.length > 0 ? lines[ambientIdx % lines.length] : '';
// (Re)arms the long-idle waiting timer. Called every time the user
// interacts so an active session never falls into "waiting" mid-drag.
const armWaitingTimer = useCallback(() => {
if (waitingTimerRef.current != null) {
window.clearTimeout(waitingTimerRef.current);
}
waitingTimerRef.current = window.setTimeout(() => {
// Only escalate to `waiting` from a calm `idle` baseline; an
// active hover / drag should keep their own animation.
setInteraction((prev) => (prev === 'idle' ? 'waiting' : prev));
waitingTimerRef.current = null;
}, WAITING_AFTER_MS);
}, []);
// Start the idle clock when the pet becomes visible / changes.
useEffect(() => {
if (!active) return;
armWaitingTimer();
return () => {
if (waitingTimerRef.current != null) {
window.clearTimeout(waitingTimerRef.current);
waitingTimerRef.current = null;
}
};
}, [active?.id, armWaitingTimer]);
// Ambient idle choreography scheduler. Only runs while the pet is in
// `idle` and has an atlas with ambient-eligible rows; otherwise we
// bail out and leave the base row alone. The effect is deliberately
// scoped to `interaction === 'idle'` so any user gesture
// (hover / drag / pointerdown) cancels the currently playing beat via
// cleanup and the user-facing state takes over instantly.
useEffect(() => {
if (interaction !== 'idle') {
setAmbientRowId(null);
return;
}
const atlas = active?.atlas;
if (!atlas || atlas.rowsDef.length === 0) return;
let playTimer: number | undefined;
let restTimer: number | undefined;
let lastPlayedId: string | undefined;
const playBeat = () => {
const def = pickAmbientRow(atlas, lastPlayedId);
if (!def) return;
lastPlayedId = def.id;
setAmbientRowId(def.id);
const playMs =
AMBIENT_PLAY_MIN_MS + Math.floor(Math.random() * AMBIENT_PLAY_VARIANCE_MS);
playTimer = window.setTimeout(() => {
setAmbientRowId(null);
const restMs =
AMBIENT_REST_MIN_MS + Math.floor(Math.random() * AMBIENT_REST_VARIANCE_MS);
restTimer = window.setTimeout(playBeat, restMs);
}, playMs);
};
// Let the pet breathe for a moment before the first beat so a
// freshly-woken overlay doesn't snap straight into a flourish.
const initialDelay =
AMBIENT_INITIAL_DELAY_MIN_MS +
Math.floor(Math.random() * AMBIENT_INITIAL_DELAY_VARIANCE_MS);
restTimer = window.setTimeout(playBeat, initialDelay);
return () => {
if (playTimer != null) window.clearTimeout(playTimer);
if (restTimer != null) window.clearTimeout(restTimer);
setAmbientRowId(null);
};
}, [interaction, active?.id, active?.atlas]);
if (!active) return null;
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const target = event.currentTarget;
target.setPointerCapture(event.pointerId);
dragRef.current = {
startX: event.clientX,
startY: event.clientY,
startRight: position.right,
startBottom: position.bottom,
moved: false,
direction: null,
};
armWaitingTimer();
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) return;
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 4) return;
drag.moved = true;
// Convert pointer movement into right/bottom offsets so the sprite
// tracks the cursor while staying anchored to the corner system.
// The clamp budget (~120px) keeps the 96px sprite plus its drop
// shadow on-screen even when dragged toward the opposite edge.
const nextRight = Math.max(8, Math.min(window.innerWidth - 120, drag.startRight - dx));
const nextBottom = Math.max(8, Math.min(window.innerHeight - 120, drag.startBottom - dy));
setPosition({ right: nextRight, bottom: nextBottom });
// Classify the gesture direction once it clears the jitter floor
// and one axis clearly dominates the other. The animation then
// sticks until the user reverses past the threshold again.
const absX = Math.abs(dx);
const absY = Math.abs(dy);
if (absX < DRAG_GESTURE_MIN_PX && absY < DRAG_GESTURE_MIN_PX) return;
let dir: 'right' | 'left' | 'up' | 'down' | null = null;
if (absX >= absY * DRAG_AXIS_BIAS) {
dir = dx > 0 ? 'right' : 'left';
} else if (absY >= absX * DRAG_AXIS_BIAS) {
dir = dy < 0 ? 'up' : 'down';
}
if (dir && dir !== drag.direction) {
drag.direction = dir;
setInteraction(
dir === 'right'
? 'drag-right'
: dir === 'left'
? 'drag-left'
: dir === 'up'
? 'drag-up'
: 'drag-down',
);
}
armWaitingTimer();
};
const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
dragRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
/* ignore */
}
// A tap (no drag) toggles the speech bubble and rotates the line.
if (drag && !drag.moved) {
setBubbleOpen((open) => {
const next = !open;
if (next) setAmbientIdx((i) => (i + 1) % Math.max(1, lines.length));
return next;
});
}
// After the drag ends, fall back to the resting animation so the
// pet stops "running" the moment the user lets go. Hovered state
// wins so a release-into-hover keeps the wave going.
setInteraction(hovered ? 'hover' : 'idle');
armWaitingTimer();
};
const onPointerEnter = () => {
setHovered(true);
// Don't override an active drag direction with the hover wave —
// the user is mid-gesture and they expect the running cycle to
// keep playing until they let go.
if (!dragRef.current) setInteraction('hover');
armWaitingTimer();
};
const onPointerLeave = () => {
setHovered(false);
if (!dragRef.current) setInteraction('idle');
armWaitingTimer();
};
return (
<div
className="pet-overlay"
role="complementary"
aria-label={t('pet.overlayAria')}
style={{
right: position.right,
bottom: position.bottom,
// The accent drives the halo, the bubble border, and the focus
// ring on the action buttons via CSS custom property cascade.
['--pet-accent' as string]: active.accent,
}}
>
{bubbleOpen ? (
<div className="pet-bubble" role="status">
<div className="pet-bubble-name">{active.name}</div>
<div className="pet-bubble-line">{visibleLine}</div>
<div className="pet-bubble-actions">
<button
type="button"
className="pet-bubble-btn"
onClick={onOpenSettings}
title={t('pet.settingsTitle')}
>
<Icon name="settings" size={12} />
<span>{t('pet.changePet')}</span>
</button>
<button
type="button"
className="pet-bubble-btn"
onClick={onTuck}
title={t('pet.tuckTitle')}
>
<Icon name="close" size={12} />
<span>{t('pet.tuck')}</span>
</button>
</div>
</div>
) : null}
<div
className="pet-sprite"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
title={t('pet.spriteTitle', { name: active.name })}
aria-label={t('pet.spriteAria', { name: active.name })}
data-pet-state={interaction}
data-pet-ambient={ambientRowId ?? undefined}
style={{
// For atlas-backed pets the row swap *is* the animation, so
// we let the sprite element sit still and animate frames
// inside it. Built-ins / single-strip uploads keep their
// gentle CSS-named bob via --pet-anim.
['--pet-anim' as string]: active.atlas
? 'none'
: `pet-${active.animation}`,
}}
>
<PetSpriteFace
active={active}
className="pet-sprite-glyph"
rowId={ambientRowId ?? preferredRowId(interaction)}
/>
<span className="pet-sprite-shadow" aria-hidden />
</div>
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { useEffect, useState } from 'react';
import { useT } from '../../i18n';
import { Icon } from '../Icon';
import type { AppConfig, PetConfig } from '../../types';
import { DEFAULT_PET } from '../../state/config';
import { BUILT_IN_PETS, CUSTOM_PET_ID, defaultCustomPet, resolveActivePet } from './pets';
import { PetSpriteFace } from './PetSpriteFace';
interface Props {
config: AppConfig;
// Adopt + wake a built-in or the user's custom pet inline. The rail
// wires this to the saved config so picks survive across reloads
// without bouncing the user into Settings for the common case.
onAdoptInline: (petId: string) => void;
// Open Settings → Pets so the user can tweak the custom pet, change
// accent, or read the catalog flavor copy.
onOpenPetSettings: () => void;
// Tuck the live overlay without changing the active pet id.
onTuck: () => void;
// Optional "remove the rail entirely" action. When provided, the
// header gets a × button that hides the rail from the layout (the
// user re-summons it from the avatar dropdown). Distinct from the
// existing collapse toggle, which only narrows the column.
onHide?: () => void;
}
const COLLAPSED_KEY = 'open-design:pet-rail-collapsed';
function loadCollapsed(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem(COLLAPSED_KEY) === '1';
} catch {
return false;
}
}
// Vertical pet column rendered to the right of the entry view's main
// content. Doubles as a discovery surface (un-adopted users see the
// full catalog inline) and a switcher (adopted users tap to swap).
export function PetRail({ config, onAdoptInline, onOpenPetSettings, onTuck, onHide }: Props) {
const t = useT();
const [collapsed, setCollapsed] = useState<boolean>(() => loadCollapsed());
const pet: PetConfig = config.pet ?? { ...DEFAULT_PET, custom: defaultCustomPet() };
useEffect(() => {
try {
window.localStorage.setItem(COLLAPSED_KEY, collapsed ? '1' : '0');
} catch {
/* ignore */
}
}, [collapsed]);
const activeId = pet.adopted ? pet.petId : null;
if (collapsed) {
return (
<aside className="pet-rail collapsed" aria-label={t('pet.railAria')}>
<button
type="button"
className="pet-rail-toggle"
onClick={() => setCollapsed(false)}
title={t('pet.railExpand')}
aria-label={t('pet.railExpand')}
>
<span className="pet-rail-toggle-glyph" aria-hidden>🐾</span>
<Icon name="chevron-left" size={14} />
</button>
</aside>
);
}
return (
<aside className="pet-rail" aria-label={t('pet.railAria')}>
<header className="pet-rail-head">
<div className="pet-rail-title">
<span aria-hidden>🐾</span>
<strong>{t('pet.railTitle')}</strong>
</div>
<div className="pet-rail-head-actions">
<button
type="button"
className="pet-rail-collapse"
onClick={() => setCollapsed(true)}
title={t('pet.railCollapse')}
aria-label={t('pet.railCollapse')}
>
<Icon name="chevron-right" size={14} />
</button>
{onHide ? (
<button
type="button"
className="pet-rail-collapse"
onClick={onHide}
title={t('pet.railHide')}
aria-label={t('pet.railHide')}
>
<Icon name="close" size={14} />
</button>
) : null}
</div>
</header>
<p className="pet-rail-hint">{t('pet.railHint')}</p>
<div className="pet-rail-status">
{pet.adopted ? (
<button
type="button"
className="pet-rail-status-pill"
onClick={onTuck}
title={pet.enabled ? t('pet.tuckTitle') : t('pet.wakeTitle')}
>
<Icon name={pet.enabled ? 'eye' : 'sparkles'} size={12} />
<span>{pet.enabled ? t('pet.tuck') : t('pet.wake')}</span>
</button>
) : (
<span className="pet-rail-fresh">{t('pet.adoptCallout')}</span>
)}
</div>
<div className="pet-rail-list">
{BUILT_IN_PETS.map((p) => {
const active = activeId === p.id;
return (
<button
type="button"
key={p.id}
className={`pet-rail-item${active ? ' active' : ''}`}
onClick={() => onAdoptInline(p.id)}
aria-pressed={active}
style={{ ['--pet-accent' as string]: p.accent }}
title={p.flavor}
>
<span className="pet-rail-item-glyph" aria-hidden>{p.glyph}</span>
<span className="pet-rail-item-meta">
<span className="pet-rail-item-name">{p.name}</span>
<span className="pet-rail-item-flavor">{p.flavor}</span>
</span>
{active ? (
<Icon name="check" size={14} aria-hidden />
) : null}
</button>
);
})}
<button
type="button"
className={`pet-rail-item custom${activeId === CUSTOM_PET_ID ? ' active' : ''}`}
onClick={() => onAdoptInline(CUSTOM_PET_ID)}
style={{ ['--pet-accent' as string]: pet.custom.accent }}
>
<span className="pet-rail-item-glyph" aria-hidden>
<PetSpriteFace
active={
resolveActivePet({ ...pet, adopted: true, petId: CUSTOM_PET_ID })!
}
size={28}
/>
</span>
<span className="pet-rail-item-meta">
<span className="pet-rail-item-name">
{pet.custom.name || t('pet.useCustom')}
</span>
<span className="pet-rail-item-flavor">{t('pet.railCustomFlavor')}</span>
</span>
{activeId === CUSTOM_PET_ID ? (
<Icon name="check" size={14} aria-hidden />
) : null}
</button>
</div>
<button
type="button"
className="pet-rail-customize"
onClick={onOpenPetSettings}
>
<Icon name="sparkles" size={12} />
<span>{t('pet.railCustomize')}</span>
</button>
</aside>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
import { useEffect, useState, type CSSProperties } from 'react';
import type { PetAtlasRowDef } from '../../types';
import type { ResolvedPet } from './pets';
interface Props {
active: ResolvedPet;
className?: string;
// Optional explicit pixel size; the overlay leaves it unset and
// inherits container metrics, while the rail / settings preview
// pin a concrete size to keep the cell shape consistent.
size?: number;
// Atlas-mode only — which row id (e.g. `idle`, `waving`, `running-right`)
// to play right now. Defaults to `idle` (or the first row, when the
// atlas does not declare an idle row). Ignored for emoji / strip pets.
rowId?: string;
}
// Renders the pet's face. Four cases:
//
// 1. No imageUrl — just the emoji glyph (legacy / built-ins).
// 2. imageUrl + atlas — the full Codex 8x9 sprite atlas. We pick the
// requested row by index and step through that row's frames at
// the row's per-second fps. Mirrors the `codex-pets-react`
// `SpriteAnimator` behaviour so different interactions (idle,
// waving, running-*) play the right row of the atlas.
// 3. imageUrl + frames > 1 — legacy horizontal spritesheet (one row
// cropped out). Walked through with a CSS `steps()` animation.
// 4. imageUrl + frames === 1 — single static image.
export function PetSpriteFace({ active, className, size, rowId }: Props) {
if (!active.imageUrl) {
const style: CSSProperties | undefined = size
? { fontSize: Math.round(size * 0.85), width: size, height: size, lineHeight: 1 }
: undefined;
return (
<span className={className} aria-hidden style={style}>
{active.glyph}
</span>
);
}
if (active.atlas && active.atlas.rowsDef.length > 0) {
return (
<AtlasSprite
imageUrl={active.imageUrl}
cols={Math.max(1, active.atlas.cols)}
rows={Math.max(1, active.atlas.rows)}
rowsDef={active.atlas.rowsDef}
rowId={rowId}
className={className}
size={size}
/>
);
}
const frames = Math.max(1, active.frames ?? 1);
const fps = Math.max(1, active.fps ?? 6);
if (frames === 1) {
return (
<span
className={`${className ?? ''} pet-image static`.trim()}
aria-hidden
style={{
backgroundImage: `url(${active.imageUrl})`,
width: size,
height: size,
}}
/>
);
}
// Strip mode — N frames laid out horizontally. The image is
// (N × container_width) wide, so the visible frame is selected by
// sliding background-position-x from 0% to 100% in (N-1) steps.
// `steps(N, jump-none)` is required because the default jump-end
// would land on 0/N, 1/N, …, (N-1)/N, which slices each frame mid-cell;
// jump-none lands on the actual cell boundaries 0/(N-1) … 1.
const durationMs = Math.round((frames / fps) * 1000);
return (
<span
className={`${className ?? ''} pet-image frames`.trim()}
aria-hidden
style={{
backgroundImage: `url(${active.imageUrl})`,
backgroundSize: `${frames * 100}% 100%`,
animation: `pet-frames ${durationMs}ms steps(${frames}, jump-none) infinite`,
width: size,
height: size,
}}
/>
);
}
interface AtlasSpriteProps {
imageUrl: string;
cols: number;
rows: number;
rowsDef: PetAtlasRowDef[];
rowId?: string;
className?: string;
size?: number;
}
// Atlas renderer. Drives the frame index from JS instead of a CSS
// `steps()` animation — sidesteps the jump-end vs jump-none footgun
// and makes per-row fps trivial to swap when the parent flips the
// `rowId` prop (idle ↔ waving ↔ running-*).
function AtlasSprite({
imageUrl,
cols,
rows,
rowsDef,
rowId,
className,
size,
}: AtlasSpriteProps) {
const def =
rowsDef.find((r) => r.id === rowId)
?? rowsDef.find((r) => r.id === 'idle')
?? rowsDef[0]!;
const rowFrames = Math.max(1, def.frames);
const fps = Math.max(1, def.fps);
const [frame, setFrame] = useState(0);
// Reset to frame 0 on row change so a freshly-triggered animation
// (e.g. tap → waving) starts cleanly instead of mid-cycle.
useEffect(() => {
setFrame(0);
if (rowFrames <= 1) return;
const intervalMs = Math.max(16, Math.round(1000 / fps));
const id = window.setInterval(() => {
setFrame((f) => (f + 1) % rowFrames);
}, intervalMs);
return () => window.clearInterval(id);
}, [def.id, def.index, rowFrames, fps]);
// Background math:
// - background-size = (cols × 100%) × (rows × 100%)
// → each grid cell renders at exactly the container size.
// - background-position-x = frame / (cols - 1) × 100%
// → 0% slides to the leftmost cell, 100% to the rightmost,
// intermediate cells land at frame/(cols-1) of the offset range.
// - background-position-y = rowIndex / (rows - 1) × 100%
const xPct = cols > 1 ? (frame / (cols - 1)) * 100 : 0;
const yPct = rows > 1 ? (def.index / (rows - 1)) * 100 : 0;
return (
<span
className={`${className ?? ''} pet-image atlas`.trim()}
aria-hidden
style={{
backgroundImage: `url(${imageUrl})`,
backgroundSize: `${cols * 100}% ${rows * 100}%`,
backgroundPosition: `${xPct}% ${yPct}%`,
width: size,
height: size,
}}
/>
);
}
+327
View File
@@ -0,0 +1,327 @@
// Codex hatch-pet atlas helpers.
//
// The companion `hatch-pet` skill (vendored under `skills/hatch-pet/`)
// produces a fixed-shape spritesheet that the Codex app reads directly:
//
// - Format: PNG or WebP, transparent background.
// - Dimensions: 1536 x 1872 px.
// - Grid: 8 columns x 9 rows of 192 x 208 cells.
// - Each row encodes one animation state (idle, running-right, …).
//
// The pet overlay can render the full atlas and switch the active row
// based on interaction state (hover, drag direction, idle timeout) —
// matching the codex-pets-react `PetWidget` behaviour. For users who
// prefer a single-row loop (or a non-Codex strip) we still expose the
// `cropAtlasRow` helper, which slices one row into a standalone strip.
//
// Source contract:
// https://github.com/openai/skills/tree/main/skills/.curated/hatch-pet/references
import type { PetAtlasLayout, PetAtlasRowDef } from '../../types';
export const CODEX_ATLAS_COLS = 8;
export const CODEX_ATLAS_ROWS = 9;
export const CODEX_CELL_WIDTH = 192;
export const CODEX_CELL_HEIGHT = 208;
export const CODEX_ATLAS_WIDTH = CODEX_ATLAS_COLS * CODEX_CELL_WIDTH; // 1536
export const CODEX_ATLAS_HEIGHT = CODEX_ATLAS_ROWS * CODEX_CELL_HEIGHT; // 1872
export const CODEX_ATLAS_ASPECT = CODEX_ATLAS_WIDTH / CODEX_ATLAS_HEIGHT; // ~0.821
export interface CodexAtlasRow {
// Row index in the atlas, top to bottom. Stable ordering matches the
// `animation-rows.md` reference shipped with the upstream skill.
index: number;
// Stable id used for translation lookup and React keys.
id:
| 'idle'
| 'running-right'
| 'running-left'
| 'waving'
| 'jumping'
| 'failed'
| 'waiting'
| 'running'
| 'review';
// Number of frames the row uses, per the upstream reference. Frames
// beyond this index are required to be transparent so we crop them
// out by default to keep the strip tight.
frames: number;
// Recommended fps so the strip plays at roughly the same cadence as
// the Codex app's own per-frame ms timings. Each row uses different
// per-frame timings; this is a reasonable rounded average.
fps: number;
}
// Mirrors `references/animation-rows.md` from the hatch-pet skill.
export const CODEX_ATLAS_ROWS_DEF: CodexAtlasRow[] = [
{ index: 0, id: 'idle', frames: 6, fps: 6 },
{ index: 1, id: 'running-right', frames: 8, fps: 8 },
{ index: 2, id: 'running-left', frames: 8, fps: 8 },
{ index: 3, id: 'waving', frames: 4, fps: 6 },
{ index: 4, id: 'jumping', frames: 5, fps: 7 },
{ index: 5, id: 'failed', frames: 8, fps: 7 },
{ index: 6, id: 'waiting', frames: 6, fps: 6 },
{ index: 7, id: 'running', frames: 6, fps: 8 },
{ index: 8, id: 'review', frames: 6, fps: 6 },
];
// Canonical layout passed to `PetCustom.atlas` when the user adopts a
// Codex hatch-pet without freezing it to a single row. The overlay reads
// this to know how to slice the grid + which rows are populated.
export const CODEX_ATLAS_LAYOUT: PetAtlasLayout = {
cols: CODEX_ATLAS_COLS,
rows: CODEX_ATLAS_ROWS,
rowsDef: CODEX_ATLAS_ROWS_DEF.map(
(row): PetAtlasRowDef => ({
index: row.index,
id: row.id,
frames: row.frames,
fps: row.fps,
}),
),
};
// Aspect-only check is enough to handle WebP/PNG atlases that have been
// resized for transport. We accept anything within ~6% of the canonical
// 8x9 / 192x208 aspect, which comfortably catches resized variants while
// rejecting normal screenshots and selfies.
export function looksLikeCodexAtlas(width: number, height: number): boolean {
if (!Number.isFinite(width) || !Number.isFinite(height)) return false;
if (width <= 0 || height <= 0) return false;
const aspect = width / height;
return Math.abs(aspect - CODEX_ATLAS_ASPECT) < 0.06;
}
// Read a user-picked file into a data URL without re-encoding through a
// canvas. The Codex atlas import path needs the original full-resolution
// pixels so the per-row crop stays sharp; the regular pet upload path in
// `image.ts` would downscale to 384 px on the longest side and destroy
// the grid alignment.
export interface RawAtlasImage {
dataUrl: string;
width: number;
height: number;
}
const ACCEPTED_TYPES = new Set([
'image/png',
'image/webp',
'image/jpeg',
'image/gif',
]);
export async function loadAtlasImageFromFile(file: File): Promise<RawAtlasImage> {
if (!file.type.startsWith('image/')) {
throw new Error('Only image files are supported.');
}
if (!ACCEPTED_TYPES.has(file.type) && file.type !== 'image/svg+xml') {
throw new Error('Use a PNG, WebP, JPEG, or GIF spritesheet.');
}
const dataUrl = await readFileAsDataUrl(file);
const dims = await measureImage(dataUrl);
return { dataUrl, width: dims.width, height: dims.height };
}
export interface CropAtlasOptions {
// Which row to extract. Defaults to row 0 (`idle`).
rowIndex: number;
// Override the columns / rows / cell size if the source is a non-Codex
// atlas. Defaults to the canonical 8x9 / 192x208 layout.
cols?: number;
rows?: number;
// Number of leading frames to keep from the row. Defaults to the
// upstream-defined "used columns" for the chosen row, falling back to
// `cols` when the row isn't recognised.
frames?: number;
// Cap on the cell height of the resulting strip. The pet overlay only
// renders at ~56-72 px, so 96 px cells stay crisp without bloating
// the localStorage payload. Set to `null` to skip downscaling.
maxCellHeight?: number | null;
}
export interface CroppedAtlasRow {
// PNG data URL of the horizontal strip ready to drop into
// `PetCustom.imageUrl` and animated via `pet-frames` keyframes.
dataUrl: string;
// Final strip dimensions after optional downscale.
width: number;
height: number;
// Number of frames packed into the strip.
frames: number;
}
const DEFAULT_MAX_CELL_HEIGHT = 96;
export async function cropAtlasRow(
dataUrl: string,
options: CropAtlasOptions,
): Promise<CroppedAtlasRow> {
const cols = Math.max(1, Math.floor(options.cols ?? CODEX_ATLAS_COLS));
const rows = Math.max(1, Math.floor(options.rows ?? CODEX_ATLAS_ROWS));
const rowIndex = Math.max(0, Math.min(rows - 1, Math.floor(options.rowIndex)));
const def = CODEX_ATLAS_ROWS_DEF.find((r) => r.index === rowIndex);
const requestedFrames =
options.frames ?? def?.frames ?? cols;
const frames = Math.max(1, Math.min(cols, Math.floor(requestedFrames)));
const maxCellHeight =
options.maxCellHeight === null
? null
: options.maxCellHeight ?? DEFAULT_MAX_CELL_HEIGHT;
const img = await loadImage(dataUrl);
const cellWidth = Math.floor(img.naturalWidth / cols);
const cellHeight = Math.floor(img.naturalHeight / rows);
if (cellWidth <= 0 || cellHeight <= 0) {
throw new Error('Atlas image is too small to crop.');
}
const targetCellHeight =
maxCellHeight && cellHeight > maxCellHeight ? maxCellHeight : cellHeight;
const scale = targetCellHeight / cellHeight;
const targetCellWidth = Math.max(1, Math.round(cellWidth * scale));
const targetWidth = targetCellWidth * frames;
const targetHeight = targetCellHeight;
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas is unavailable in this browser.');
}
// Pixel-art atlases lose readability under bilinear smoothing, so we
// explicitly disable it before drawing.
ctx.imageSmoothingEnabled = false;
for (let f = 0; f < frames; f++) {
const sx = f * cellWidth;
const sy = rowIndex * cellHeight;
ctx.drawImage(
img,
sx,
sy,
cellWidth,
cellHeight,
f * targetCellWidth,
0,
targetCellWidth,
targetCellHeight,
);
}
const out = canvas.toDataURL('image/png');
return {
dataUrl: out,
width: targetWidth,
height: targetHeight,
frames,
};
}
// Same idea as `cropAtlasRow` but keeps every row so the overlay can
// switch animations on the fly. We downscale to a target cell height
// (default 80 px → 8x9 grid lands at ~528 KB PNG which fits inside the
// MAX_DATA_URL_BYTES guard from `image.ts` even for busy spritesheets)
// while preserving the grid layout 1:1 so background-position math in
// `PetSpriteFace` stays simple.
const DEFAULT_FULL_ATLAS_MAX_CELL = 80;
export interface PreparedAtlas {
// PNG data URL of the full atlas, ready to drop into
// `PetCustom.imageUrl` together with the matching layout.
dataUrl: string;
// Final pixel dimensions of the downscaled atlas.
width: number;
height: number;
// Layout metadata describing the grid + per-row playback config.
layout: PetAtlasLayout;
}
export async function prepareCodexAtlas(
sourceDataUrl: string,
options?: { maxCellHeight?: number | null },
): Promise<PreparedAtlas> {
const maxCellHeight =
options?.maxCellHeight === null
? null
: options?.maxCellHeight ?? DEFAULT_FULL_ATLAS_MAX_CELL;
const img = await loadImage(sourceDataUrl);
const cellWidth = Math.floor(img.naturalWidth / CODEX_ATLAS_COLS);
const cellHeight = Math.floor(img.naturalHeight / CODEX_ATLAS_ROWS);
if (cellWidth <= 0 || cellHeight <= 0) {
throw new Error('Atlas image is too small to slice.');
}
const targetCellHeight =
maxCellHeight && cellHeight > maxCellHeight ? maxCellHeight : cellHeight;
const scale = targetCellHeight / cellHeight;
const targetCellWidth = Math.max(1, Math.round(cellWidth * scale));
const targetWidth = targetCellWidth * CODEX_ATLAS_COLS;
const targetHeight = targetCellHeight * CODEX_ATLAS_ROWS;
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas is unavailable in this browser.');
}
ctx.imageSmoothingEnabled = false;
// Draw cell-by-cell so alignment survives even if the source has a
// slightly off canvas size (some tools add a 1 px gutter that would
// otherwise smear into adjacent cells under a single drawImage).
for (let r = 0; r < CODEX_ATLAS_ROWS; r++) {
for (let c = 0; c < CODEX_ATLAS_COLS; c++) {
ctx.drawImage(
img,
c * cellWidth,
r * cellHeight,
cellWidth,
cellHeight,
c * targetCellWidth,
r * targetCellHeight,
targetCellWidth,
targetCellHeight,
);
}
}
const dataUrl = canvas.toDataURL('image/png');
return {
dataUrl,
width: targetWidth,
height: targetHeight,
layout: CODEX_ATLAS_LAYOUT,
};
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error('Read failed'));
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('Could not decode the image.'));
return;
}
resolve(result);
};
reader.readAsDataURL(file);
});
}
function measureImage(
dataUrl: string,
): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => reject(new Error('Could not load that image.'));
img.src = dataUrl;
});
}
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Could not load that image.'));
img.src = dataUrl;
});
}
+139
View File
@@ -0,0 +1,139 @@
// Helpers for turning a user-picked image file into a self-contained
// pet sprite payload that is safe to drop into localStorage. We do
// three things:
//
// 1. Reject anything that is not an image.
// 2. For animated GIFs (and SVGs), pass the original bytes through as a
// data URL — re-encoding through a canvas would freeze a GIF on its
// first frame and rasterize an SVG, which we explicitly want to
// avoid for spritesheet uploads modeled on codex-pets-react sheets.
// 3. For everything else (PNG / JPG / WebP), draw to a canvas at a
// capped longest-side and re-export as PNG so the resulting data
// URL stays bounded even when the source is a 4K screenshot.
//
// All of this happens client-side; nothing is uploaded to the daemon.
export interface PetImageResult {
// Ready-to-render data URL (data:image/...;base64,…) or a passthrough
// for animated formats.
dataUrl: string;
// Pixel size of the resulting image — useful for the settings preview
// when guessing a sensible default frame count for spritesheets.
width: number;
height: number;
// True when we re-encoded through a canvas (PNG output). False when
// we kept the original bytes (GIF, SVG) so the caller can warn the
// user about size limits before saving.
reencoded: boolean;
}
// Hard cap on the data URL we are willing to stash in localStorage.
// localStorage typically has a 5 MB budget per origin and we already
// share that bucket with the rest of `open-design:config`. 800 KB
// keeps room for a beefy spritesheet without blowing the budget.
const MAX_DATA_URL_BYTES = 800 * 1024;
// Capped longest-side for re-encoded sprites. 384 px gives a 4-frame
// strip plenty of resolution at the 56 px overlay size while keeping
// the data URL short.
const MAX_REENCODED_PX = 384;
const PASSTHROUGH_TYPES = new Set(['image/gif', 'image/svg+xml', 'image/webp']);
export async function loadPetImageFromFile(
file: File,
): Promise<PetImageResult> {
if (!file.type.startsWith('image/')) {
throw new Error('Only image files are supported.');
}
if (PASSTHROUGH_TYPES.has(file.type)) {
const dataUrl = await fileToDataUrl(file);
if (approxDataUrlBytes(dataUrl) > MAX_DATA_URL_BYTES) {
throw new Error(
'That image is too large after encoding. Try one under ~800 KB.',
);
}
const dims = await measureImage(dataUrl);
return { dataUrl, width: dims.width, height: dims.height, reencoded: false };
}
// PNG / JPG / etc — re-encode through a canvas so the data URL stays
// small even when the source is high-resolution.
const dataUrl = await fileToDataUrl(file);
const original = await measureImage(dataUrl);
const scale = Math.min(
1,
MAX_REENCODED_PX / Math.max(original.width, original.height),
);
const targetW = Math.max(1, Math.round(original.width * scale));
const targetH = Math.max(1, Math.round(original.height * scale));
const reencoded = await drawToPng(dataUrl, targetW, targetH);
if (approxDataUrlBytes(reencoded) > MAX_DATA_URL_BYTES) {
throw new Error(
'That image is too large after encoding. Try a smaller source.',
);
}
return {
dataUrl: reencoded,
width: targetW,
height: targetH,
reencoded: true,
};
}
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error('Read failed'));
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('Could not decode the image.'));
return;
}
resolve(result);
};
reader.readAsDataURL(file);
});
}
function measureImage(dataUrl: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => reject(new Error('Could not load that image.'));
img.src = dataUrl;
});
}
function drawToPng(dataUrl: string, w: number, h: number): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas is unavailable in this browser.'));
return;
}
ctx.drawImage(img, 0, 0, w, h);
try {
resolve(canvas.toDataURL('image/png'));
} catch (err) {
reject(err instanceof Error ? err : new Error('Encode failed'));
}
};
img.onerror = () => reject(new Error('Could not load that image.'));
img.src = dataUrl;
});
}
function approxDataUrlBytes(dataUrl: string): number {
const comma = dataUrl.indexOf(',');
if (comma === -1) return dataUrl.length;
// base64 is ~4 chars per 3 bytes; this estimate is good enough to
// guard the localStorage budget without parsing.
const base64 = dataUrl.slice(comma + 1);
return Math.floor((base64.length * 3) / 4);
}
+340
View File
@@ -0,0 +1,340 @@
import type { AppConfig, PetAtlasLayout, PetAtlasRowDef, PetCustom, PetConfig } from '../../types';
import {
codexPetSpritesheetUrl,
fetchCodexPets,
} from '../../providers/registry';
import { prepareCodexAtlas } from './codexAtlas';
// Built-in pet catalog. Historically this listed a handful of emoji-only
// pets (Mochi, Pixel, Foxy…), but those felt boring next to the rich
// hatch-pet sprite atlases bundled under `assets/community-pets/`. The
// "Built-in" tab now sources its pets from those bundled spritesheets at
// runtime via `/api/codex-pets` (filtered by `bundled: true`), and the
// emoji-based catalog has been retired.
//
// We keep the type and an empty array for backwards compatibility with
// rail / composer code paths and saved configs whose `petId` still
// points at a legacy emoji id — those configs fall back to the user's
// custom slot in `resolveActivePet` so the overlay never renders blank.
export interface BuiltInPet {
id: string;
name: string;
glyph: string;
accent: string;
greeting: string;
// Free-form one-liner shown under the pet name in the catalog card
// — flavor text, not a tooltip. Keep it short.
flavor: string;
// CSS animation name applied to the sprite when the overlay is awake.
// All four are defined in `index.css` under `@keyframes pet-…`.
animation: 'bounce' | 'sway' | 'float' | 'wiggle';
}
export const BUILT_IN_PETS: BuiltInPet[] = [];
export const CUSTOM_PET_ID = 'custom';
export interface ResolvedPet {
id: string;
name: string;
glyph: string;
accent: string;
greeting: string;
animation: BuiltInPet['animation'];
// Optional uploaded image data URL. Present only for custom pets that
// have an image; built-ins fall back to their emoji glyph.
imageUrl?: string;
// Legacy single-row spritesheet config (used when `atlas` is missing).
// Number of horizontal frames in the imageUrl (1 = static).
frames?: number;
// Frames-per-second for the spritesheet step animation.
fps?: number;
// Optional sprite atlas layout. When present, `imageUrl` is the full
// grid and `PetSpriteFace` picks one row to play based on the
// overlay's interaction state.
atlas?: PetAtlasLayout;
}
// Resolve the pet definition currently in use. Returns `null` only when
// the user has not adopted yet — call sites use that to decide whether
// to render the floating overlay at all.
export function resolveActivePet(pet: PetConfig | undefined): ResolvedPet | null {
if (!pet?.adopted) return null;
// Bundled "Built-in" pets adopt into the custom slot (the spritesheet
// and atlas layout are copied there by `adoptCodexPet`), so the
// custom branch is the rendering path for both user-authored pets
// and bundled adoptions.
if (pet.petId === CUSTOM_PET_ID) {
return resolveCustomPet(pet.custom);
}
const found = BUILT_IN_PETS.find((p) => p.id === pet.petId);
if (found) {
return {
id: found.id,
name: found.name,
glyph: found.glyph,
accent: found.accent,
greeting: found.greeting,
animation: found.animation,
};
}
// Legacy fallback — older configs may still carry an emoji built-in
// id (e.g. `mochi`) from before the catalog migrated to bundled
// spritesheets. Render the user's custom slot instead of crashing or
// blanking the overlay; the user can re-adopt from Settings to pick
// a bundled pet.
return resolveCustomPet(pet.custom);
}
function resolveCustomPet(c: PetCustom): ResolvedPet {
return {
id: CUSTOM_PET_ID,
name: c.name?.trim() || 'Buddy',
glyph: c.glyph?.trim() || '🦄',
accent: c.accent?.trim() || '#c96442',
greeting: c.greeting?.trim() || 'Hi! I am here whenever you need me.',
// Custom pets get the gentle float animation by default. We could
// expose this in the editor later; today's UX keeps the picker
// focused on glyph + name + color.
animation: 'float',
imageUrl: c.imageUrl,
frames: clampFrames(c.frames),
fps: clampFps(c.fps),
atlas: sanitizeAtlas(c.atlas),
};
}
export const FRAMES_MIN = 1;
export const FRAMES_MAX = 24;
export const FPS_MIN = 1;
export const FPS_MAX = 30;
function clampFrames(value: number | undefined): number {
if (!Number.isFinite(value as number)) return 1;
return Math.max(FRAMES_MIN, Math.min(FRAMES_MAX, Math.round(value as number)));
}
function clampFps(value: number | undefined): number {
if (!Number.isFinite(value as number)) return 6;
return Math.max(FPS_MIN, Math.min(FPS_MAX, Math.round(value as number)));
}
// Atlas hardening — strips out malformed entries so the renderer never
// has to defensively check for NaN cell sizes / negative indices. We
// keep rows we can validate even if the layout omits a few; missing
// rows just fall back to `idle` at lookup time.
function sanitizeAtlas(input: PetAtlasLayout | undefined): PetAtlasLayout | undefined {
if (!input) return undefined;
const cols = Math.max(1, Math.floor(input.cols));
const rows = Math.max(1, Math.floor(input.rows));
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return undefined;
const seen = new Set<number>();
const rowsDef: PetAtlasRowDef[] = [];
for (const row of input.rowsDef ?? []) {
if (!row || typeof row.id !== 'string' || !row.id.trim()) continue;
const index = Math.floor(row.index);
if (!Number.isFinite(index) || index < 0 || index >= rows) continue;
if (seen.has(index)) continue;
seen.add(index);
rowsDef.push({
index,
id: row.id.trim(),
frames: Math.max(1, Math.min(cols, Math.floor(row.frames) || 1)),
fps: Math.max(FPS_MIN, Math.min(FPS_MAX, Math.floor(row.fps) || 6)),
});
}
if (rowsDef.length === 0) return undefined;
rowsDef.sort((a, b) => a.index - b.index);
return { cols, rows, rowsDef };
}
// Logical interaction states that drive the overlay's animation
// switching. Kept narrow on purpose so the mapping below stays a
// declarative table rather than a tangle of conditionals.
export type PetInteraction =
| 'idle'
| 'hover'
| 'drag-right'
| 'drag-left'
| 'drag-up'
| 'drag-down'
| 'waiting';
// Preferred Codex atlas row id for each interaction state. Hover and
// drag each map to a dedicated action row so the pet visibly reacts to
// the user — hover plays a wave, drag swaps to a directional run (or
// hop when the gesture is vertical). Autonomous ambient variety below
// only fires when the pet is otherwise at rest so rest ↔ interaction
// reads as two cleanly separated behaviours.
const INTERACTION_ROW_ID: Record<PetInteraction, string> = {
idle: 'idle',
hover: 'waving',
'drag-right': 'running-right',
'drag-left': 'running-left',
'drag-up': 'jumping',
'drag-down': 'waving',
waiting: 'waiting',
};
const ROW_FALLBACK_ORDER: readonly string[] = [
'idle',
'waiting',
'waving',
'running',
'running-right',
];
export function preferredRowId(state: PetInteraction): string {
return INTERACTION_ROW_ID[state];
}
// Resolve the atlas row to play given the desired animation id. We try
// the requested id first, then walk a sensible fallback chain, then
// return whichever row the atlas does have so playback never blanks
// out for a partially-populated pet.
export function pickAtlasRow(
layout: PetAtlasLayout | undefined,
preferred: string,
): PetAtlasRowDef | undefined {
if (!layout || layout.rowsDef.length === 0) return undefined;
const direct = layout.rowsDef.find((r) => r.id === preferred);
if (direct) return direct;
for (const id of ROW_FALLBACK_ORDER) {
const fallback = layout.rowsDef.find((r) => r.id === id);
if (fallback) return fallback;
}
return layout.rowsDef[0];
}
// Ambient row pool — the overlay dips into these between `idle` cycles
// so a parked pet doesn't look frozen. Ordered by "quietness": waving
// and review feel calm enough to interject without startling the user,
// jumping / running* are more energetic and round out the variety when
// the atlas ships them. `idle`, `waiting`, and `failed` are excluded
// intentionally: idle is the resting baseline, waiting is reserved for
// the long-idle cue, and failed reads as a negative micro-narrative.
const AMBIENT_ROW_POOL: readonly string[] = [
'waving',
'review',
'jumping',
'running',
'running-right',
'running-left',
];
// Pick a random ambient row from the atlas, preferring ids in
// AMBIENT_ROW_POOL and avoiding `avoidId` when possible so the overlay
// doesn't replay the same micro-animation twice in a row. Returns null
// when the atlas ships only `idle` / `waiting` rows so the caller can
// no-op cleanly.
export function pickAmbientRow(
layout: PetAtlasLayout | undefined,
avoidId?: string,
): PetAtlasRowDef | null {
if (!layout || layout.rowsDef.length === 0) return null;
const pool = layout.rowsDef.filter((r) => AMBIENT_ROW_POOL.includes(r.id));
if (pool.length === 0) return null;
const candidates =
pool.length > 1 && avoidId ? pool.filter((r) => r.id !== avoidId) : pool;
const choices = candidates.length > 0 ? candidates : pool;
return choices[Math.floor(Math.random() * choices.length)] ?? null;
}
// A short pool of "ambient" prompts that the overlay rotates through on
// hover so the speech bubble feels alive after the initial greeting.
// Keep these brand-neutral and product-relevant to Open Design.
export function ambientLines(name: string): string[] {
return [
`${name}: nudge me when you want a fresh idea.`,
`${name}: I will keep you company while it builds.`,
`${name}: take a breath — the prototype will wait.`,
`${name}: small tweaks compound. Keep going!`,
];
}
export function defaultCustomPet(): PetCustom {
return {
name: 'Buddy',
glyph: '🦄',
accent: '#c96442',
greeting: 'Hi! I am here whenever you need me.',
};
}
// One-shot self-healing migration for pets adopted before the overlay
// learned how to switch atlas rows.
//
// Older versions of `adoptCodexPet` cropped the Codex spritesheet down
// to the idle row and stored just that horizontal strip on
// `PetCustom.imageUrl` (strip mode, single row). The overlay is now an
// atlas-aware renderer that can swap rows per interaction (hover ↔
// waving, drag ↔ running-*, idle ↔ ambient rotation), but it needs the
// full 8×9 grid in `PetCustom.atlas` + `imageUrl` to do so.
//
// When the persisted config points at a custom pet that has an
// imageUrl but no atlas, we look up the Codex pet registry, match by
// the name we stamped on adoption, and silently re-download the
// full spritesheet. The user sees nothing except their pet going from
// "one-state statue" to fully animated on next launch. The migration
// bails on any failure — this is best-effort and the strip sprite
// stays as-is if, say, the daemon is offline.
export async function migrateCustomPetAtlas(
cfg: AppConfig,
): Promise<PetCustom | null> {
const pet = cfg.pet;
if (!pet || !pet.adopted || pet.petId !== CUSTOM_PET_ID) return null;
const custom = pet.custom;
if (!custom?.imageUrl || custom.atlas) return null;
const name = custom.name?.trim();
if (!name) return null;
let registry;
try {
registry = await fetchCodexPets();
} catch {
return null;
}
if (!registry?.pets?.length) return null;
const needle = name.toLowerCase();
const match = registry.pets.find(
(p) =>
(p.displayName?.trim().toLowerCase() ?? '') === needle ||
p.id.trim().toLowerCase() === needle,
);
if (!match) return null;
try {
const resp = await fetch(codexPetSpritesheetUrl(match));
if (!resp.ok) return null;
const blob = await resp.blob();
const dataUrl = await blobToDataUrl(blob);
const prepared = await prepareCodexAtlas(dataUrl);
return {
...custom,
imageUrl: prepared.dataUrl,
frames: 1,
fps: prepared.layout.rowsDef[0]?.fps ?? custom.fps ?? 6,
atlas: prepared.layout,
};
} catch {
return null;
}
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error('Read failed'));
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('Could not read sprite blob.'));
return;
}
resolve(result);
};
reader.readAsDataURL(blob);
});
}