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
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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&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&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('<svg onload="alert(1)">');
|
||||
expect(markup).toContain('<script>alert(2)</script>');
|
||||
expect(markup).toContain('<![CDATA[<script>alert(3)</script>]]>');
|
||||
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=');
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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__';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user