Add initial project structure with essential files
- Created .gitignore to exclude build artifacts and dependencies. - Added index.html as the main entry point for the application. - Included LICENSE file with Apache 2.0 terms. - Initialized package.json and package-lock.json for project dependencies. - Added pnpm-lock.yaml for package management. - Created QUICKSTART.md for setup instructions. - Added README.md and README.zh-CN.md for project documentation in English and Chinese.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
// 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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,693 @@
|
||||
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 type { Dict } from '../i18n/types';
|
||||
import type { AgentEvent, ChatMessage, ProjectFile } from '../types';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}: 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 ?? [];
|
||||
// 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">{t('assistant.role')}</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}
|
||||
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}
|
||||
<AssistantFooter
|
||||
streaming={streaming}
|
||||
startedAt={message.startedAt}
|
||||
endedAt={message.endedAt}
|
||||
usage={usage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantFooter({
|
||||
streaming,
|
||||
startedAt,
|
||||
endedAt,
|
||||
usage,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
startedAt: number | undefined;
|
||||
endedAt: number | undefined;
|
||||
usage: Extract<AgentEvent, { kind: 'usage' }> | undefined;
|
||||
}) {
|
||||
const t = useT();
|
||||
const elapsed = useLiveElapsed(streaming, startedAt, endedAt);
|
||||
if (!streaming && !elapsed && !usage) return null;
|
||||
return (
|
||||
<div className="assistant-footer">
|
||||
<span className="dot" data-active={streaming ? 'true' : 'false'} />
|
||||
<span className="assistant-label">
|
||||
{streaming ? t('assistant.workingLabel') : 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 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,
|
||||
projectFileNames,
|
||||
onRequestOpenFile,
|
||||
}: {
|
||||
items: ToolItem[];
|
||||
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}
|
||||
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}
|
||||
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,226 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import { AgentIcon } from './AgentIcon';
|
||||
import { Icon } from './Icon';
|
||||
import type { AgentInfo, AppConfig, ExecMode } from '../types';
|
||||
|
||||
interface Props {
|
||||
config: AppConfig;
|
||||
agents: AgentInfo[];
|
||||
daemonLive: boolean;
|
||||
onModeChange: (mode: ExecMode) => void;
|
||||
onAgentChange: (id: string) => void;
|
||||
onOpenSettings: () => void;
|
||||
onRefreshAgents: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact avatar at the right of the project topbar. 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,
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="avatar-menu" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-btn"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={t('avatar.title')}
|
||||
>
|
||||
<img
|
||||
src="/avatar.png"
|
||||
alt=""
|
||||
aria-hidden
|
||||
draggable={false}
|
||||
className="avatar-btn-photo"
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="avatar-popover" role="menu">
|
||||
<div className="avatar-popover-head">
|
||||
<span className="who">
|
||||
{config.mode === 'daemon'
|
||||
? t('avatar.localCli')
|
||||
: t('avatar.anthropicApi')}
|
||||
</span>
|
||||
<span className="where">
|
||||
{config.mode === 'api'
|
||||
? safeHost(config.baseUrl)
|
||||
: currentAgent
|
||||
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}`
|
||||
: 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
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--text-faint)',
|
||||
fontWeight: 600,
|
||||
padding: '8px 10px 4px',
|
||||
}}
|
||||
>
|
||||
{t('avatar.codeAgent')}
|
||||
</div>
|
||||
{installedAgents.map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
className="avatar-item"
|
||||
onClick={() => {
|
||||
onAgentChange(a.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
<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,488 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useT } from '../i18n';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import { projectRawUrl, uploadProjectFiles } from "../providers/registry";
|
||||
import type { ChatAttachment, ProjectFile } from "../types";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
||||
|
||||
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>;
|
||||
onSend: (prompt: string, attachments: ChatAttachment[]) => 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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
onSend,
|
||||
onStop,
|
||||
onOpenSettings,
|
||||
},
|
||||
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);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [importOpen, setImportOpen] = 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);
|
||||
// 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.
|
||||
const seededRef = useRef(false);
|
||||
|
||||
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]);
|
||||
|
||||
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([]);
|
||||
setMention(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);
|
||||
try {
|
||||
const attachments = await uploadProjectFiles(id, files);
|
||||
setStaged((s) => [...s, ...attachments]);
|
||||
} 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);
|
||||
}
|
||||
|
||||
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();
|
||||
if (!prompt || streaming) return;
|
||||
onSend(prompt, staged);
|
||||
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" : ""}`}
|
||||
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}
|
||||
<div className="composer-input-wrap">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
placeholder={t('chat.composerPlaceholder')}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
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}
|
||||
</div>
|
||||
<div className="composer-row">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
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"
|
||||
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>
|
||||
<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"
|
||||
onClick={() => void submit()}
|
||||
disabled={!draft.trim()}
|
||||
>
|
||||
<Icon name="send" size={13} />
|
||||
<span>{t('chat.send')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{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 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 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" 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,524 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import { projectRawUrl } from '../providers/registry';
|
||||
import type { ChatAttachment, ChatMessage, Conversation, ProjectFile } from '../types';
|
||||
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>;
|
||||
onSend: (prompt: string, attachments: ChatAttachment[]) => 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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
type Tab = 'chat' | 'comments';
|
||||
|
||||
export function ChatPane({
|
||||
messages,
|
||||
streaming,
|
||||
error,
|
||||
projectId,
|
||||
projectFiles,
|
||||
projectFileNames,
|
||||
onEnsureProject,
|
||||
onSend,
|
||||
onStop,
|
||||
onRequestOpenFile,
|
||||
initialDraft,
|
||||
onSubmitForm,
|
||||
onNewConversation,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
onSelectConversation,
|
||||
onDeleteConversation,
|
||||
onRenameConversation,
|
||||
onOpenSettings,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const logRef = useRef<HTMLDivElement | null>(null);
|
||||
const historyWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerRef = useRef<ChatComposerHandle | null>(null);
|
||||
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;
|
||||
// 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(() => {
|
||||
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={false}
|
||||
className="chat-header-tab"
|
||||
disabled
|
||||
title={t('chat.commentsSoon')}
|
||||
data-coming-soon="true"
|
||||
>
|
||||
{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"
|
||||
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">
|
||||
<div className="chat-history-menu-head">
|
||||
<span className="chat-history-menu-title">
|
||||
{t('chat.conversationsHeading')}
|
||||
</span>
|
||||
{onNewConversation ? (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-history-new"
|
||||
onClick={() => {
|
||||
onNewConversation();
|
||||
setShowConvList(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" size={11} />
|
||||
<span>{t('chat.new')}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="chat-history-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"
|
||||
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) =>
|
||||
m.role === 'user' ? (
|
||||
<UserMessage
|
||||
key={m.id}
|
||||
message={m}
|
||||
projectId={projectId}
|
||||
projectFileNames={projectFileNames}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
key={m.id}
|
||||
message={m}
|
||||
streaming={streaming && m.id === lastAssistantId}
|
||||
projectId={projectId}
|
||||
projectFileNames={projectFileNames}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
isLast={m.id === lastAssistantId}
|
||||
nextUserContent={nextUserContentByAssistantId.get(m.id)}
|
||||
onSubmitForm={onSubmitForm}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{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}
|
||||
initialDraft={initialDraft}
|
||||
onEnsureProject={onEnsureProject}
|
||||
onSend={onSend}
|
||||
onStop={onStop}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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' : ''}`}>
|
||||
{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"
|
||||
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"
|
||||
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 ?? [];
|
||||
return (
|
||||
<div className="msg user">
|
||||
<div className="role">{t('chat.you')}</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}
|
||||
<div className="user-text">{message.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,365 @@
|
||||
import { useEffect, useMemo, useState } 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;
|
||||
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'];
|
||||
|
||||
/**
|
||||
* Full-panel browser for a project's `.ocd/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,
|
||||
onPaste,
|
||||
onNewSketch,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
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 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]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<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" 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) => (
|
||||
<div className="df-section" key={section}>
|
||||
<div className="df-section-label">
|
||||
{t(SECTION_LABEL_KEY[section])}
|
||||
</div>
|
||||
{grouped[section].map((f) => {
|
||||
const active = preview === f.name;
|
||||
const isHovered = hover === f.name;
|
||||
return (
|
||||
<button
|
||||
key={f.name}
|
||||
type="button"
|
||||
className={`df-row ${active ? 'active' : ''}`}
|
||||
onMouseEnter={() => setHover(f.name)}
|
||||
onMouseLeave={() => setHover((c) => (c === f.name ? null : c))}
|
||||
onClick={() => setPreview(f.name)}
|
||||
onDoubleClick={() => onOpenFile(f.name)}
|
||||
>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div className="df-drop">
|
||||
<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
|
||||
className="df-row-popover"
|
||||
style={{ top: menuPos.top, left: menuPos.left }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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={() => setMenuPos(null)}>
|
||||
{t('designFiles.download')}
|
||||
</button>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
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" />
|
||||
) : (
|
||||
<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">
|
||||
<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';
|
||||
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 '{}';
|
||||
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');
|
||||
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,59 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import {
|
||||
fetchDesignSystemPreview,
|
||||
fetchDesignSystemShowcase,
|
||||
} from '../providers/registry';
|
||||
import type { DesignSystemSummary } from '../types';
|
||||
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).
|
||||
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);
|
||||
|
||||
// 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],
|
||||
);
|
||||
|
||||
// If the system swaps under us (rare but possible), wipe both caches.
|
||||
useEffect(() => {
|
||||
setShowcaseHtml(undefined);
|
||||
setTokensHtml(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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import type { DesignSystemSummary } 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',
|
||||
];
|
||||
|
||||
export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: Props) {
|
||||
const t = useT();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [category, setCategory] = useState<string>('All');
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>();
|
||||
for (const s of systems) 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];
|
||||
}, [systems]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
return systems.filter((s) => {
|
||||
if (category !== 'All' && (s.category || 'Uncategorized') !== category) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
s.title.toLowerCase().includes(q) ||
|
||||
s.summary.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [systems, filter, category]);
|
||||
|
||||
// The category metadata coming from each design system is authored in
|
||||
// English. We translate the well-known buckets (All / Uncategorized) but
|
||||
// pass the rest through unchanged so user-facing labels stay aligned with
|
||||
// the underlying tags.
|
||||
const renderCategory = (c: string) => {
|
||||
if (c === 'All') return t('ds.categoryAll');
|
||||
if (c === 'Uncategorized') return t('ds.categoryUncategorized');
|
||||
return c;
|
||||
};
|
||||
|
||||
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>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="tab-empty">{t('ds.emptyNoMatch')}</div>
|
||||
) : (
|
||||
<div className="ds-list">
|
||||
{filtered.map((s) => {
|
||||
const active = s.id === selectedId;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`ds-row ${active ? 'active' : ''}`}
|
||||
onClick={() => onSelect(s.id)}
|
||||
>
|
||||
<div className="ds-row-body">
|
||||
<div className="ds-row-title">
|
||||
{s.title}
|
||||
{active ? (
|
||||
<span className="ds-row-default">
|
||||
{t('ds.badgeDefault')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="ds-row-summary">{s.summary || s.category}</div>
|
||||
</div>
|
||||
{s.swatches && s.swatches.length > 0 ? (
|
||||
<div className="ds-row-swatches" aria-hidden>
|
||||
{s.swatches.map((c, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="ds-row-swatch"
|
||||
style={{ background: c }}
|
||||
title={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPreview(s.id);
|
||||
}}
|
||||
title={t('ds.previewTitle')}
|
||||
>
|
||||
{t('ds.preview')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import type { DesignSystemSummary, Project, SkillSummary } from '../types';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
type SubTab = 'recent' | 'yours';
|
||||
|
||||
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 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">
|
||||
<div className="tab-panel-toolbar">
|
||||
<div className="toolbar-left">
|
||||
<div
|
||||
className="subtab-pill"
|
||||
role="tablist"
|
||||
aria-label={t('designs.filterAria')}
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={sub === 'recent'}
|
||||
className={sub === 'recent' ? 'active' : ''}
|
||||
onClick={() => setSub('recent')}
|
||||
>
|
||||
{t('designs.subRecent')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={sub === 'yours'}
|
||||
className={sub === 'yours' ? 'active' : ''}
|
||||
onClick={() => setSub('yours')}
|
||||
>
|
||||
{t('designs.subYours')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="tab-empty">
|
||||
{projects.length === 0
|
||||
? t('designs.emptyNoProjects')
|
||||
: t('designs.emptyNoMatch')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="design-grid">
|
||||
{filtered.map((p) => {
|
||||
const skill = skillName(p.skillId);
|
||||
const ds = dsName(p.designSystemId);
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="design-card"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(p.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onOpen(p.id);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="design-card-close"
|
||||
title={t('designs.deleteTitle')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(t('designs.deleteConfirm', { name: p.name }))) {
|
||||
onDelete(p.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
×
|
||||
</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}` : ''}
|
||||
{' · '}
|
||||
{relativeTime(p.updatedAt, t)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,320 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import type {
|
||||
AgentInfo,
|
||||
AppConfig,
|
||||
DesignSystemSummary,
|
||||
Project,
|
||||
ProjectTemplate,
|
||||
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, type CreateTab } from './NewProjectPanel';
|
||||
|
||||
type TopTab = 'designs' | 'examples' | 'design-systems';
|
||||
|
||||
interface Props {
|
||||
skills: SkillSummary[];
|
||||
designSystems: DesignSystemSummary[];
|
||||
projects: Project[];
|
||||
templates: ProjectTemplate[];
|
||||
defaultDesignSystemId: string | null;
|
||||
config: AppConfig;
|
||||
agents: AgentInfo[];
|
||||
loading?: boolean;
|
||||
onCreateProject: (input: CreateInput & { pendingPrompt?: string }) => void;
|
||||
onOpenProject: (id: string) => void;
|
||||
onDeleteProject: (id: string) => void;
|
||||
onChangeDefaultDesignSystem: (id: string) => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
const SIDEBAR_MIN = 320;
|
||||
const SIDEBAR_MAX = 560;
|
||||
const SIDEBAR_DEFAULT = 380;
|
||||
const SIDEBAR_STORAGE_KEY = 'ocd-entry-sidebar-width';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function EntryView({
|
||||
skills,
|
||||
designSystems,
|
||||
projects,
|
||||
templates,
|
||||
defaultDesignSystemId,
|
||||
config,
|
||||
agents,
|
||||
loading = false,
|
||||
onCreateProject,
|
||||
onOpenProject,
|
||||
onDeleteProject,
|
||||
onChangeDefaultDesignSystem,
|
||||
onOpenSettings,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const [topTab, setTopTab] = useState<TopTab>('designs');
|
||||
const [previewSystemId, setPreviewSystemId] = useState<string | null>(null);
|
||||
const [panelPreset, setPanelPreset] = useState<{
|
||||
tab: CreateTab;
|
||||
skillId: string | null;
|
||||
name: string;
|
||||
pendingPrompt?: string;
|
||||
nonce: number;
|
||||
} | null>(null);
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => loadSidebarWidth());
|
||||
const [resizing, setResizing] = useState(false);
|
||||
|
||||
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]);
|
||||
|
||||
function usePromptFromSkill(skill: SkillSummary) {
|
||||
setPanelPreset({
|
||||
tab: tabForSkill(skill),
|
||||
skillId: skill.id,
|
||||
name: skill.name,
|
||||
pendingPrompt: skill.examplePrompt || skill.description,
|
||||
nonce: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
pendingPrompt: panelPreset?.pendingPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="entry"
|
||||
style={{ gridTemplateColumns: `${sidebarWidth}px 1fr` }}
|
||||
>
|
||||
<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
|
||||
key={panelPreset?.nonce ?? 'default'}
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId={defaultDesignSystemId}
|
||||
templates={templates}
|
||||
onCreate={handleCreate}
|
||||
presetTab={panelPreset?.tab}
|
||||
presetSkillId={panelPreset?.skillId ?? null}
|
||||
presetName={panelPreset?.name}
|
||||
loading={loading}
|
||||
/>
|
||||
<div className="entry-side-foot">
|
||||
<button
|
||||
type="button"
|
||||
className="foot-pill"
|
||||
onClick={onOpenSettings}
|
||||
title={t('settings.envConfigure')}
|
||||
>
|
||||
<Icon name="settings" size={12} />
|
||||
<span>
|
||||
{config.mode === 'daemon'
|
||||
? t('settings.localCli')
|
||||
: t('settings.anthropicApi')}
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
<div className="entry-header-right">
|
||||
{/* Avatar settings live next to tabs to mirror the project view. */}
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-btn"
|
||||
onClick={onOpenSettings}
|
||||
title={t('entry.openSettingsTitle')}
|
||||
aria-label={t('entry.openSettingsAria')}
|
||||
>
|
||||
<img
|
||||
src="/avatar.png"
|
||||
alt=""
|
||||
aria-hidden
|
||||
draggable={false}
|
||||
className="avatar-btn-photo"
|
||||
/>
|
||||
</button>
|
||||
</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}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
{previewSystem ? (
|
||||
<DesignSystemPreviewModal
|
||||
system={previewSystem}
|
||||
onClose={() => setPreviewSystemId(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopTabButton({
|
||||
current,
|
||||
value,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
current: TopTab;
|
||||
value: TopTab;
|
||||
label: string;
|
||||
onClick: (v: TopTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={current === value}
|
||||
className={`entry-tab ${current === value ? 'active' : ''}`}
|
||||
onClick={() => onClick(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function tabForSkill(skill: SkillSummary): CreateTab {
|
||||
if (skill.mode === 'deck') return 'deck';
|
||||
if (skill.mode === 'prototype') return 'prototype';
|
||||
return 'template';
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
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 } from '../types';
|
||||
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 ScenarioFilter = string;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function ExamplesTab({ skills, onUsePrompt }: Props) {
|
||||
const t = useT();
|
||||
// Hold preview HTML per skill across re-renders so cards never re-flicker.
|
||||
const [previews, setPreviews] = useState<Record<string, string | null>>({});
|
||||
const [modeFilter, setModeFilter] = useState<ModeFilter>('all');
|
||||
const [scenarioFilter, setScenarioFilter] = useState<ScenarioFilter>('all');
|
||||
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 c: Record<ModeFilter, number> = {
|
||||
all: skills.length,
|
||||
'prototype-desktop': 0,
|
||||
'prototype-mobile': 0,
|
||||
deck: 0,
|
||||
document: 0,
|
||||
};
|
||||
for (const s of skills) {
|
||||
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]);
|
||||
|
||||
const scenarioCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of skills) {
|
||||
if (!matchesMode(s, modeFilter)) continue;
|
||||
const tag = s.scenario || 'general';
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [skills, 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 matched = skills.filter((s) => {
|
||||
if (!matchesMode(s, modeFilter)) return false;
|
||||
if (scenarioFilter === 'all') return true;
|
||||
return (s.scenario || 'general') === scenarioFilter;
|
||||
});
|
||||
// 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, modeFilter, scenarioFilter]);
|
||||
|
||||
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-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={previewSkill.examplePrompt || previewSkill.description.replace(/\s+/g, ' ').slice(0, 160)}
|
||||
views={[
|
||||
{
|
||||
id: 'preview',
|
||||
label: t('examples.previewLabel'),
|
||||
html: previews[previewSkill.id],
|
||||
},
|
||||
]}
|
||||
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 t = useT();
|
||||
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';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="example-card"
|
||||
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">
|
||||
{skill.examplePrompt
|
||||
? `“${skill.examplePrompt}”`
|
||||
: skill.description.replace(/\s+/g, ' ').slice(0, 240)}
|
||||
</div>
|
||||
<div className="example-card-actions">
|
||||
<button className="primary example-cta" 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,785 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import { fetchProjectFileText, projectFileUrl } from '../providers/registry';
|
||||
import { exportAsHtml, exportAsPdf, exportAsZip } from '../runtime/exports';
|
||||
import { buildSrcdoc } from '../runtime/srcdoc';
|
||||
import { saveTemplate } from '../state/projects';
|
||||
import type { ProjectFile } from '../types';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
file: ProjectFile;
|
||||
liveHtml?: string;
|
||||
isDeck?: boolean;
|
||||
onExportAsPptx?: ((fileName: string) => void) | undefined;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
export function FileViewer({
|
||||
projectId,
|
||||
file,
|
||||
liveHtml,
|
||||
isDeck,
|
||||
onExportAsPptx,
|
||||
streaming,
|
||||
}: Props) {
|
||||
if (file.kind === 'html') {
|
||||
return (
|
||||
<HtmlViewer
|
||||
projectId={projectId}
|
||||
file={file}
|
||||
liveHtml={liveHtml}
|
||||
isDeck={Boolean(isDeck)}
|
||||
onExportAsPptx={onExportAsPptx}
|
||||
streaming={Boolean(streaming)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (file.kind === 'image') {
|
||||
return <ImageViewer projectId={projectId} file={file} />;
|
||||
}
|
||||
if (file.kind === 'sketch') {
|
||||
return <ImageViewer projectId={projectId} file={file} />;
|
||||
}
|
||||
if (file.kind === 'text' || file.kind === 'code') {
|
||||
return <TextViewer projectId={projectId} file={file} />;
|
||||
}
|
||||
return <BinaryViewer projectId={projectId} file={file} />;
|
||||
}
|
||||
|
||||
function BinaryViewer({
|
||||
projectId,
|
||||
file,
|
||||
}: {
|
||||
projectId: string;
|
||||
file: ProjectFile;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="viewer binary-viewer">
|
||||
<div className="viewer-toolbar">
|
||||
<div className="viewer-toolbar-left">
|
||||
<span className="viewer-meta">
|
||||
{t('fileViewer.binaryMeta', { size: humanSize(file.size) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="viewer-toolbar-actions">
|
||||
<a
|
||||
className="ghost-link"
|
||||
href={projectFileUrl(projectId, file.name)}
|
||||
download={file.name}
|
||||
>
|
||||
{t('fileViewer.download')}
|
||||
</a>
|
||||
<a
|
||||
className="ghost-link"
|
||||
href={projectFileUrl(projectId, file.name)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{t('fileViewer.open')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="viewer-body">
|
||||
<div className="viewer-empty">
|
||||
{t('fileViewer.binaryNote', { size: file.size })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HtmlViewer({
|
||||
projectId,
|
||||
file,
|
||||
liveHtml,
|
||||
isDeck,
|
||||
onExportAsPptx,
|
||||
streaming,
|
||||
}: {
|
||||
projectId: string;
|
||||
file: ProjectFile;
|
||||
liveHtml?: string;
|
||||
isDeck: boolean;
|
||||
onExportAsPptx?: ((fileName: string) => void) | undefined;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [mode, setMode] = useState<'preview' | 'source'>('preview');
|
||||
const [source, setSource] = useState<string | null>(liveHtml ?? null);
|
||||
const [zoom, setZoom] = useState(100);
|
||||
const [presentMenuOpen, setPresentMenuOpen] = useState(false);
|
||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||
// Template save UX. We surface a transient "Saved" pill in the share
|
||||
// menu so the user gets feedback without a noisy toast layer.
|
||||
const [savingTemplate, setSavingTemplate] = useState(false);
|
||||
const [templateNote, setTemplateNote] = useState<string | null>(null);
|
||||
const [inTabPresent, setInTabPresent] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
// Slide deck nav state: the iframe posts the active index + total count
|
||||
// back to the host every time a slide settles. Host renders prev/next
|
||||
// controls in the toolbar and reflects the count beside them.
|
||||
const [slideState, setSlideState] = useState<{ active: number; count: number } | null>(null);
|
||||
const previewBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const shareRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (liveHtml !== undefined) {
|
||||
setSource(liveHtml);
|
||||
return;
|
||||
}
|
||||
setSource(null);
|
||||
let cancelled = false;
|
||||
void fetchProjectFileText(projectId, file.name).then((text) => {
|
||||
if (!cancelled) setSource(text);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, file.name, file.mtime, liveHtml, reloadKey]);
|
||||
|
||||
const srcDoc = useMemo(
|
||||
() => (source ? buildSrcdoc(source, { deck: isDeck }) : ''),
|
||||
[source, isDeck],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDeck) {
|
||||
setSlideState(null);
|
||||
return;
|
||||
}
|
||||
function onMessage(ev: MessageEvent) {
|
||||
const data = ev?.data as
|
||||
| { type?: string; active?: number; count?: number }
|
||||
| null;
|
||||
if (!data || data.type !== 'ocd:slide-state') return;
|
||||
if (typeof data.active !== 'number' || typeof data.count !== 'number') return;
|
||||
setSlideState({ active: data.active, count: data.count });
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [isDeck]);
|
||||
|
||||
function postSlide(action: 'next' | 'prev' | 'first' | 'last') {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
if (!win) return;
|
||||
win.postMessage({ type: 'ocd:slide', action }, '*');
|
||||
}
|
||||
|
||||
// Keyboard nav on the host, so the user can press ←/→ even when focus
|
||||
// is on the chat composer or any other host control.
|
||||
useEffect(() => {
|
||||
if (!isDeck || mode !== 'preview') return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target) {
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return;
|
||||
}
|
||||
if (e.key === 'ArrowRight' || e.key === 'PageDown') {
|
||||
e.preventDefault();
|
||||
postSlide('next');
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
|
||||
e.preventDefault();
|
||||
postSlide('prev');
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
postSlide('first');
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
postSlide('last');
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isDeck, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!presentMenuOpen) return;
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (target.closest('.present-wrap')) return;
|
||||
setPresentMenuOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setPresentMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [presentMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shareMenuOpen) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (!shareRef.current) return;
|
||||
if (!shareRef.current.contains(e.target as Node)) setShareMenuOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setShareMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [shareMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inTabPresent) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setInTabPresent(false);
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [inTabPresent]);
|
||||
|
||||
function openInNewTab() {
|
||||
if (!source) return;
|
||||
const blob = new Blob([source], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
// Snapshot this project as a reusable template. The daemon snapshots
|
||||
// EVERY html/text/code file in the project (not just the file open in
|
||||
// the viewer), so the template captures the whole design, not a single
|
||||
// page. Surfaced here in the Share menu because that's where the user's
|
||||
// share / export mental model already lives.
|
||||
async function handleSaveAsTemplate() {
|
||||
setShareMenuOpen(false);
|
||||
const defaultName =
|
||||
file.name.replace(/\.html?$/i, '') || t('fileViewer.templateNameDefault');
|
||||
const name = window.prompt(t('fileViewer.templateNamePrompt'), defaultName);
|
||||
if (!name || !name.trim()) return;
|
||||
const description = window.prompt(
|
||||
t('fileViewer.templateDescPrompt'),
|
||||
'',
|
||||
);
|
||||
setSavingTemplate(true);
|
||||
setTemplateNote(null);
|
||||
try {
|
||||
const tpl = await saveTemplate({
|
||||
name: name.trim(),
|
||||
description: description?.trim() || undefined,
|
||||
sourceProjectId: projectId,
|
||||
});
|
||||
setTemplateNote(
|
||||
tpl
|
||||
? t('fileViewer.savedTemplate', { name: tpl.name })
|
||||
: t('fileViewer.savedTemplateFail'),
|
||||
);
|
||||
} finally {
|
||||
setSavingTemplate(false);
|
||||
// Auto-clear the note so the menu doesn't keep stale state next open.
|
||||
setTimeout(() => setTemplateNote(null), 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function presentInThisTab() {
|
||||
setPresentMenuOpen(false);
|
||||
setInTabPresent(true);
|
||||
}
|
||||
|
||||
function presentFullscreen() {
|
||||
setPresentMenuOpen(false);
|
||||
const el = previewBodyRef.current;
|
||||
if (el && typeof el.requestFullscreen === 'function') {
|
||||
el.requestFullscreen().catch(() => setInTabPresent(true));
|
||||
} else {
|
||||
setInTabPresent(true);
|
||||
}
|
||||
}
|
||||
|
||||
function presentNewTab() {
|
||||
setPresentMenuOpen(false);
|
||||
openInNewTab();
|
||||
}
|
||||
|
||||
function bumpZoom(delta: number) {
|
||||
setZoom((z) => Math.max(25, Math.min(200, z + delta)));
|
||||
}
|
||||
|
||||
const showPresent = isDeck && source !== null;
|
||||
const canShare = source !== null;
|
||||
const exportTitle = file.name.replace(/\.html?$/i, '') || file.name;
|
||||
const canPptx = canShare && Boolean(onExportAsPptx) && !streaming;
|
||||
const previewScale = zoom / 100;
|
||||
|
||||
return (
|
||||
<div className="viewer html-viewer">
|
||||
<div className="viewer-toolbar">
|
||||
<div className="viewer-toolbar-left">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-only"
|
||||
onClick={() => setReloadKey((n) => n + 1)}
|
||||
title={t('fileViewer.reload')}
|
||||
aria-label={t('fileViewer.reloadAria')}
|
||||
>
|
||||
<Icon name="reload" size={14} />
|
||||
</button>
|
||||
{isDeck ? (
|
||||
<span
|
||||
className="deck-nav"
|
||||
role="group"
|
||||
aria-label={t('fileViewer.slideNavAria')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-only"
|
||||
onClick={() => postSlide('prev')}
|
||||
title={t('fileViewer.previousSlide')}
|
||||
aria-label={t('fileViewer.previousSlide')}
|
||||
disabled={slideState !== null && slideState.active <= 0}
|
||||
>
|
||||
<Icon name="chevron-right" size={14} style={{ transform: 'rotate(180deg)' }} />
|
||||
</button>
|
||||
<span className="deck-nav-counter">
|
||||
{slideState
|
||||
? `${slideState.active + 1} / ${slideState.count}`
|
||||
: '— / —'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-only"
|
||||
onClick={() => postSlide('next')}
|
||||
title={t('fileViewer.nextSlide')}
|
||||
aria-label={t('fileViewer.nextSlide')}
|
||||
disabled={
|
||||
slideState !== null &&
|
||||
slideState.active >= slideState.count - 1
|
||||
}
|
||||
>
|
||||
<Icon name="chevron-right" size={14} />
|
||||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-toggle"
|
||||
disabled
|
||||
data-coming-soon="true"
|
||||
title={t('fileViewer.tweaks')}
|
||||
aria-pressed={false}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Icon name="tweaks" size={13} />
|
||||
<span>{t('fileViewer.tweaks')}</span>
|
||||
<span className="switch" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="viewer-toolbar-actions">
|
||||
<div className="viewer-tabs">
|
||||
<button
|
||||
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => setMode('preview')}
|
||||
>
|
||||
{t('fileViewer.preview')}
|
||||
</button>
|
||||
<button
|
||||
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
|
||||
onClick={() => setMode('source')}
|
||||
>
|
||||
{t('fileViewer.source')}
|
||||
</button>
|
||||
</div>
|
||||
<span className="viewer-divider" aria-hidden />
|
||||
<button
|
||||
className="viewer-action"
|
||||
type="button"
|
||||
disabled
|
||||
data-coming-soon="true"
|
||||
title={t('fileViewer.comment')}
|
||||
>
|
||||
<Icon name="comment" size={13} />
|
||||
<span>{t('fileViewer.comment')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="viewer-action"
|
||||
type="button"
|
||||
disabled
|
||||
data-coming-soon="true"
|
||||
title={t('fileViewer.edit')}
|
||||
>
|
||||
<Icon name="edit" size={13} />
|
||||
<span>{t('fileViewer.edit')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="viewer-action"
|
||||
type="button"
|
||||
disabled
|
||||
data-coming-soon="true"
|
||||
title={t('fileViewer.draw')}
|
||||
>
|
||||
<Icon name="draw" size={13} />
|
||||
<span>{t('fileViewer.draw')}</span>
|
||||
</button>
|
||||
<span className="viewer-divider" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className="icon-only"
|
||||
onClick={() => bumpZoom(-25)}
|
||||
title={t('fileViewer.zoomOut')}
|
||||
aria-label={t('fileViewer.zoomOut')}
|
||||
>
|
||||
<Icon name="minus" size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-action"
|
||||
onClick={() => setZoom(100)}
|
||||
title={t('fileViewer.resetZoom')}
|
||||
style={{ minWidth: 60 }}
|
||||
>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-only"
|
||||
onClick={() => bumpZoom(25)}
|
||||
title={t('fileViewer.zoomIn')}
|
||||
aria-label={t('fileViewer.zoomIn')}
|
||||
>
|
||||
<Icon name="plus" size={14} />
|
||||
</button>
|
||||
<span className="viewer-divider" aria-hidden />
|
||||
{showPresent ? (
|
||||
<div className="present-wrap">
|
||||
<button
|
||||
className="viewer-action present-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={presentMenuOpen}
|
||||
onClick={() => setPresentMenuOpen((v) => !v)}
|
||||
>
|
||||
<Icon name="present" size={13} />
|
||||
<span>{t('fileViewer.present')}</span>
|
||||
<Icon name="chevron-down" size={11} />
|
||||
</button>
|
||||
{presentMenuOpen ? (
|
||||
<div className="present-menu" role="menu">
|
||||
<button role="menuitem" onClick={presentInThisTab}>
|
||||
<span className="present-icon"><Icon name="eye" size={13} /></span>{' '}
|
||||
{t('fileViewer.presentInTab')}
|
||||
</button>
|
||||
<button role="menuitem" onClick={presentFullscreen}>
|
||||
<span className="present-icon"><Icon name="play" size={13} /></span>{' '}
|
||||
{t('fileViewer.presentFullscreen')}
|
||||
</button>
|
||||
<button role="menuitem" onClick={presentNewTab}>
|
||||
<span className="present-icon"><Icon name="share" size={13} /></span>{' '}
|
||||
{t('fileViewer.presentNewTab')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{canShare ? (
|
||||
<div className="share-menu" ref={shareRef}>
|
||||
<button
|
||||
className="viewer-action primary"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={shareMenuOpen}
|
||||
onClick={() => setShareMenuOpen((v) => !v)}
|
||||
>
|
||||
<span>{t('fileViewer.shareLabel')}</span>
|
||||
<Icon name="chevron-down" size={11} />
|
||||
</button>
|
||||
{shareMenuOpen ? (
|
||||
<div className="share-menu-popover" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className="share-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShareMenuOpen(false);
|
||||
exportAsPdf(source ?? '', exportTitle, { deck: isDeck });
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
|
||||
<span>
|
||||
{isDeck
|
||||
? t('fileViewer.exportPdfAllSlides')
|
||||
: t('fileViewer.exportPdf')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="share-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!canPptx}
|
||||
title={
|
||||
onExportAsPptx
|
||||
? streaming
|
||||
? t('fileViewer.exportPptxBusy')
|
||||
: t('fileViewer.exportPptxHint')
|
||||
: t('fileViewer.exportPptxNa')
|
||||
}
|
||||
onClick={() => {
|
||||
setShareMenuOpen(false);
|
||||
if (onExportAsPptx) onExportAsPptx(file.name);
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="present" size={14} /></span>
|
||||
<span>{t('fileViewer.exportPptx') + '…'}</span>
|
||||
</button>
|
||||
<div className="share-menu-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="share-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShareMenuOpen(false);
|
||||
exportAsZip(source ?? '', exportTitle);
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="download" size={14} /></span>
|
||||
<span>{t('fileViewer.exportZip')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="share-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShareMenuOpen(false);
|
||||
exportAsHtml(source ?? '', exportTitle);
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="file-code" size={14} /></span>
|
||||
<span>{t('fileViewer.exportHtml')}</span>
|
||||
</button>
|
||||
<div className="share-menu-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="share-menu-item"
|
||||
role="menuitem"
|
||||
disabled={savingTemplate}
|
||||
onClick={() => {
|
||||
void handleSaveAsTemplate();
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
|
||||
<span>
|
||||
{savingTemplate
|
||||
? t('fileViewer.savingTemplate')
|
||||
: templateNote
|
||||
? templateNote
|
||||
: t('fileViewer.saveAsTemplate')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="viewer-body" ref={previewBodyRef}>
|
||||
{source === null ? (
|
||||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||||
) : mode === 'preview' ? (
|
||||
<div
|
||||
style={{
|
||||
width: `${100 / previewScale}%`,
|
||||
height: `${100 / previewScale}%`,
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts"
|
||||
srcDoc={srcDoc}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="viewer-source">{source}</pre>
|
||||
)}
|
||||
</div>
|
||||
{inTabPresent && source ? (
|
||||
<div
|
||||
className="present-overlay"
|
||||
role="dialog"
|
||||
aria-label={t('fileViewer.exitPresentation')}
|
||||
>
|
||||
<button
|
||||
className="present-exit"
|
||||
onClick={() => setInTabPresent(false)}
|
||||
aria-label={t('fileViewer.exitPresentation')}
|
||||
>
|
||||
<Icon name="close" size={13} /> {t('fileViewer.exitPresentation')}
|
||||
</button>
|
||||
<iframe title="present" sandbox="allow-scripts" srcDoc={srcDoc} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageViewer({
|
||||
projectId,
|
||||
file,
|
||||
}: {
|
||||
projectId: string;
|
||||
file: ProjectFile;
|
||||
}) {
|
||||
const t = useT();
|
||||
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
|
||||
return (
|
||||
<div className="viewer image-viewer">
|
||||
<div className="viewer-toolbar">
|
||||
<div className="viewer-toolbar-left">
|
||||
<span className="viewer-meta">
|
||||
{file.kind === 'sketch'
|
||||
? t('fileViewer.sketchMeta', { size: humanSize(file.size) })
|
||||
: t('fileViewer.imageMeta', { size: humanSize(file.size) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="viewer-toolbar-actions">
|
||||
<a
|
||||
className="ghost-link"
|
||||
href={projectFileUrl(projectId, file.name)}
|
||||
download={file.name}
|
||||
>
|
||||
{t('fileViewer.download')}
|
||||
</a>
|
||||
<a
|
||||
className="ghost-link"
|
||||
href={projectFileUrl(projectId, file.name)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{t('fileViewer.open')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="viewer-body image-body">
|
||||
<img alt={file.name} src={url} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextViewer({
|
||||
projectId,
|
||||
file,
|
||||
}: {
|
||||
projectId: string;
|
||||
file: ProjectFile;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
useEffect(() => {
|
||||
setText(null);
|
||||
let cancelled = false;
|
||||
void fetchProjectFileText(projectId, file.name).then((t) => {
|
||||
if (!cancelled) setText(t ?? '');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, file.name, file.mtime, reloadKey]);
|
||||
|
||||
async function copy() {
|
||||
if (text == null) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// best-effort fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} finally {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lineCount = text ? text.split('\n').length : 0;
|
||||
|
||||
return (
|
||||
<div className="viewer text-viewer">
|
||||
<div className="viewer-toolbar">
|
||||
<div className="viewer-toolbar-left" />
|
||||
<div className="viewer-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-action"
|
||||
onClick={() => setReloadKey((n) => n + 1)}
|
||||
title={t('fileViewer.reloadDisk')}
|
||||
>
|
||||
<Icon name="reload" size={13} />
|
||||
<span>{t('fileViewer.reload')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-action"
|
||||
disabled
|
||||
title={t('fileViewer.saveDisabled')}
|
||||
>
|
||||
<Icon name="check" size={13} />
|
||||
<span>{t('fileViewer.save')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-action"
|
||||
onClick={() => void copy()}
|
||||
title={t('fileViewer.copyTitle')}
|
||||
>
|
||||
<Icon name={copied ? 'check' : 'copy'} size={13} />
|
||||
<span>{copied ? t('fileViewer.copied') : t('fileViewer.copy')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="viewer-body">
|
||||
{text === null ? (
|
||||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||||
) : lineCount > 0 ? (
|
||||
<CodeWithLines text={text} />
|
||||
) : (
|
||||
<pre className="viewer-source">{text}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeWithLines({ text }: { text: string }) {
|
||||
const lines = text.split('\n');
|
||||
// Trailing newline produces a phantom empty line — keep gutter aligned.
|
||||
const gutter = lines.map((_, i) => `${i + 1}`).join('\n');
|
||||
return (
|
||||
<pre className="code-viewer">
|
||||
<code className="gutter" aria-hidden>
|
||||
{gutter}
|
||||
</code>
|
||||
<code className="lines">{text}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function humanSize(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,463 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import {
|
||||
deleteProjectFile,
|
||||
fetchProjectFileText,
|
||||
uploadProjectFile,
|
||||
writeProjectTextFile,
|
||||
} from '../providers/registry';
|
||||
import type { OpenTabsState, 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;
|
||||
}
|
||||
|
||||
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,
|
||||
}: 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 [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 f = ev.target.files?.[0];
|
||||
if (!f) return;
|
||||
const result = await uploadProjectFile(projectId, f);
|
||||
ev.target.value = '';
|
||||
if (result) {
|
||||
await onRefreshFiles();
|
||||
openFile(result.name);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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 };
|
||||
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">
|
||||
<div className="ws-tabs-bar">
|
||||
<button
|
||||
type="button"
|
||||
className={`ws-tab design-files-tab ${activeTab === DESIGN_FILES_TAB ? 'active' : ''}`}
|
||||
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">
|
||||
{activeTab === DESIGN_FILES_TAB ? (
|
||||
<DesignFilesPanel
|
||||
projectId={projectId}
|
||||
files={files}
|
||||
onRefreshFiles={onRefreshFiles}
|
||||
onOpenFile={openFile}
|
||||
onDeleteFile={(name) => void handleDelete(name)}
|
||||
onUpload={() => fileInputRef.current?.click()}
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<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"
|
||||
accept="image/*"
|
||||
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?: 'html' | 'image' | 'sketch' | 'text' | 'code' | 'binary';
|
||||
}) {
|
||||
const t = useT();
|
||||
const iconName = kindIconName(kind);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`ws-tab ${active ? 'active' : ''}`}
|
||||
onClick={onActivate}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
>
|
||||
{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}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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,383 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
type IconName =
|
||||
| 'arrow-left'
|
||||
| 'arrow-up'
|
||||
| 'attach'
|
||||
| 'check'
|
||||
| 'chevron-down'
|
||||
| 'chevron-right'
|
||||
| 'close'
|
||||
| 'copy'
|
||||
| 'comment'
|
||||
| 'download'
|
||||
| 'draw'
|
||||
| 'edit'
|
||||
| 'eye'
|
||||
| 'file'
|
||||
| 'file-code'
|
||||
| 'folder'
|
||||
| 'grid'
|
||||
| 'history'
|
||||
| 'image'
|
||||
| 'import'
|
||||
| 'link'
|
||||
| 'mic'
|
||||
| 'minus'
|
||||
| 'pencil'
|
||||
| 'plus'
|
||||
| 'play'
|
||||
| 'present'
|
||||
| 'refresh'
|
||||
| 'reload'
|
||||
| 'search'
|
||||
| 'send'
|
||||
| 'settings'
|
||||
| 'share'
|
||||
| 'sliders'
|
||||
| 'spinner'
|
||||
| 'sparkles'
|
||||
| 'stop'
|
||||
| '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 '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-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 '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 '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="grid" 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import type {
|
||||
DesignSystemSummary,
|
||||
ProjectKind,
|
||||
ProjectMetadata,
|
||||
ProjectTemplate,
|
||||
SkillSummary,
|
||||
} from '../types';
|
||||
import { Icon } from './Icon';
|
||||
import { Skeleton } from './Loading';
|
||||
|
||||
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export type CreateTab = 'prototype' | 'deck' | 'template' | 'other';
|
||||
|
||||
export interface CreateInput {
|
||||
name: string;
|
||||
skillId: string | null;
|
||||
designSystemId: string | null;
|
||||
metadata: ProjectMetadata;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
skills: SkillSummary[];
|
||||
designSystems: DesignSystemSummary[];
|
||||
defaultDesignSystemId: string | null;
|
||||
templates: ProjectTemplate[];
|
||||
onCreate: (input: CreateInput) => void;
|
||||
presetTab?: CreateTab;
|
||||
presetSkillId?: string | null;
|
||||
presetName?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const TAB_LABEL_KEYS: Record<CreateTab, keyof Dict> = {
|
||||
prototype: 'newproj.tabPrototype',
|
||||
deck: 'newproj.tabDeck',
|
||||
template: 'newproj.tabTemplate',
|
||||
other: 'newproj.tabOther',
|
||||
};
|
||||
|
||||
export function NewProjectPanel({
|
||||
skills,
|
||||
designSystems,
|
||||
defaultDesignSystemId,
|
||||
templates,
|
||||
onCreate,
|
||||
presetTab,
|
||||
presetSkillId,
|
||||
presetName,
|
||||
loading = false,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const [tab, setTab] = useState<CreateTab>(presetTab ?? 'prototype');
|
||||
const [name, setName] = useState(presetName ?? '');
|
||||
// Design-system selection is now an *array* internally so the same
|
||||
// component can drive both single-select and multi-select modes without
|
||||
// duplicating state. Single-select coerces to length 0/1.
|
||||
const [selectedDsIds, setSelectedDsIds] = useState<string[]>([]);
|
||||
const [dsMulti, setDsMulti] = useState(false);
|
||||
|
||||
// Per-tab metadata. Tracked independently so switching tabs preserves
|
||||
// each tab's pick rather than resetting to defaults.
|
||||
const [fidelity, setFidelity] = useState<'wireframe' | 'high-fidelity'>(
|
||||
'high-fidelity',
|
||||
);
|
||||
const [speakerNotes, setSpeakerNotes] = useState(false);
|
||||
const [animations, setAnimations] = useState(false);
|
||||
const [templateId, setTemplateId] = useState<string | null>(null);
|
||||
|
||||
// When entering the template tab, snap to the first user-saved template
|
||||
// if there is one (and we don't already have a valid pick). The template
|
||||
// tab no longer offers a built-in fallback — the entire point is to
|
||||
// start from a template *the user* created via Share.
|
||||
useEffect(() => {
|
||||
if (tab !== 'template') return;
|
||||
if (templates.length === 0) {
|
||||
setTemplateId(null);
|
||||
return;
|
||||
}
|
||||
if (templateId == null || !templates.some((t) => t.id === templateId)) {
|
||||
setTemplateId(templates[0]!.id);
|
||||
}
|
||||
}, [tab, templates, templateId]);
|
||||
|
||||
// The skill the request still routes through — kept so prototype/deck
|
||||
// pick a default-rendered skill (so the agent gets the right SKILL.md
|
||||
// body) without requiring the user to choose one explicitly.
|
||||
const skillIdForTab = useMemo(() => {
|
||||
if (presetSkillId !== undefined) return presetSkillId;
|
||||
if (tab === 'other') return null;
|
||||
if (tab === 'prototype') {
|
||||
const list = skills.filter((s) => s.mode === 'prototype');
|
||||
return list.find((s) => s.defaultFor.includes('prototype'))?.id
|
||||
?? list[0]?.id
|
||||
?? null;
|
||||
}
|
||||
if (tab === 'deck') {
|
||||
const list = skills.filter((s) => s.mode === 'deck');
|
||||
return list.find((s) => s.defaultFor.includes('deck'))?.id
|
||||
?? list[0]?.id
|
||||
?? null;
|
||||
}
|
||||
return null;
|
||||
}, [tab, skills, presetSkillId]);
|
||||
|
||||
const canCreate =
|
||||
!loading && (tab !== 'template' || templateId != null);
|
||||
|
||||
function handleCreate() {
|
||||
if (!canCreate) return;
|
||||
const primaryDs = selectedDsIds[0] ?? null;
|
||||
const inspirations = selectedDsIds.slice(1);
|
||||
const metadata = buildMetadata({
|
||||
tab,
|
||||
fidelity,
|
||||
speakerNotes,
|
||||
animations,
|
||||
templateId,
|
||||
templates,
|
||||
inspirationIds: inspirations,
|
||||
});
|
||||
onCreate({
|
||||
name: name.trim() || autoName(tab, t),
|
||||
skillId: skillIdForTab,
|
||||
designSystemId: primaryDs,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="newproj">
|
||||
<div className="newproj-tabs" role="tablist">
|
||||
{(Object.keys(TAB_LABEL_KEYS) as CreateTab[]).map((entry) => (
|
||||
<button
|
||||
key={entry}
|
||||
role="tab"
|
||||
aria-selected={tab === entry}
|
||||
className={`newproj-tab ${tab === entry ? 'active' : ''}`}
|
||||
onClick={() => setTab(entry)}
|
||||
>
|
||||
{t(TAB_LABEL_KEYS[entry])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="newproj-body">
|
||||
<h3 className="newproj-title">{titleForTab(tab, t)}</h3>
|
||||
|
||||
<input
|
||||
className="newproj-name"
|
||||
placeholder={t('newproj.namePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
<DesignSystemPicker
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId={defaultDesignSystemId}
|
||||
selectedIds={selectedDsIds}
|
||||
multi={dsMulti}
|
||||
onChangeMulti={setDsMulti}
|
||||
onChange={setSelectedDsIds}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{tab === 'prototype' ? (
|
||||
<FidelityPicker value={fidelity} onChange={setFidelity} />
|
||||
) : null}
|
||||
|
||||
{tab === 'deck' ? (
|
||||
<ToggleRow
|
||||
label={t('newproj.toggleSpeakerNotes')}
|
||||
hint={t('newproj.toggleSpeakerNotesHint')}
|
||||
checked={speakerNotes}
|
||||
onChange={setSpeakerNotes}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{tab === 'template' ? (
|
||||
<>
|
||||
<TemplatePicker
|
||||
templates={templates}
|
||||
value={templateId}
|
||||
onChange={setTemplateId}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t('newproj.toggleAnimations')}
|
||||
hint={t('newproj.toggleAnimationsHint')}
|
||||
checked={animations}
|
||||
onChange={setAnimations}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="primary newproj-create"
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate}
|
||||
title={
|
||||
tab === 'template' && templateId == null
|
||||
? t('newproj.createDisabledTitle')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon name="plus" size={13} />
|
||||
<span>
|
||||
{tab === 'template'
|
||||
? t('newproj.createFromTemplate')
|
||||
: t('newproj.create')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="newproj-footer">{t('newproj.privacyFooter')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FidelityPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: 'wireframe' | 'high-fidelity';
|
||||
onChange: (v: 'wireframe' | 'high-fidelity') => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="newproj-section">
|
||||
<label className="newproj-label">{t('newproj.fidelityLabel')}</label>
|
||||
<div className="fidelity-grid">
|
||||
<FidelityCard
|
||||
active={value === 'wireframe'}
|
||||
onClick={() => onChange('wireframe')}
|
||||
label={t('newproj.fidelityWireframe')}
|
||||
variant="wireframe"
|
||||
/>
|
||||
<FidelityCard
|
||||
active={value === 'high-fidelity'}
|
||||
onClick={() => onChange('high-fidelity')}
|
||||
label={t('newproj.fidelityHigh')}
|
||||
variant="high-fidelity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FidelityCard({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
variant,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
variant: 'wireframe' | 'high-fidelity';
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`fidelity-card${active ? ' active' : ''}`}
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className={`fidelity-thumb fidelity-thumb-${variant}`} aria-hidden>
|
||||
{variant === 'wireframe' ? <WireframeArt /> : <HighFidelityArt />}
|
||||
</span>
|
||||
<span className="fidelity-label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function WireframeArt() {
|
||||
return (
|
||||
<svg viewBox="0 0 120 70" width="100%" height="100%" aria-hidden>
|
||||
<rect x="6" y="8" width="46" height="6" rx="2" fill="#d8d4cb" />
|
||||
<rect x="6" y="20" width="34" height="4" rx="2" fill="#ebe8e1" />
|
||||
<rect x="6" y="28" width="38" height="4" rx="2" fill="#ebe8e1" />
|
||||
<rect x="6" y="36" width="30" height="4" rx="2" fill="#ebe8e1" />
|
||||
<circle cx="22" cy="56" r="6" fill="none" stroke="#d8d4cb" strokeWidth="1.4" />
|
||||
<rect x="64" y="8" width="50" height="54" rx="3" fill="none" stroke="#d8d4cb" strokeWidth="1.4" />
|
||||
<rect x="70" y="14" width="38" height="4" rx="2" fill="#ebe8e1" />
|
||||
<rect x="70" y="22" width="32" height="4" rx="2" fill="#ebe8e1" />
|
||||
<rect x="70" y="30" width="38" height="4" rx="2" fill="#ebe8e1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HighFidelityArt() {
|
||||
return (
|
||||
<svg viewBox="0 0 120 70" width="100%" height="100%" aria-hidden>
|
||||
<rect x="6" y="8" width="34" height="6" rx="2" fill="#1a1916" />
|
||||
<rect x="6" y="20" width="46" height="4" rx="2" fill="#74716b" />
|
||||
<rect x="6" y="28" width="42" height="4" rx="2" fill="#b3b0a8" />
|
||||
<rect x="6" y="40" width="22" height="9" rx="2" fill="#c96442" />
|
||||
<rect x="64" y="8" width="50" height="54" rx="4" fill="#fbeee5" />
|
||||
<rect x="70" y="14" width="38" height="4" rx="2" fill="#c96442" />
|
||||
<rect x="70" y="22" width="32" height="3" rx="1.5" fill="#74716b" />
|
||||
<rect x="70" y="29" width="36" height="3" rx="1.5" fill="#b3b0a8" />
|
||||
<rect x="70" y="36" width="20" height="6" rx="2" fill="#c96442" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-row${checked ? ' on' : ''}`}
|
||||
onClick={() => onChange(!checked)}
|
||||
aria-pressed={checked}
|
||||
>
|
||||
<div className="toggle-row-text">
|
||||
<span className="toggle-row-label">{label}</span>
|
||||
{hint ? <span className="toggle-row-hint">{hint}</span> : null}
|
||||
</div>
|
||||
<span className="toggle-row-switch" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatePicker({
|
||||
templates,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
templates: ProjectTemplate[];
|
||||
value: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="newproj-section">
|
||||
<label className="newproj-label">{t('newproj.templateLabel')}</label>
|
||||
{templates.length === 0 ? (
|
||||
<div className="template-howto">
|
||||
<span className="template-howto-title">
|
||||
{t('newproj.noTemplatesTitle')}
|
||||
</span>
|
||||
<span className="template-howto-body">
|
||||
{t('newproj.noTemplatesBody')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="template-list">
|
||||
{templates.map((tpl) => {
|
||||
const fallbackDesc = `${t('newproj.savedTemplate')} · ${tpl.files.length} ${
|
||||
tpl.files.length === 1
|
||||
? t('newproj.fileSingular')
|
||||
: t('newproj.filePlural')
|
||||
}`;
|
||||
return (
|
||||
<TemplateOption
|
||||
key={tpl.id}
|
||||
active={value === tpl.id}
|
||||
onClick={() => onChange(tpl.id)}
|
||||
name={tpl.name}
|
||||
description={tpl.description ?? fallbackDesc}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateOption({
|
||||
active,
|
||||
onClick,
|
||||
name,
|
||||
description,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
name: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`template-option${active ? ' active' : ''}`}
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className={`template-radio${active ? ' active' : ''}`} aria-hidden />
|
||||
<span className="template-option-text">
|
||||
<span className="template-option-name">{name}</span>
|
||||
<span className="template-option-desc">{description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Design system picker — custom popover (replaces native <select>).
|
||||
- Single-select by default. Toggle in the popover header switches to
|
||||
multi-select, which lets users blend up to a few inspirations
|
||||
(first pick is the primary; the rest go into metadata).
|
||||
- Trigger card mirrors the claude.ai/design treatment: a tiny brand
|
||||
swatch strip + title + "Default" subtitle + chevron.
|
||||
============================================================ */
|
||||
function DesignSystemPicker({
|
||||
designSystems,
|
||||
defaultDesignSystemId,
|
||||
selectedIds,
|
||||
multi,
|
||||
onChange,
|
||||
onChangeMulti,
|
||||
loading,
|
||||
}: {
|
||||
designSystems: DesignSystemSummary[];
|
||||
defaultDesignSystemId: string | null;
|
||||
selectedIds: string[];
|
||||
multi: boolean;
|
||||
onChange: (ids: string[]) => void;
|
||||
onChangeMulti: (v: boolean) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const byId = useMemo(() => {
|
||||
const map = new Map<string, DesignSystemSummary>();
|
||||
for (const d of designSystems) map.set(d.id, d);
|
||||
return map;
|
||||
}, [designSystems]);
|
||||
|
||||
// Sort: selected first (in pick order), then default DS, then alpha
|
||||
// by category then title. Keeps the popover scannable while honoring
|
||||
// the user's existing picks.
|
||||
const ordered = useMemo(() => {
|
||||
const picked = selectedIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((d): d is DesignSystemSummary => Boolean(d));
|
||||
const pickedSet = new Set(picked.map((d) => d.id));
|
||||
const rest = designSystems
|
||||
.filter((d) => !pickedSet.has(d.id))
|
||||
.sort((a, b) => {
|
||||
if (a.id === defaultDesignSystemId) return -1;
|
||||
if (b.id === defaultDesignSystemId) return 1;
|
||||
const ca = a.category || 'Other';
|
||||
const cb = b.category || 'Other';
|
||||
if (ca !== cb) return ca.localeCompare(cb);
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
return [...picked, ...rest];
|
||||
}, [designSystems, byId, selectedIds, defaultDesignSystemId]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return ordered;
|
||||
return ordered.filter((d) => {
|
||||
return (
|
||||
d.title.toLowerCase().includes(q) ||
|
||||
(d.summary || '').toLowerCase().includes(q) ||
|
||||
(d.category || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [ordered, query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = window.setTimeout(() => searchRef.current?.focus(), 30);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointer(e: MouseEvent) {
|
||||
if (wrapRef.current?.contains(e.target as Node)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}
|
||||
// Defer listener registration by a tick so the very click that opened
|
||||
// the popover doesn't get re-interpreted as an outside-click on the
|
||||
// mousedown that follows in the same event cycle (StrictMode also
|
||||
// double-invokes the effect, which can race the same event).
|
||||
const t = window.setTimeout(() => {
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
document.removeEventListener('mousedown', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function toggle(id: string) {
|
||||
if (multi) {
|
||||
// Multi-select: tapping toggles membership; the *first* id in the
|
||||
// array is treated as the primary across the rest of the app.
|
||||
const has = selectedIds.includes(id);
|
||||
if (has) {
|
||||
onChange(selectedIds.filter((x) => x !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
} else {
|
||||
onChange([id]);
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
onChange([]);
|
||||
if (!multi) setOpen(false);
|
||||
}
|
||||
|
||||
const primaryId = selectedIds[0] ?? null;
|
||||
const primary = primaryId ? byId.get(primaryId) ?? null : null;
|
||||
const extraCount = Math.max(0, selectedIds.length - 1);
|
||||
const isDefault = !!primary && primary.id === defaultDesignSystemId;
|
||||
|
||||
if (loading && designSystems.length === 0) {
|
||||
return (
|
||||
<div className="newproj-section">
|
||||
<label className="newproj-label">{t('newproj.designSystem')}</label>
|
||||
<Skeleton height={56} width="100%" radius={8} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="newproj-section ds-picker" ref={wrapRef}>
|
||||
<label className="newproj-label">{t('newproj.designSystem')}</label>
|
||||
<button
|
||||
type="button"
|
||||
className={`ds-picker-trigger${open ? ' open' : ''}${primary ? '' : ' empty'}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<DesignSystemAvatar system={primary} extraCount={extraCount} />
|
||||
<span className="ds-picker-meta">
|
||||
<span className="ds-picker-title">
|
||||
{primary ? primary.title : t('newproj.dsNoneFreeform')}
|
||||
{extraCount > 0 ? (
|
||||
<span className="ds-picker-extra-pill">+{extraCount}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="ds-picker-sub">
|
||||
{primary
|
||||
? isDefault
|
||||
? t('common.default')
|
||||
: primary.category || t('newproj.dsCategoryFallback')
|
||||
: t('newproj.dsNoneSubtitleEmpty')}
|
||||
</span>
|
||||
</span>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size={14}
|
||||
className="ds-picker-chevron"
|
||||
style={{ transform: open ? 'rotate(180deg)' : undefined }}
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="ds-picker-popover" role="listbox">
|
||||
<div className="ds-picker-head">
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="ds-picker-search"
|
||||
placeholder={t('newproj.dsSearch')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div
|
||||
className="ds-picker-mode"
|
||||
role="tablist"
|
||||
aria-label={t('newproj.dsModeAria')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={!multi}
|
||||
className={`ds-picker-mode-btn${!multi ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
onChangeMulti(false);
|
||||
if (selectedIds.length > 1) onChange(selectedIds.slice(0, 1));
|
||||
}}
|
||||
>
|
||||
{t('newproj.dsModeSingle')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={multi}
|
||||
className={`ds-picker-mode-btn${multi ? ' active' : ''}`}
|
||||
onClick={() => onChangeMulti(true)}
|
||||
>
|
||||
{t('newproj.dsModeMulti')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ds-picker-list">
|
||||
<DsPickerItem
|
||||
active={selectedIds.length === 0}
|
||||
multi={multi}
|
||||
onClick={clearAll}
|
||||
avatar={<NoneAvatar />}
|
||||
title={t('newproj.dsNoneTitle')}
|
||||
subtitle={t('newproj.dsNoneSub')}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="ds-picker-empty">
|
||||
{t('newproj.dsEmpty', { query })}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((d) => {
|
||||
const active = selectedIds.includes(d.id);
|
||||
const order = active ? selectedIds.indexOf(d.id) : -1;
|
||||
return (
|
||||
<DsPickerItem
|
||||
key={d.id}
|
||||
active={active}
|
||||
multi={multi}
|
||||
order={order}
|
||||
onClick={() => toggle(d.id)}
|
||||
avatar={<DesignSystemAvatar system={d} />}
|
||||
title={d.title}
|
||||
badge={
|
||||
d.id === defaultDesignSystemId
|
||||
? t('newproj.dsBadgeDefault')
|
||||
: undefined
|
||||
}
|
||||
subtitle={d.summary || d.category || ''}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{multi && selectedIds.length > 1 ? (
|
||||
<div className="ds-picker-foot">
|
||||
<span className="ds-picker-foot-text">
|
||||
<strong>{primary?.title ?? t('newproj.dsPrimaryFallback')}</strong>{' '}
|
||||
{extraCount === 1
|
||||
? t('newproj.dsFootSingular')
|
||||
: t('newproj.dsFootPlural')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ds-picker-clear"
|
||||
onClick={clearAll}
|
||||
>
|
||||
{t('newproj.dsFootClear')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DsPickerItem({
|
||||
active,
|
||||
multi,
|
||||
order,
|
||||
onClick,
|
||||
avatar,
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
}: {
|
||||
active: boolean;
|
||||
multi: boolean;
|
||||
order?: number;
|
||||
onClick: () => void;
|
||||
avatar: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`ds-picker-item${active ? ' active' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="ds-picker-item-avatar">{avatar}</span>
|
||||
<span className="ds-picker-item-text">
|
||||
<span className="ds-picker-item-title">
|
||||
{title}
|
||||
{badge ? <span className="ds-picker-item-badge">{badge}</span> : null}
|
||||
</span>
|
||||
<span className="ds-picker-item-sub">{subtitle}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`ds-picker-mark ${multi ? 'check' : 'radio'}${active ? ' active' : ''}`}
|
||||
aria-hidden
|
||||
>
|
||||
{multi ? (
|
||||
active ? (order != null && order >= 0 ? order + 1 : '✓') : ''
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DesignSystemAvatar({
|
||||
system,
|
||||
extraCount = 0,
|
||||
}: {
|
||||
system: DesignSystemSummary | null;
|
||||
extraCount?: number;
|
||||
}) {
|
||||
if (!system) return <NoneAvatar />;
|
||||
const swatches = system.swatches && system.swatches.length > 0
|
||||
? system.swatches.slice(0, 4)
|
||||
: fallbackSwatches(system.title);
|
||||
return (
|
||||
<span className="ds-avatar" aria-hidden>
|
||||
<span className="ds-avatar-grid">
|
||||
{swatches.map((c, i) => (
|
||||
<span key={i} className="ds-avatar-cell" style={{ background: c }} />
|
||||
))}
|
||||
</span>
|
||||
{extraCount > 0 ? (
|
||||
<span className="ds-avatar-stack">+{extraCount}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NoneAvatar() {
|
||||
return (
|
||||
<span className="ds-avatar ds-avatar-none" aria-hidden>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.6" />
|
||||
<line x1="6" y1="18" x2="18" y2="6" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Deterministic fallback swatches for design systems whose DESIGN.md doesn't
|
||||
// expose its tokens via the bold-and-hex format. Keeps the avatar visually
|
||||
// distinct per-system without extra metadata fetches.
|
||||
function fallbackSwatches(seed: string): string[] {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
const base = h % 360;
|
||||
return [
|
||||
`hsl(${base}, 18%, 96%)`,
|
||||
`hsl(${(base + 90) % 360}, 22%, 78%)`,
|
||||
`hsl(${(base + 180) % 360}, 30%, 32%)`,
|
||||
`hsl(${(base + 30) % 360}, 70%, 52%)`,
|
||||
];
|
||||
}
|
||||
|
||||
function buildMetadata(input: {
|
||||
tab: CreateTab;
|
||||
fidelity: 'wireframe' | 'high-fidelity';
|
||||
speakerNotes: boolean;
|
||||
animations: boolean;
|
||||
templateId: string | null;
|
||||
templates: ProjectTemplate[];
|
||||
inspirationIds: string[];
|
||||
}): ProjectMetadata {
|
||||
const kind: ProjectKind = input.tab;
|
||||
const inspirations = input.inspirationIds.length > 0
|
||||
? { inspirationDesignSystemIds: input.inspirationIds }
|
||||
: {};
|
||||
if (input.tab === 'prototype') {
|
||||
return { kind, fidelity: input.fidelity, ...inspirations };
|
||||
}
|
||||
if (input.tab === 'deck') {
|
||||
return { kind, speakerNotes: input.speakerNotes, ...inspirations };
|
||||
}
|
||||
if (input.tab === 'template') {
|
||||
if (input.templateId == null) {
|
||||
return { kind, animations: input.animations, ...inspirations };
|
||||
}
|
||||
const tpl = input.templates.find((x) => x.id === input.templateId);
|
||||
// The fallback label is consumed by the agent prompt rather than the
|
||||
// UI, so we keep it in English to match the rest of the prompt corpus.
|
||||
return {
|
||||
kind,
|
||||
animations: input.animations,
|
||||
templateId: input.templateId,
|
||||
templateLabel: tpl?.name ?? 'Saved template',
|
||||
...inspirations,
|
||||
};
|
||||
}
|
||||
return { kind: 'other', ...inspirations };
|
||||
}
|
||||
|
||||
function titleForTab(tab: CreateTab, t: TranslateFn): string {
|
||||
switch (tab) {
|
||||
case 'prototype':
|
||||
return t('newproj.titlePrototype');
|
||||
case 'deck':
|
||||
return t('newproj.titleDeck');
|
||||
case 'template':
|
||||
return t('newproj.titleTemplate');
|
||||
case 'other':
|
||||
return t('newproj.titleOther');
|
||||
}
|
||||
}
|
||||
|
||||
function autoName(tab: CreateTab, t: TranslateFn): string {
|
||||
const stamp = new Date().toLocaleDateString();
|
||||
return `${t(TAB_LABEL_KEYS[tab])} · ${stamp}`;
|
||||
}
|
||||
@@ -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,270 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import { exportAsHtml, exportAsPdf, exportAsZip } 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
}: 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 shareRef = useRef<HTMLDivElement | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activeView = views.find((v) => v.id === activeId) ?? views[0];
|
||||
const activeHtml = activeView?.html ?? null;
|
||||
const srcDoc = useMemo(
|
||||
() => (activeHtml ? buildSrcdoc(activeHtml) : ''),
|
||||
[activeHtml],
|
||||
);
|
||||
const exportTitle = exportTitleFor(activeView?.id ?? '');
|
||||
|
||||
function openInNewTab() {
|
||||
if (!activeHtml) return;
|
||||
const blob = new Blob([activeHtml], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
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">
|
||||
<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);
|
||||
}}
|
||||
>
|
||||
<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" ref={stageRef}>
|
||||
{activeHtml === null || activeHtml === undefined ? (
|
||||
<div className="ds-modal-empty">
|
||||
{t('preview.loading', {
|
||||
label:
|
||||
activeView?.label.toLowerCase() ?? t('common.preview').toLowerCase(),
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
key={activeView?.id ?? 'view'}
|
||||
title={`${title} ${activeView?.label ?? ''}`}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
srcDoc={srcDoc}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createArtifactParser } from '../artifacts/parser';
|
||||
import { useT } from '../i18n';
|
||||
import { streamMessage } from '../providers/anthropic';
|
||||
import { streamViaDaemon } from '../providers/daemon';
|
||||
import {
|
||||
fetchDesignSystem,
|
||||
fetchProjectFiles,
|
||||
fetchSkill,
|
||||
writeProjectTextFile,
|
||||
} from '../providers/registry';
|
||||
import { composeSystemPrompt } from '../prompts/system';
|
||||
import { navigate } from '../router';
|
||||
import {
|
||||
createConversation,
|
||||
deleteConversation as deleteConversationApi,
|
||||
getTemplate,
|
||||
listConversations,
|
||||
listMessages,
|
||||
loadTabs,
|
||||
patchConversation,
|
||||
patchProject,
|
||||
saveMessage,
|
||||
saveTabs,
|
||||
} from '../state/projects';
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentInfo,
|
||||
AppConfig,
|
||||
Artifact,
|
||||
ChatAttachment,
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
DesignSystemSummary,
|
||||
OpenTabsState,
|
||||
Project,
|
||||
ProjectFile,
|
||||
ProjectTemplate,
|
||||
SkillSummary,
|
||||
} from '../types';
|
||||
import { AvatarMenu } from './AvatarMenu';
|
||||
import { ChatPane } from './ChatPane';
|
||||
import { FileWorkspace } from './FileWorkspace';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
routeFileName: string | null;
|
||||
config: AppConfig;
|
||||
agents: AgentInfo[];
|
||||
skills: SkillSummary[];
|
||||
designSystems: DesignSystemSummary[];
|
||||
daemonLive: boolean;
|
||||
onModeChange: (mode: AppConfig['mode']) => void;
|
||||
onAgentChange: (id: string) => void;
|
||||
onRefreshAgents: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onBack: () => void;
|
||||
onClearPendingPrompt: () => void;
|
||||
onTouchProject: () => void;
|
||||
onProjectChange: (next: Project) => void;
|
||||
onProjectsRefresh: () => void;
|
||||
}
|
||||
|
||||
export function ProjectView({
|
||||
project,
|
||||
routeFileName,
|
||||
config,
|
||||
agents,
|
||||
skills,
|
||||
designSystems,
|
||||
daemonLive,
|
||||
onModeChange,
|
||||
onAgentChange,
|
||||
onRefreshAgents,
|
||||
onOpenSettings,
|
||||
onBack,
|
||||
onClearPendingPrompt,
|
||||
onTouchProject,
|
||||
onProjectChange,
|
||||
onProjectsRefresh,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [artifact, setArtifact] = useState<Artifact | null>(null);
|
||||
const [filesRefresh, setFilesRefresh] = useState(0);
|
||||
const [projectFiles, setProjectFiles] = useState<ProjectFile[]>([]);
|
||||
// The persisted set of open tabs + active tab. Persisted via PUT on every
|
||||
// change; loaded once when the project mounts.
|
||||
const [openTabsState, setOpenTabsState] = useState<OpenTabsState>({
|
||||
tabs: [],
|
||||
active: null,
|
||||
});
|
||||
const tabsLoadedRef = useRef(false);
|
||||
// Routed to FileWorkspace — bumped whenever the user clicks "open" on a
|
||||
// tool card, an attachment chip, or a produced-file chip in chat. We
|
||||
// include a nonce so re-clicking the same name after the user closed the
|
||||
// tab still focuses it.
|
||||
const [openRequest, setOpenRequest] = useState<{ name: string; nonce: number } | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const skillCache = useRef<Map<string, string>>(new Map());
|
||||
const designCache = useRef<Map<string, string>>(new Map());
|
||||
const templateCache = useRef<Map<string, ProjectTemplate>>(new Map());
|
||||
// We auto-save the most recent artifact to the project folder. Track the
|
||||
// last name we persisted so re-renders during streaming don't spawn
|
||||
// duplicate writes.
|
||||
const savedArtifactRef = useRef<string | null>(null);
|
||||
// Pending Write tool invocations: tool_use_id -> destination basename.
|
||||
// When the matching tool_result lands we refresh the file list and open
|
||||
// the file as a tab once. Keying off the tool_use_id (rather than
|
||||
// diffing the file list at end-of-turn) lets us auto-open the moment
|
||||
// the agent's Write actually completes, without the previous synthetic
|
||||
// "live" tab that was causing flicker against manual opens.
|
||||
const pendingWritesRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Load conversations on project switch. If none exist (older projects
|
||||
// pre-conversations, or a freshly created one whose default seed got
|
||||
// dropped), create one on the fly.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const list = await listConversations(project.id);
|
||||
if (cancelled) return;
|
||||
if (list.length === 0) {
|
||||
const fresh = await createConversation(project.id);
|
||||
if (cancelled) return;
|
||||
if (fresh) {
|
||||
setConversations([fresh]);
|
||||
setActiveConversationId(fresh.id);
|
||||
}
|
||||
} else {
|
||||
setConversations(list);
|
||||
setActiveConversationId(list[0]!.id);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [project.id]);
|
||||
|
||||
// Load messages whenever the active conversation changes. This happens
|
||||
// on project mount (after conversations load) and on user-triggered
|
||||
// conversation switches.
|
||||
useEffect(() => {
|
||||
if (!activeConversationId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const list = await listMessages(project.id, activeConversationId);
|
||||
if (cancelled) return;
|
||||
setMessages(list);
|
||||
setArtifact(null);
|
||||
setError(null);
|
||||
savedArtifactRef.current = null;
|
||||
pendingWritesRef.current.clear();
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [project.id, activeConversationId]);
|
||||
|
||||
// Hydrate the open-tabs state once per project. After this initial
|
||||
// load, every mutation flows through saveTabsState() which keeps DB +
|
||||
// local state coherent.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
tabsLoadedRef.current = false;
|
||||
(async () => {
|
||||
const state = await loadTabs(project.id);
|
||||
if (cancelled) return;
|
||||
setOpenTabsState(state);
|
||||
tabsLoadedRef.current = true;
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [project.id]);
|
||||
|
||||
const persistTabsState = useCallback(
|
||||
(next: OpenTabsState) => {
|
||||
setOpenTabsState(next);
|
||||
if (tabsLoadedRef.current) {
|
||||
void saveTabs(project.id, next);
|
||||
}
|
||||
},
|
||||
[project.id],
|
||||
);
|
||||
|
||||
const refreshProjectFiles = useCallback(async (): Promise<ProjectFile[]> => {
|
||||
const next = await fetchProjectFiles(project.id);
|
||||
setProjectFiles(next);
|
||||
return next;
|
||||
}, [project.id]);
|
||||
|
||||
const requestOpenFile = useCallback((name: string) => {
|
||||
if (!name) return;
|
||||
setOpenRequest({ name, nonce: Date.now() });
|
||||
}, []);
|
||||
|
||||
// Set of project file names that the chat surface uses to decide whether
|
||||
// a tool card's path is openable as a tab. Recomputed on every file-list
|
||||
// change; tool cards just read from the set.
|
||||
const projectFileNames = useMemo(
|
||||
() => new Set(projectFiles.map((f) => f.name)),
|
||||
[projectFiles],
|
||||
);
|
||||
|
||||
// Keep the @-picker's source of truth fresh: every refreshSignal bump
|
||||
// (artifact saved, sketch saved, image uploaded) refetches; on first
|
||||
// mount we also do an initial pull so attachments staged before the
|
||||
// agent has written anything still see the user's pasted images.
|
||||
useEffect(() => {
|
||||
if (!daemonLive) return;
|
||||
void refreshProjectFiles();
|
||||
}, [daemonLive, refreshProjectFiles, filesRefresh]);
|
||||
|
||||
// When the URL points at a specific file, fire an open request so the
|
||||
// FileWorkspace promotes it to an active tab. We watch routeFileName
|
||||
// (the parsed segment) so back/forward navigation triggers the same path.
|
||||
useEffect(() => {
|
||||
if (!routeFileName) return;
|
||||
requestOpenFile(routeFileName);
|
||||
}, [routeFileName, requestOpenFile]);
|
||||
|
||||
// Sync the URL when the active tab changes, so reload + share-link both
|
||||
// land back on the same view. Replace (not push) on tab activation so the
|
||||
// history stack doesn't fill with every tab click.
|
||||
const lastSyncedFileRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const target = openTabsState.active && projectFileNames.has(openTabsState.active)
|
||||
? openTabsState.active
|
||||
: null;
|
||||
if (target === lastSyncedFileRef.current) return;
|
||||
lastSyncedFileRef.current = target;
|
||||
navigate(
|
||||
{ kind: 'project', projectId: project.id, fileName: target },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [openTabsState.active, projectFileNames, project.id]);
|
||||
|
||||
const handleEnsureProject = useCallback(async (): Promise<string | null> => {
|
||||
return project.id;
|
||||
}, [project.id]);
|
||||
|
||||
const composedSystemPrompt = useCallback(async (): Promise<string> => {
|
||||
let skillBody: string | undefined;
|
||||
let skillName: string | undefined;
|
||||
let skillMode: SkillSummary['mode'] | undefined;
|
||||
let designSystemBody: string | undefined;
|
||||
let designSystemTitle: string | undefined;
|
||||
|
||||
if (project.skillId) {
|
||||
const summary = skills.find((s) => s.id === project.skillId);
|
||||
skillName = summary?.name;
|
||||
skillMode = summary?.mode;
|
||||
const cached = skillCache.current.get(project.skillId);
|
||||
if (cached !== undefined) {
|
||||
skillBody = cached;
|
||||
} else {
|
||||
const detail = await fetchSkill(project.skillId);
|
||||
if (detail) {
|
||||
skillBody = detail.body;
|
||||
skillCache.current.set(project.skillId, detail.body);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (project.designSystemId) {
|
||||
const summary = designSystems.find((d) => d.id === project.designSystemId);
|
||||
designSystemTitle = summary?.title;
|
||||
const cached = designCache.current.get(project.designSystemId);
|
||||
if (cached !== undefined) {
|
||||
designSystemBody = cached;
|
||||
} else {
|
||||
const detail = await fetchDesignSystem(project.designSystemId);
|
||||
if (detail) {
|
||||
designSystemBody = detail.body;
|
||||
designCache.current.set(project.designSystemId, detail.body);
|
||||
}
|
||||
}
|
||||
}
|
||||
let template: ProjectTemplate | undefined;
|
||||
const tplId = project.metadata?.templateId;
|
||||
if (project.metadata?.kind === 'template' && tplId) {
|
||||
const cached = templateCache.current.get(tplId);
|
||||
if (cached) {
|
||||
template = cached;
|
||||
} else {
|
||||
const fetched = await getTemplate(tplId);
|
||||
if (fetched) {
|
||||
templateCache.current.set(tplId, fetched);
|
||||
template = fetched;
|
||||
}
|
||||
}
|
||||
}
|
||||
return composeSystemPrompt({
|
||||
skillBody,
|
||||
skillName,
|
||||
skillMode,
|
||||
designSystemBody,
|
||||
designSystemTitle,
|
||||
metadata: project.metadata,
|
||||
template,
|
||||
});
|
||||
}, [
|
||||
project.skillId,
|
||||
project.designSystemId,
|
||||
project.metadata,
|
||||
skills,
|
||||
designSystems,
|
||||
]);
|
||||
|
||||
const persistMessage = useCallback(
|
||||
(m: ChatMessage) => {
|
||||
if (!activeConversationId) return;
|
||||
void saveMessage(project.id, activeConversationId, m);
|
||||
},
|
||||
[project.id, activeConversationId],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (prompt: string, attachments: ChatAttachment[]) => {
|
||||
if (!activeConversationId) return;
|
||||
setError(null);
|
||||
const startedAt = Date.now();
|
||||
const userMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
};
|
||||
const assistantId = crypto.randomUUID();
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: assistantId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
events: [],
|
||||
startedAt,
|
||||
};
|
||||
const nextHistory = [...messages, userMsg];
|
||||
setMessages([...nextHistory, assistantMsg]);
|
||||
setStreaming(true);
|
||||
setArtifact(null);
|
||||
savedArtifactRef.current = null;
|
||||
onTouchProject();
|
||||
persistMessage(userMsg);
|
||||
// If this is the first turn, derive a working title from the prompt
|
||||
// so the conversation is identifiable in the dropdown without a
|
||||
// round-trip through the agent.
|
||||
if (messages.length === 0) {
|
||||
const title = prompt.slice(0, 60).trim();
|
||||
if (title) {
|
||||
setConversations((curr) =>
|
||||
curr.map((c) =>
|
||||
c.id === activeConversationId ? { ...c, title } : c,
|
||||
),
|
||||
);
|
||||
void patchConversation(project.id, activeConversationId, { title });
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the file list at turn-start so we can diff after the
|
||||
// agent finishes and surface anything new (e.g. a generated .pptx)
|
||||
// as download chips on the assistant message.
|
||||
const beforeFileNames = new Set(projectFiles.map((f) => f.name));
|
||||
|
||||
const parser = createArtifactParser();
|
||||
let liveHtml = '';
|
||||
|
||||
const updateAssistant = (updater: (prev: ChatMessage) => ChatMessage) => {
|
||||
setMessages((curr) =>
|
||||
curr.map((m) => (m.id === assistantId ? updater(m) : m)),
|
||||
);
|
||||
};
|
||||
|
||||
const pushEvent = (ev: AgentEvent) => {
|
||||
updateAssistant((prev) => ({ ...prev, events: [...(prev.events ?? []), ev] }));
|
||||
// Track Write tool invocations so we can auto-open the destination
|
||||
// file the moment the agent finishes writing it. The file-creating
|
||||
// tools we care about: Write (new file), Edit (existing file —
|
||||
// surfacing the freshly-modified file is also useful).
|
||||
if (ev.kind === 'tool_use' && (ev.name === 'Write' || ev.name === 'Edit')) {
|
||||
const filePath = (ev.input as { file_path?: unknown } | null)?.file_path;
|
||||
if (typeof filePath === 'string' && filePath.length > 0) {
|
||||
const base = filePath.split('/').pop() || filePath;
|
||||
pendingWritesRef.current.set(ev.id, base);
|
||||
}
|
||||
}
|
||||
if (ev.kind === 'tool_result') {
|
||||
const base = pendingWritesRef.current.get(ev.toolUseId);
|
||||
if (base) {
|
||||
pendingWritesRef.current.delete(ev.toolUseId);
|
||||
if (!ev.isError) {
|
||||
// Refresh first so FileWorkspace's file list (and the tab
|
||||
// body) sees the new content before we ask it to focus.
|
||||
void refreshProjectFiles().then(() => {
|
||||
requestOpenFile(base);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const appendContent = (delta: string) => {
|
||||
updateAssistant((prev) => ({ ...prev, content: prev.content + delta }));
|
||||
for (const ev of parser.feed(delta)) {
|
||||
if (ev.type === 'artifact:start') {
|
||||
liveHtml = '';
|
||||
setArtifact({ identifier: ev.identifier, title: ev.title, html: '' });
|
||||
} else if (ev.type === 'artifact:chunk') {
|
||||
liveHtml += ev.delta;
|
||||
setArtifact((prev) =>
|
||||
prev
|
||||
? { ...prev, html: liveHtml }
|
||||
: { identifier: ev.identifier, title: '', html: liveHtml },
|
||||
);
|
||||
} else if (ev.type === 'artifact:end') {
|
||||
setArtifact((prev) => (prev ? { ...prev, html: ev.fullContent } : null));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const systemPrompt = await composedSystemPrompt();
|
||||
|
||||
const handlers = {
|
||||
onDelta: appendContent,
|
||||
onAgentEvent: pushEvent,
|
||||
onDone: () => {
|
||||
for (const ev of parser.flush()) {
|
||||
if (ev.type === 'artifact:end') {
|
||||
setArtifact((prev) => (prev ? { ...prev, html: ev.fullContent } : null));
|
||||
}
|
||||
}
|
||||
updateAssistant((prev) => ({ ...prev, endedAt: Date.now() }));
|
||||
setStreaming(false);
|
||||
abortRef.current = null;
|
||||
// Persist the finished artifact to the project folder so it shows
|
||||
// up as a real tab (not just the synthetic "live" stream).
|
||||
setArtifact((prev) => {
|
||||
if (!prev || !prev.html) return prev;
|
||||
void persistArtifact(prev);
|
||||
return prev;
|
||||
});
|
||||
// Refetch the file list directly (rather than just bumping the
|
||||
// refresh signal) so we can diff against the pre-turn snapshot
|
||||
// and attach the new files to the assistant message as download
|
||||
// chips.
|
||||
void refreshProjectFiles().then((nextFiles) => {
|
||||
const produced = nextFiles.filter((f) => !beforeFileNames.has(f.name));
|
||||
setMessages((curr) => {
|
||||
const updated = curr.map((m) =>
|
||||
m.id === assistantId
|
||||
? produced.length > 0
|
||||
? { ...m, producedFiles: produced }
|
||||
: m
|
||||
: m,
|
||||
);
|
||||
const finalized = updated.find((m) => m.id === assistantId);
|
||||
if (finalized) persistMessage(finalized);
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
onProjectsRefresh();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
updateAssistant((prev) => ({ ...prev, endedAt: Date.now() }));
|
||||
setStreaming(false);
|
||||
abortRef.current = null;
|
||||
setMessages((curr) => {
|
||||
const finalized = curr.find((m) => m.id === assistantId);
|
||||
if (finalized) persistMessage(finalized);
|
||||
return curr;
|
||||
});
|
||||
void refreshProjectFiles();
|
||||
},
|
||||
};
|
||||
|
||||
if (config.mode === 'daemon') {
|
||||
if (!config.agentId) {
|
||||
handlers.onError(new Error('Pick a local agent first (top bar).'));
|
||||
return;
|
||||
}
|
||||
void streamViaDaemon({
|
||||
agentId: config.agentId,
|
||||
history: nextHistory,
|
||||
systemPrompt,
|
||||
signal: controller.signal,
|
||||
handlers,
|
||||
projectId: project.id,
|
||||
attachments: attachments.map((a) => a.path),
|
||||
});
|
||||
} else {
|
||||
pushEvent({ kind: 'status', label: 'requesting', detail: config.model });
|
||||
void streamMessage(config, systemPrompt, nextHistory, controller.signal, {
|
||||
onDelta: (delta) => {
|
||||
handlers.onDelta(delta);
|
||||
handlers.onAgentEvent({ kind: 'text', text: delta });
|
||||
},
|
||||
onDone: handlers.onDone,
|
||||
onError: handlers.onError,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
activeConversationId,
|
||||
messages,
|
||||
config,
|
||||
composedSystemPrompt,
|
||||
onTouchProject,
|
||||
project.id,
|
||||
projectFiles,
|
||||
refreshProjectFiles,
|
||||
persistMessage,
|
||||
onProjectsRefresh,
|
||||
],
|
||||
);
|
||||
|
||||
const persistArtifact = useCallback(
|
||||
async (art: Artifact) => {
|
||||
const baseName = (art.identifier || art.title || 'artifact')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60) || 'artifact';
|
||||
// Pick a name that doesn't collide with an existing project file.
|
||||
// The first run uses `<base>.html`; subsequent runs append `-2`, `-3`…
|
||||
// so prior artifacts aren't silently overwritten.
|
||||
const existing = new Set(projectFiles.map((f) => f.name));
|
||||
let fileName = `${baseName}.html`;
|
||||
let n = 2;
|
||||
while (existing.has(fileName) && savedArtifactRef.current !== fileName) {
|
||||
fileName = `${baseName}-${n}.html`;
|
||||
n += 1;
|
||||
}
|
||||
if (savedArtifactRef.current === fileName) return;
|
||||
savedArtifactRef.current = fileName;
|
||||
const file = await writeProjectTextFile(project.id, fileName, art.html);
|
||||
if (file) {
|
||||
setFilesRefresh((n) => n + 1);
|
||||
// Auto-open the freshly-persisted artifact as a tab so the user
|
||||
// sees it without an extra click. The Write-tool path already does
|
||||
// this for tool-emitted files; this handles the artifact-tag path.
|
||||
requestOpenFile(file.name);
|
||||
}
|
||||
},
|
||||
[project.id, projectFiles, requestOpenFile],
|
||||
);
|
||||
|
||||
const handleExportAsPptx = useCallback(
|
||||
(fileName: string) => {
|
||||
if (streaming) return;
|
||||
const baseTitle = fileName.replace(/\.html?$/i, '') || fileName;
|
||||
const prompt =
|
||||
`Export @${fileName} as an editable PPTX file titled "${baseTitle}".\n\n` +
|
||||
`Use a PPTX skill (e.g. python-pptx) to produce a real .pptx — one slide per ` +
|
||||
`top-level section/page in the HTML. Preserve text content, headings, and the ` +
|
||||
`general layout intent. Save the file directly into the current project folder ` +
|
||||
`(this conversation's working directory) as \`${baseTitle}.pptx\` so it shows ` +
|
||||
`up in the file list, and report the on-disk path when done.`;
|
||||
const attachment: ChatAttachment = {
|
||||
path: fileName,
|
||||
name: fileName,
|
||||
kind: 'file',
|
||||
};
|
||||
void handleSend(prompt, [attachment]);
|
||||
},
|
||||
[streaming, handleSend],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setStreaming(false);
|
||||
setMessages((curr) => {
|
||||
const next = curr.map((m) =>
|
||||
m.role === 'assistant' && m.endedAt === undefined
|
||||
? { ...m, endedAt: Date.now() }
|
||||
: m,
|
||||
);
|
||||
const finalized = next.find(
|
||||
(m) =>
|
||||
m.role === 'assistant' &&
|
||||
m.endedAt !== undefined &&
|
||||
!curr.find((x) => x.id === m.id && x.endedAt !== undefined),
|
||||
);
|
||||
if (finalized) persistMessage(finalized);
|
||||
return next;
|
||||
});
|
||||
}, [persistMessage]);
|
||||
|
||||
const handleNewConversation = useCallback(async () => {
|
||||
const fresh = await createConversation(project.id);
|
||||
if (!fresh) return;
|
||||
setConversations((curr) => [fresh, ...curr]);
|
||||
setActiveConversationId(fresh.id);
|
||||
}, [project.id]);
|
||||
|
||||
const handleSelectConversation = useCallback((id: string) => {
|
||||
setActiveConversationId(id);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConversation = useCallback(
|
||||
async (id: string) => {
|
||||
const ok = await deleteConversationApi(project.id, id);
|
||||
if (!ok) return;
|
||||
setConversations((curr) => {
|
||||
const next = curr.filter((c) => c.id !== id);
|
||||
if (next.length === 0) {
|
||||
// Re-seed so the project always has at least one conversation
|
||||
// to write into.
|
||||
void createConversation(project.id).then((fresh) => {
|
||||
if (fresh) {
|
||||
setConversations([fresh]);
|
||||
setActiveConversationId(fresh.id);
|
||||
}
|
||||
});
|
||||
} else if (id === activeConversationId) {
|
||||
setActiveConversationId(next[0]!.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[project.id, activeConversationId],
|
||||
);
|
||||
|
||||
const handleRenameConversation = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
const trimmed = title.trim() || null;
|
||||
setConversations((curr) =>
|
||||
curr.map((c) => (c.id === id ? { ...c, title: trimmed } : c)),
|
||||
);
|
||||
await patchConversation(project.id, id, { title: trimmed });
|
||||
},
|
||||
[project.id],
|
||||
);
|
||||
|
||||
const handleProjectRename = useCallback(
|
||||
(newName: string) => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed || trimmed === project.name) return;
|
||||
const updated: Project = { ...project, name: trimmed, updatedAt: Date.now() };
|
||||
onProjectChange(updated);
|
||||
void patchProject(project.id, { name: trimmed });
|
||||
},
|
||||
[project, onProjectChange],
|
||||
);
|
||||
|
||||
const projectMeta = useMemo(() => {
|
||||
const skill = skills.find((s) => s.id === project.skillId)?.name;
|
||||
const ds = designSystems.find((d) => d.id === project.designSystemId)?.title;
|
||||
return [skill, ds].filter(Boolean).join(' · ') || t('project.metaFreeform');
|
||||
}, [skills, designSystems, project.skillId, project.designSystemId, t]);
|
||||
|
||||
const isDeck = useMemo(
|
||||
() => skills.find((s) => s.id === project.skillId)?.mode === 'deck',
|
||||
[skills, project.skillId],
|
||||
);
|
||||
|
||||
// Hand the pending prompt to ChatPane exactly once. After the first render
|
||||
// we tell App to clear it so re-entering the project later doesn't reseed.
|
||||
const initialDraft = project.pendingPrompt;
|
||||
useEffect(() => {
|
||||
if (project.pendingPrompt) onClearPendingPrompt();
|
||||
}, [project.pendingPrompt, onClearPendingPrompt]);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="topbar">
|
||||
<div className="topbar-left">
|
||||
<button
|
||||
className="ghost back-btn"
|
||||
onClick={onBack}
|
||||
title={t('project.backToProjects')}
|
||||
aria-label={t('project.backToProjects')}
|
||||
>
|
||||
<Icon name="arrow-left" size={14} />
|
||||
</button>
|
||||
<span className="brand-mark" aria-hidden>
|
||||
<img src="/logo.svg" alt="" className="brand-mark-img" draggable={false} />
|
||||
</span>
|
||||
<div className="topbar-title">
|
||||
<span
|
||||
className="title editable"
|
||||
tabIndex={0}
|
||||
role="textbox"
|
||||
suppressContentEditableWarning
|
||||
contentEditable
|
||||
onBlur={(e) => handleProjectRename(e.currentTarget.textContent ?? '')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).blur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="meta">{projectMeta}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<AvatarMenu
|
||||
config={config}
|
||||
agents={agents}
|
||||
daemonLive={daemonLive}
|
||||
onModeChange={onModeChange}
|
||||
onAgentChange={onAgentChange}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onRefreshAgents={onRefreshAgents}
|
||||
onBack={onBack}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="split">
|
||||
<ChatPane
|
||||
// The conversation id is part of the key so switching conversations
|
||||
// resets internal scroll/draft state inside ChatPane and ChatComposer.
|
||||
key={activeConversationId ?? 'no-conv'}
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
error={error}
|
||||
projectId={project.id}
|
||||
projectFiles={projectFiles}
|
||||
projectFileNames={projectFileNames}
|
||||
onEnsureProject={handleEnsureProject}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
onRequestOpenFile={requestOpenFile}
|
||||
initialDraft={initialDraft}
|
||||
onSubmitForm={(text) => {
|
||||
if (streaming) return;
|
||||
void handleSend(text, []);
|
||||
}}
|
||||
onNewConversation={handleNewConversation}
|
||||
conversations={conversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
onRenameConversation={handleRenameConversation}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
<FileWorkspace
|
||||
projectId={project.id}
|
||||
files={projectFiles}
|
||||
onRefreshFiles={() => {
|
||||
void refreshProjectFiles();
|
||||
}}
|
||||
isDeck={isDeck}
|
||||
onExportAsPptx={handleExportAsPptx}
|
||||
streaming={streaming}
|
||||
openRequest={openRequest}
|
||||
tabsState={openTabsState}
|
||||
onTabsStateChange={persistTabsState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
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) {
|
||||
setAnswers((prev) => {
|
||||
const current = Array.isArray(prev[id]) ? (prev[id] as string[]) : [];
|
||||
const has = current.includes(option);
|
||||
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;
|
||||
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 ready = 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);
|
||||
return (
|
||||
<label key={opt} className={`qf-chip${on ? ' qf-chip-on' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={opt}
|
||||
checked={on}
|
||||
disabled={locked}
|
||||
onChange={() => toggleCheckbox(q.id, opt)}
|
||||
/>
|
||||
<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,290 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { LOCALE_LABEL, LOCALES, useI18n } from '../i18n';
|
||||
import type { Locale } from '../i18n';
|
||||
import { AgentIcon } from './AgentIcon';
|
||||
import type { AgentInfo, AppConfig, ExecMode } from '../types';
|
||||
|
||||
interface Props {
|
||||
initial: AppConfig;
|
||||
agents: AgentInfo[];
|
||||
daemonLive: boolean;
|
||||
welcome?: boolean;
|
||||
onSave: (cfg: AppConfig) => void;
|
||||
onClose: () => void;
|
||||
onRefreshAgents: () => void;
|
||||
}
|
||||
|
||||
const SUGGESTED_MODELS = [
|
||||
'claude-opus-4-5',
|
||||
'claude-sonnet-4-5',
|
||||
'claude-haiku-4-5',
|
||||
];
|
||||
|
||||
export function SettingsDialog({
|
||||
initial,
|
||||
agents,
|
||||
daemonLive,
|
||||
welcome,
|
||||
onSave,
|
||||
onClose,
|
||||
onRefreshAgents,
|
||||
}: Props) {
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const [cfg, setCfg] = useState<AppConfig>(initial);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
// If the daemon goes offline mid-edit, force API mode so the UI doesn't
|
||||
// pretend Local CLI is selectable.
|
||||
useEffect(() => {
|
||||
if (!daemonLive && cfg.mode === 'daemon') {
|
||||
setCfg((c) => ({ ...c, mode: 'api' }));
|
||||
}
|
||||
}, [daemonLive, cfg.mode]);
|
||||
|
||||
const installedCount = useMemo(
|
||||
() => agents.filter((a) => a.available).length,
|
||||
[agents],
|
||||
);
|
||||
|
||||
const setMode = (mode: ExecMode) => setCfg((c) => ({ ...c, mode }));
|
||||
|
||||
const canSave =
|
||||
cfg.mode === 'daemon'
|
||||
? Boolean(cfg.agentId && agents.find((a) => a.id === cfg.agentId)?.available)
|
||||
: Boolean(cfg.apiKey.trim() && cfg.model.trim() && cfg.baseUrl.trim());
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="modal modal-settings"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="modal-head">
|
||||
{welcome ? (
|
||||
<>
|
||||
<span className="kicker">{t('settings.welcomeKicker')}</span>
|
||||
<h2>{t('settings.welcomeTitle')}</h2>
|
||||
<p className="subtitle">{t('settings.welcomeSubtitle')}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="kicker">{t('settings.kicker')}</span>
|
||||
<h2>{t('settings.title')}</h2>
|
||||
<p className="subtitle">{t('settings.subtitle')}</p>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="seg-control"
|
||||
role="tablist"
|
||||
aria-label={t('settings.modeAria')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={cfg.mode === 'daemon'}
|
||||
className={'seg-btn' + (cfg.mode === 'daemon' ? ' active' : '')}
|
||||
disabled={!daemonLive}
|
||||
onClick={() => setMode('daemon')}
|
||||
title={
|
||||
daemonLive
|
||||
? t('settings.modeDaemonHelp')
|
||||
: t('settings.modeDaemonOffline')
|
||||
}
|
||||
>
|
||||
<span className="seg-title">{t('settings.modeDaemon')}</span>
|
||||
<span className="seg-meta">
|
||||
{daemonLive
|
||||
? t('settings.modeDaemonInstalledMeta', { count: installedCount })
|
||||
: t('settings.modeDaemonOfflineMeta')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={cfg.mode === 'api'}
|
||||
className={'seg-btn' + (cfg.mode === 'api' ? ' active' : '')}
|
||||
onClick={() => setMode('api')}
|
||||
>
|
||||
<span className="seg-title">{t('settings.modeApi')}</span>
|
||||
<span className="seg-meta">{t('settings.modeApiMeta')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cfg.mode === 'daemon' ? (
|
||||
<section className="settings-section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h3>{t('settings.codeAgent')}</h3>
|
||||
<p className="hint">{t('settings.codeAgentHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost icon-btn"
|
||||
onClick={onRefreshAgents}
|
||||
title={t('settings.rescanTitle')}
|
||||
>
|
||||
{t('settings.rescan')}
|
||||
</button>
|
||||
</div>
|
||||
{agents.length === 0 ? (
|
||||
<div className="empty-card">
|
||||
{t('settings.noAgentsDetected')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="agent-grid">
|
||||
{agents.map((a) => {
|
||||
const active = cfg.agentId === a.id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
className={
|
||||
'agent-card' +
|
||||
(active ? ' active' : '') +
|
||||
(a.available ? '' : ' disabled')
|
||||
}
|
||||
onClick={() =>
|
||||
a.available && setCfg((c) => ({ ...c, agentId: a.id }))
|
||||
}
|
||||
disabled={!a.available}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<AgentIcon id={a.id} size={40} />
|
||||
<div className="agent-card-body">
|
||||
<div className="agent-card-name">{a.name}</div>
|
||||
<div className="agent-card-meta">
|
||||
{a.available ? (
|
||||
a.version ? (
|
||||
<span title={a.path ?? ''}>{a.version}</span>
|
||||
) : (
|
||||
<span title={a.path ?? ''}>
|
||||
{t('common.installed')}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="muted">
|
||||
{t('common.notInstalled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{a.available ? (
|
||||
<span
|
||||
className={'status-dot' + (active ? ' active' : '')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section className="settings-section">
|
||||
<div className="section-head">
|
||||
<h3>{t('settings.apiSection')}</h3>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="field-label">{t('settings.apiKey')}</span>
|
||||
<div className="field-row">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="sk-ant-..."
|
||||
value={cfg.apiKey}
|
||||
onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost icon-btn"
|
||||
onClick={() => setShowApiKey((v) => !v)}
|
||||
title={
|
||||
showApiKey ? t('settings.hideKey') : t('settings.showKey')
|
||||
}
|
||||
>
|
||||
{showApiKey ? t('settings.hide') : t('settings.show')}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">{t('settings.model')}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={cfg.model}
|
||||
list="suggested-models"
|
||||
onChange={(e) => setCfg({ ...cfg, model: e.target.value })}
|
||||
/>
|
||||
<datalist id="suggested-models">
|
||||
{SUGGESTED_MODELS.map((m) => (
|
||||
<option value={m} key={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">{t('settings.baseUrl')}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={cfg.baseUrl}
|
||||
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<p className="hint">{t('settings.apiHint')}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h3>{t('settings.language')}</h3>
|
||||
<p className="hint">{t('settings.languageHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="seg-control"
|
||||
role="tablist"
|
||||
aria-label={t('settings.language')}
|
||||
>
|
||||
{LOCALES.map((code) => {
|
||||
const active = locale === code;
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={'seg-btn' + (active ? ' active' : '')}
|
||||
onClick={() => setLocale(code as Locale)}
|
||||
>
|
||||
<span className="seg-title">{LOCALE_LABEL[code]}</span>
|
||||
<span className="seg-meta">{code}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="modal-foot">
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
{welcome ? t('settings.skipForNow') : t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
onSave(cfg);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{welcome ? t('settings.getStarted') : t('common.save')}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,354 @@
|
||||
/**
|
||||
* Renders a single tool_use (optionally paired with its tool_result) as an
|
||||
* inline card in the assistant message stream. Tools we recognize get
|
||||
* specialized layouts; unknown ones fall back to a generic command/output
|
||||
* card.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import type { AgentEvent } from '../types';
|
||||
|
||||
interface Props {
|
||||
use: Extract<AgentEvent, { kind: 'tool_use' }>;
|
||||
result?: Extract<AgentEvent, { kind: 'tool_result' }> | undefined;
|
||||
// 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, projectFileNames, onRequestOpenFile }: Props) {
|
||||
const name = use.name;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface TodoItem {
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
activeForm?: string;
|
||||
}
|
||||
|
||||
function TodoCard({ input }: { input: unknown }) {
|
||||
const t = useT();
|
||||
const todos = parseTodos(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 parseTodos(input: unknown): TodoItem[] {
|
||||
if (!input || typeof input !== 'object') return [];
|
||||
const obj = input as { todos?: unknown };
|
||||
if (!Array.isArray(obj.todos)) return [];
|
||||
return obj.todos
|
||||
.map((t): TodoItem | null => {
|
||||
if (!t || typeof t !== 'object') return null;
|
||||
const r = t as Record<string, unknown>;
|
||||
const content = typeof r.content === 'string' ? r.content : '';
|
||||
if (!content) return null;
|
||||
const status = (r.status === 'completed' || r.status === 'in_progress')
|
||||
? r.status
|
||||
: 'pending';
|
||||
return {
|
||||
content,
|
||||
status,
|
||||
activeForm: typeof r.activeForm === 'string' ? r.activeForm : undefined,
|
||||
};
|
||||
})
|
||||
.filter((x): x is TodoItem => x !== null);
|
||||
}
|
||||
|
||||
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) + '…';
|
||||
}
|
||||
Reference in New Issue
Block a user