Files
fusion/packages/dashboard/app/components/AgentGenerationModal.tsx
gsxdsm 1e49494bac feat(i18n): full-sweep string migration — 5,930 keys across 5 locales (#1352)
Migration (multi-agent sweep over 216 files, 60 batches):
- Every user-visible dashboard + TUI string moved to t() with the exact
  English inline default (en rendering byte-identical)
- Catalogs merged from per-batch fragments: en/zh-CN/zh-TW/fr/es now
  carry ~5,930 keys each across common/app/errors/cli namespaces;
  CLI bundles regenerated (6 locales incl. ko)

Integration fixes:
- 18 type errors: reserved {{count}} interpolations renamed, malformed
  plural call, hand-rolled t-param types replaced with TFunction<"app">
- 23 lint errors: superseded label constants/helpers removed
- ExecutorStatusBar hook-order violation (keyboard-open early return
  moved below hooks)
- TUI tests wrapped in I18nextProvider (uninitialized fallback renders
  literal {{placeholders}}); dashboard vitest.setup boots a minimal en
  i18next instance for the same reason

Known WIP (next commits): ~457 residual strings across 50 batches,
Korean drafts for swept keys, and a dashboard test-suite pass that is
still being stabilized (~283 failures under investigation — fake-timer
waitFor interaction, likely stale node_modules vs merged lockfile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:06:53 -07:00

335 lines
12 KiB
TypeScript

import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { AgentGenerationSpec } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import {
startAgentGeneration,
generateAgentSpec,
cancelAgentGeneration,
} from "../api";
interface AgentGenerationModalProps {
isOpen: boolean;
onClose: () => void;
onGenerated: (spec: AgentGenerationSpec) => void;
projectId?: string;
}
type ViewState =
| { type: "input" }
| { type: "loading" }
| { type: "preview"; spec: AgentGenerationSpec; sessionId: string };
const MIN_ROLE_LENGTH = 3;
const MAX_ROLE_LENGTH = 1000;
/**
* Modal for AI-assisted agent creation.
*
* The user enters a role description and the system generates a complete
* agent specification including title, icon, system prompt, and suggested
* runtime configuration.
*
* Follows the same general modal pattern as PlanningModeModal but simplified
* (no multi-step Q&A — single input → single generation result).
*/
export function AgentGenerationModal({
isOpen,
onClose,
onGenerated,
projectId,
}: AgentGenerationModalProps) {
const { t } = useTranslation("app");
useMobileScrollLock(isOpen);
const [roleDescription, setRoleDescription] = useState("");
const [view, setView] = useState<ViewState>({ type: "input" });
const [error, setError] = useState<string | null>(null);
const [systemPromptExpanded, setSystemPromptExpanded] = useState(false);
const sessionIdRef = useRef<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Focus textarea on open
useEffect(() => {
if (isOpen && view.type === "input") {
textareaRef.current?.focus();
}
}, [isOpen, view.type]);
// Cleanup session on unmount or modal close
useEffect(() => {
if (!isOpen && sessionIdRef.current) {
const sid = sessionIdRef.current;
sessionIdRef.current = null;
cancelAgentGeneration(sid, projectId).catch(() => {
/* ignore cleanup errors */
});
}
}, [isOpen, projectId]);
// Handle escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleCancel();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
const handleCancel = useCallback(() => {
// Cleanup session server-side
if (sessionIdRef.current) {
const sid = sessionIdRef.current;
sessionIdRef.current = null;
cancelAgentGeneration(sid, projectId).catch(() => {
/* ignore cleanup errors */
});
}
setRoleDescription("");
setView({ type: "input" });
setError(null);
setSystemPromptExpanded(false);
onClose();
}, [onClose, projectId]);
const handleGenerate = useCallback(async () => {
if (!roleDescription.trim() || roleDescription.trim().length < MIN_ROLE_LENGTH) return;
setError(null);
setView({ type: "loading" });
try {
// Phase 1: Start session
const { sessionId } = await startAgentGeneration(roleDescription.trim(), projectId);
sessionIdRef.current = sessionId;
// Phase 2: Generate spec (single combined loading state)
const { spec } = await generateAgentSpec(sessionId, projectId);
setView({ type: "preview", spec, sessionId });
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to generate agent specification";
// Handle rate limit errors with user-friendly message
if (message.includes("429") || message.toLowerCase().includes("rate limit")) {
setError(t("agents.generation.rateLimited", "Too many requests. Please wait a moment and try again."));
} else {
setError(message);
}
setView({ type: "input" });
sessionIdRef.current = null;
}
}, [roleDescription, projectId]);
const handleRegenerate = useCallback(async () => {
// Cancel existing session and create a new one
if (sessionIdRef.current) {
const oldSid = sessionIdRef.current;
sessionIdRef.current = null;
try {
await cancelAgentGeneration(oldSid, projectId);
} catch {
/* ignore */
}
}
// Re-run generation with same role description
await handleGenerate();
}, [handleGenerate, projectId]);
const handleUseSpec = useCallback(() => {
if (view.type !== "preview") return;
// Clear session ref so we don't cancel on close (we're using the spec)
sessionIdRef.current = null;
onGenerated(view.spec);
// Reset and close
setRoleDescription("");
setView({ type: "input" });
setError(null);
setSystemPromptExpanded(false);
onClose();
}, [view, onGenerated, onClose]);
if (!isOpen) return null;
const canGenerate =
roleDescription.trim().length >= MIN_ROLE_LENGTH &&
roleDescription.trim().length <= MAX_ROLE_LENGTH;
return (
<div
className="agent-dialog-overlay"
onClick={(e) => {
if (e.target === e.currentTarget) handleCancel();
}}
>
<div
className="agent-dialog"
role="dialog"
aria-modal="true"
aria-label="Generate agent with AI"
>
{/* Header */}
<div className="agent-dialog-header">
<span className="agent-dialog-header-title">
<span className="agent-dialog-header-sparkle">✨</span>
{t("agents.generation.title", "Generate Agent")}
</span>
<button
className="modal-close"
onClick={handleCancel}
aria-label={t("actions.close", "Close")}
>
&times;
</button>
</div>
{/* Body */}
<div className="agent-dialog-body">
{error && <div className="agent-dialog-error-banner">{error}</div>}
{view.type === "input" && (
<div>
<p className="agent-dialog-info">
{t("agents.generation.info", "Describe your agent's role and the AI will generate a complete specification including system prompt, suggested configuration, and more.")}
</p>
<div className="agent-dialog-field">
<label htmlFor="agent-role-description">{t("agents.generation.roleLabel", "Role Description")}</label>
<textarea
ref={textareaRef}
id="agent-role-description"
className="input agent-dialog-textarea"
rows={4}
placeholder={t("agents.generation.rolePlaceholder", 'e.g. "Senior frontend code reviewer who specializes in React accessibility"')}
value={roleDescription}
onChange={(e) => setRoleDescription(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && canGenerate) {
e.preventDefault();
handleGenerate();
}
}}
maxLength={MAX_ROLE_LENGTH}
aria-describedby="role-description-hint"
/>
<div
id="role-description-hint"
className="agent-dialog-hint"
>
<span>{t("agents.generation.roleHint", "Describe what your agent should do")}</span>
<span>
{roleDescription.length}/{MAX_ROLE_LENGTH}
</span>
</div>
</div>
</div>
)}
{view.type === "loading" && (
<div className="agent-dialog-loading-center">
<div className="agent-dialog-spinner spin" />
<p className="agent-dialog-loading-text">
{t("agents.generation.loading", "Generating agent specification...")}
</p>
</div>
)}
{view.type === "preview" && (
<div>
<div className="agent-dialog-summary agent-dialog-summary--spaced">
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
{t("agents.generation.previewTitle", "Title")}
</span>
<span className="agent-dialog-summary-row-value">
{view.spec.icon} {view.spec.title}
</span>
</div>
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
{t("agents.generation.previewRole", "Role")}
</span>
<span>{view.spec.role}</span>
</div>
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
{t("agents.generation.previewDescription", "Description")}
</span>
<span className="agent-dialog-summary-row-value agent-dialog-summary-row-value--body">{view.spec.description}</span>
</div>
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
{t("agents.generation.previewThinking", "Thinking")}
</span>
<span className="agent-dialog-summary-row-value agent-dialog-summary-row-value--capitalize">
{view.spec.thinkingLevel}
</span>
</div>
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
{t("agents.generation.previewMaxTurns", "Max Turns")}
</span>
<span>{view.spec.maxTurns}</span>
</div>
</div>
{/* System prompt preview */}
<div className="agent-dialog-field">
<label>
{t("agents.generation.systemPrompt", "System Prompt")}
<button
type="button"
className="agent-dialog-expand-btn"
onClick={() => setSystemPromptExpanded(!systemPromptExpanded)}
>
{systemPromptExpanded ? t("agents.generation.collapse", "Collapse") : t("agents.generation.expand", "Expand")}
</button>
</label>
<div
className={`agent-generation-prompt-box${systemPromptExpanded ? "" : " agent-generation-prompt-box--collapsed"}`}
>
{view.spec.systemPrompt}
{!systemPromptExpanded &&
view.spec.systemPrompt.length > 500 && (
<div className="agent-generation-prompt-fade" />
)}
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="agent-dialog-footer">
<button className="btn" onClick={handleCancel}>
{t("actions.cancel", "Cancel")}
</button>
{view.type === "input" && (
<button
className="btn btn-task-create"
onClick={() => void handleGenerate()}
disabled={!canGenerate}
>
{t("agents.generation.generate", "Generate")}
</button>
)}
{view.type === "preview" && (
<>
<button
className="btn"
onClick={() => void handleRegenerate()}
>
{t("agents.generation.regenerate", "Regenerate")}
</button>
<button className="btn btn-task-create" onClick={handleUseSpec}>
{t("agents.generation.useThis", "Use This")}
</button>
</>
)}
</div>
</div>
</div>
);
}