Files
fusion/packages/dashboard/app/components/CreateRoomModal.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

236 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { fetchAgents } from "../api";
import type { Agent } from "@fusion/core";
import { AgentAvatar } from "./AgentAvatar";
import "./CreateRoomModal.css";
export interface RoomDraft {
/** Slack-style display name without leading "#" (e.g. "engineering"). Lowercase. */
name: string;
/** Display form including the leading "#" (e.g. "#engineering"). */
displayName: string;
/** Agent IDs selected as initial members. */
memberAgentIds: string[];
}
export function validateRoomName(input: string, existingRoomNames: string[] = [], t?: (key: string, defaultValue: string) => string): { ok: true; name: string } | { ok: false; error: string } {
const raw = input.trim().replace(/^#/, "");
const getError = (key: string, defaultValue: string) => t ? t(key, defaultValue) : defaultValue;
if (!raw) return { ok: false, error: getError("createRoom.nameRequired", "Room name is required.") };
if (/[A-Z]/.test(raw)) return { ok: false, error: getError("createRoom.lowercase", "Use lowercase letters only.") };
const stripped = raw.toLowerCase();
if (stripped.length > 80) return { ok: false, error: getError("createRoom.maxLength", "Room names can be at most 80 characters.") };
if (!/^[a-z0-9_-]+$/.test(stripped)) return { ok: false, error: getError("createRoom.validChars", "Use lowercase letters, numbers, hyphens, or underscores only.") };
if (/^[-_]|[-_]$/.test(stripped)) return { ok: false, error: getError("createRoom.noEdgeChars", "Room names cannot start or end with a hyphen or underscore.") };
if (existingRoomNames.some((name) => name.toLowerCase() === stripped)) {
return { ok: false, error: getError("createRoom.duplicate", "A room with this name already exists.") };
}
return { ok: true, name: stripped };
}
interface CreateRoomModalProps {
isOpen: boolean;
onClose: () => void;
onCreate: (draft: RoomDraft) => void | Promise<void>;
projectId?: string;
existingRoomNames?: string[];
}
export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existingRoomNames = [] }: CreateRoomModalProps) {
const { t } = useTranslation("app");
const [rawName, setRawName] = useState("");
const [agents, setAgents] = useState<Agent[]>([]);
const [search, setSearch] = useState("");
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([]);
const [loadingAgents, setLoadingAgents] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setLoadingAgents(true);
setSubmitError(null);
fetchAgents(undefined, projectId)
.then((result) => setAgents(result))
.catch(() => {
setAgents([]);
setSubmitError(t("createRoom.failedLoadAgents", "Failed to load agents."));
})
.finally(() => setLoadingAgents(false));
}, [isOpen, projectId]);
useEffect(() => {
if (!isOpen) {
setRawName("");
setSearch("");
setSelectedAgentIds([]);
setSubmitError(null);
setIsSubmitting(false);
return;
}
const frame = window.requestAnimationFrame(() => nameInputRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
if (isOpen) return;
previousFocusRef.current?.focus();
}, [isOpen]);
const validation = useMemo(() => validateRoomName(rawName, existingRoomNames, t), [rawName, existingRoomNames, t]);
const filteredAgents = useMemo(() => {
const normalized = search.trim().toLowerCase();
if (!normalized) return agents;
return agents.filter((agent) => agent.name.toLowerCase().includes(normalized));
}, [agents, search]);
const selectedAgents = useMemo(
() => agents.filter((agent) => selectedAgentIds.includes(agent.id)),
[agents, selectedAgentIds],
);
const canSubmit = validation.ok && selectedAgentIds.length > 0 && !isSubmitting && !loadingAgents;
if (!isOpen) return null;
const toggleAgent = (id: string) => {
if (isSubmitting) return;
setSelectedAgentIds((prev) => (prev.includes(id) ? prev.filter((current) => current !== id) : [...prev, id]));
};
const handleSubmit = async () => {
if (!validation.ok) {
setSubmitError(validation.error);
return;
}
if (selectedAgentIds.length === 0) {
setSubmitError(t("createRoom.selectMember", "Select at least one member."));
return;
}
setSubmitError(null);
setIsSubmitting(true);
try {
await onCreate({
name: validation.name,
displayName: `#${validation.name}`,
memberAgentIds: selectedAgentIds,
});
onClose();
} catch (error) {
setSubmitError(error instanceof Error ? error.message : t("createRoom.failedCreate", "Failed to create room."));
} finally {
setIsSubmitting(false);
}
};
return createPortal(
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()}>
<div className="modal modal-lg create-room-modal" role="dialog" aria-modal="true" aria-label={t("createRoom.title", "Create room")} onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>{t("createRoom.title", "Create room")}</h3>
<button type="button" className="modal-close" aria-label={t("actions.close", "Close")} onClick={onClose}>×</button>
</div>
<div className="form-group create-room-modal-name-group">
<label htmlFor="create-room-name">{t("createRoom.nameLabel", "Room name")}</label>
<div className="create-room-modal-name-field">
<span aria-hidden="true" className="create-room-modal-name-hash">#</span>
<input
ref={nameInputRef}
id="create-room-name"
className="input"
value={rawName}
disabled={isSubmitting}
onChange={(event) => {
const normalized = event.target.value.replace(/^#/, "").replace(/\s+/g, "-").toLowerCase();
setRawName(normalized);
}}
/>
</div>
{!validation.ok && <div className="form-error">{validation.error}</div>}
</div>
<div className="form-group">
<label htmlFor="create-room-member-search">{t("createRoom.members", "Members")}</label>
<input
id="create-room-member-search"
className="input"
placeholder={t("createRoom.searchAgents", "Search agents")}
value={search}
disabled={isSubmitting}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
{selectedAgents.length > 0 && (
<div className="create-room-modal-selected" data-testid="create-room-selected-chips">
{selectedAgents.map((agent) => (
<button
key={agent.id}
type="button"
className="btn btn-sm create-room-modal-chip"
onClick={() => toggleAgent(agent.id)}
disabled={isSubmitting}
>
{agent.name} ×
</button>
))}
</div>
)}
<div className="create-room-modal-member-list" data-testid="create-room-member-list">
{loadingAgents ? (
<div className="create-room-modal-empty">{t("createRoom.loadingAgents", "Loading agents...")}</div>
) : filteredAgents.length === 0 ? (
<div className="create-room-modal-empty">
{agents.length === 0 ? t("createRoom.noAgents", "No agents in this project yet.") : t("createRoom.noMatch", "No agents match your search.")}
</div>
) : (
filteredAgents.map((agent) => {
const selected = selectedAgentIds.includes(agent.id);
return (
<button
key={agent.id}
type="button"
className={`create-room-modal-member-row${selected ? " create-room-modal-member-row--selected" : ""}`}
onClick={() => toggleAgent(agent.id)}
disabled={isSubmitting}
>
<AgentAvatar agent={agent} size={20} />
<span>{agent.name}</span>
<span className="create-room-modal-member-role">{agent.role}</span>
</button>
);
})
)}
</div>
{submitError && <div className="form-group"><div className="form-error">{submitError}</div></div>}
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose} disabled={isSubmitting}>{t("actions.cancel", "Cancel")}</button>
<button type="button" className="btn btn-primary" onClick={() => void handleSubmit()} disabled={!canSubmit}>
{isSubmitting ? t("createRoom.creating", "Creating...") : t("createRoom.create", "Create room")}
</button>
</div>
</div>
</div>,
document.body,
);
}