diff --git a/src/components/ControlBar.tsx b/src/components/ControlBar.tsx
index b7305aa..40fdf2f 100644
--- a/src/components/ControlBar.tsx
+++ b/src/components/ControlBar.tsx
@@ -11,10 +11,110 @@ import {
getNotificationBackgroundOnly,
setNotificationBackgroundOnly,
} from "../lib/notificationSound";
+import { useLocale } from "@/components/LocaleProvider";
+
+const COPY = {
+ en: {
+ server: {
+ title: "Server",
+ stop: "Stop",
+ confirmStop: "Confirm Stop?",
+ restart: "Restart",
+ resetAgents: "Reset Agents",
+ healthGaveUp: "AC auto-restart failed 3x — manual restart required",
+ healthRestarted: (time: string) => `AC auto-restarted at ${time}`,
+ stopped: "Stopped",
+ failed: "Failed",
+ error: "Error",
+ acRestarted: (pid: number) => `AC restarted (PID: ${pid}) — resetting agents...`,
+ acAndAgentsRestarted: (restarted: number) => `AC + ${restarted} agent${restarted !== 1 ? "s" : ""} restarted`,
+ agentResetFailed: "AC restarted — agent reset failed",
+ resetResult: (restarted: number, total: number) => `Reset — ${restarted} of ${total} agent${total !== 1 ? "s" : ""} restarted`,
+ },
+ system: {
+ keepAwake: "Keep Mac Awake",
+ keepAwakeAbout: "About Keep Mac Awake",
+ keepAwakeHelp: <>
Keep Mac Awake runs macOS
caffeinate to stop the screen, disk, and system idle timers from sleeping your Mac during an overnight run. Make sure the laptop is plugged in — caffeinate blocks sleep but not battery drain.>,
+ autoAwakeOn: "Auto-awake ON: caffeinate starts/stops with batch lifecycle",
+ autoAwakeOff: "Auto-awake OFF: manual Start/Stop only",
+ awakeFor: (time: string) => `Awake for ${time} more — keep Mac plugged in`,
+ awakeIndefinitely: "Awake indefinitely — keep Mac plugged in",
+ awakeDesc: "Prevents your Mac from sleeping during overnight runs.",
+ awake: "Awake",
+ start: "Start",
+ on: "on",
+ sound: "Notification Sound",
+ soundAbout: "About Notification Sound",
+ soundHelp: <>
Notification Sound plays a brief chime when an agent posts a new message (not your own sends, not system events). Sound choice picks one of the bundled chimes. Background-only mode suppresses the chime while the tab is focused — ding only when you're looking elsewhere. All prefs persist in localStorage.>,
+ soundDesc: "Plays a chime when an agent posts a new message.",
+ soundLabel: "Sound",
+ soundBgOnly: "Only when tab is in background",
+ awakeStatusActive: "Batch active — auto-started caffeinate.",
+ awakeStatusComplete: "Batch complete — awake paused.",
+ awakeStatusNew: "New batch detected — auto-started caffeinate.",
+ keepAwakeModalTitle: "Keep Awake",
+ keepAwakeModalAbout: "About Keep Awake",
+ keepAwakeModalHelp: <>
Keep Awake prevents your Mac from sleeping for the duration you set. Use this when you want agents to keep working overnight.
Under the hood, this runs macOS's
caffeinate command. While it's active your screen, disk, and system idle timers are all paused — make sure your Mac is
plugged in to avoid draining the battery.>,
+ makeSurePluggedIn: "Make sure Mac is plugged in",
+ for: "for",
+ hours: "hours",
+ untilStopped: "Until stopped (no expiry)",
+ },
+ },
+ ko: {
+ server: {
+ title: "서버",
+ stop: "중지",
+ confirmStop: "정말 중지?",
+ restart: "재시작",
+ resetAgents: "에이전트 초기화",
+ healthGaveUp: "AC 자동 재시작 3회 실패 — 수동 재시작이 필요합니다",
+ healthRestarted: (time: string) => `AC가 ${time}에 자동 재시작되었습니다`,
+ stopped: "중지됨",
+ failed: "실패",
+ error: "오류",
+ acRestarted: (pid: number) => `AC 재시작됨 (PID: ${pid}) — 에이전트 초기화 중...`,
+ acAndAgentsRestarted: (restarted: number) => `AC 및 ${restarted}개 에이전트 재시작됨`,
+ agentResetFailed: "AC 재시작됨 — 에이전트 초기화 실패",
+ resetResult: (restarted: number, total: number) => `초기화 완료 — ${total}개 중 ${restarted}개 에이전트 재시작됨`,
+ },
+ system: {
+ keepAwake: "Mac 절전 방지",
+ keepAwakeAbout: "Mac 절전 방지 정보",
+ keepAwakeHelp: <>
Mac 절전 방지 는 macOS의
caffeinate를 실행해 야간 작업 중 Mac이 절전 상태로 들어가는 것을 막습니다. 충전기를 연결해 두세요. caffeinate는 절전은 막지만 배터리 소모를 막아주지는 않습니다.>,
+ autoAwakeOn: "자동 절전 방지 켬: 배치 상태에 따라 caffeinate 시작/종료",
+ autoAwakeOff: "자동 절전 방지 끔: 수동 시작/종료만 가능",
+ awakeFor: (time: string) => `앞으로 ${time} 동안 절전 방지 — 전원을 연결해 두세요`,
+ awakeIndefinitely: "무기한 절전 방지 중 — 전원을 연결해 두세요",
+ awakeDesc: "야간 작업 중 Mac이 잠들지 않도록 합니다.",
+ awake: "절전 방지 중",
+ start: "시작",
+ on: "켬",
+ sound: "알림음",
+ soundAbout: "알림음 정보",
+ soundHelp: <>
알림음 은 에이전트가 새 메시지를 보낼 때 짧은 알림음을 재생합니다. 내 메시지나 시스템 이벤트에는 울리지 않습니다. 사운드 선택으로 내장 알림음 중 하나를 고를 수 있고, 백그라운드 전용 모드는 탭이 포커스된 동안에는 알림음을 막습니다. 모든 설정은 localStorage에 저장됩니다.>,
+ soundDesc: "에이전트가 새 메시지를 보낼 때 알림음을 재생합니다.",
+ soundLabel: "알림음",
+ soundBgOnly: "탭이 백그라운드에 있을 때만",
+ awakeStatusActive: "배치 실행 중 — caffeinate를 자동으로 시작했습니다.",
+ awakeStatusComplete: "배치 완료 — 절전 방지를 일시 중단했습니다.",
+ awakeStatusNew: "새 배치 감지 — caffeinate를 자동으로 시작했습니다.",
+ keepAwakeModalTitle: "절전 방지",
+ keepAwakeModalAbout: "절전 방지 정보",
+ keepAwakeModalHelp: <>
절전 방지 는 설정한 시간 동안 Mac이 절전 상태로 들어가는 것을 막습니다. 에이전트가 밤새 작업하게 하려면 이 기능을 사용하세요.
내부적으로는 macOS의
caffeinate 명령을 실행합니다. 실행 중에는 화면, 디스크, 시스템 유휴 타이머가 모두 정지됩니다. 배터리 소모를 막으려면 Mac에
전원을 연결 해 두세요.>,
+ makeSurePluggedIn: "전원을 연결해 두세요",
+ for: "시간:",
+ hours: "시간",
+ untilStopped: "중지할 때까지 (만료 없음)",
+ },
+ },
+} as const;
// ─── Server Controls ─────────────────────────────────────────────────────────
function ServerSection({ projectId }: { projectId: string }) {
+ const { locale } = useLocale();
+ const t = COPY[locale].server;
const [loading, setLoading] = useState
(null);
const [feedback, setFeedback] = useState(null);
const [confirmStop, setConfirmStop] = useState(false);
@@ -29,12 +129,12 @@ function ServerSection({ projectId }: { projectId: string }) {
.then((d) => {
if (!d) { setHealthNote(null); return; }
if (d.autoRestart?.gaveUp) {
- setHealthNote("AC auto-restart failed 3x — manual restart required");
+ setHealthNote(t.healthGaveUp);
} else if (d.autoRestart?.lastRestart) {
const ago = Math.round((Date.now() - d.autoRestart.lastRestart) / 1000);
if (ago < 300) {
const time = new Date(d.autoRestart.lastRestart).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
- setHealthNote(`AC auto-restarted at ${time}`);
+ setHealthNote(t.healthRestarted(time));
} else {
setHealthNote(null);
}
@@ -47,7 +147,7 @@ function ServerSection({ projectId }: { projectId: string }) {
pollHealth();
const interval = setInterval(pollHealth, 30000);
return () => clearInterval(interval);
- }, [projectId]);
+ }, [projectId, t]);
const clearFeedback = () => {
setTimeout(() => setFeedback(null), 3000);
@@ -73,9 +173,9 @@ function ServerSection({ projectId }: { projectId: string }) {
{ method: "POST" }
);
const d = await r.json();
- setFeedback(d.ok ? "Stopped" : "Failed");
+ setFeedback(d.ok ? t.stopped : t.failed);
} catch {
- setFeedback("Error");
+ setFeedback(t.error);
}
setLoading(null);
clearFeedback();
@@ -90,7 +190,7 @@ function ServerSection({ projectId }: { projectId: string }) {
);
const d = await r.json();
if (d.ok && d.pid) {
- setFeedback(`AC restarted (PID: ${d.pid}) — resetting agents...`);
+ setFeedback(t.acRestarted(d.pid));
// #417: After AC restart, also reset all agents so they get
// fresh MCP tokens. Without this, agents stay stuck with stale
// connections from the pre-restart session.
@@ -101,18 +201,18 @@ function ServerSection({ projectId }: { projectId: string }) {
);
const resetData = await resetRes.json();
if (resetData.ok) {
- setFeedback(`AC + ${resetData.restarted} agent${resetData.restarted !== 1 ? "s" : ""} restarted`);
+ setFeedback(t.acAndAgentsRestarted(resetData.restarted));
} else {
- setFeedback(`AC restarted — agent reset failed`);
+ setFeedback(t.agentResetFailed);
}
} catch {
- setFeedback(`AC restarted — agent reset failed`);
+ setFeedback(t.agentResetFailed);
}
} else {
- setFeedback(d.error || "Failed to restart");
+ setFeedback(d.error || t.failed);
}
} catch {
- setFeedback("Error");
+ setFeedback(t.error);
}
setLoading(null);
clearFeedback();
@@ -127,10 +227,10 @@ function ServerSection({ projectId }: { projectId: string }) {
);
const d = await r.json();
setFeedback(
- d.ok ? `Reset — ${d.restarted} of ${d.total} agent${d.total !== 1 ? "s" : ""} restarted` : (d.error || "Failed")
+ d.ok ? t.resetResult(d.restarted, d.total) : (d.error || t.failed)
);
} catch {
- setFeedback("Error");
+ setFeedback(t.error);
}
setLoading(null);
clearFeedback();
@@ -139,7 +239,7 @@ function ServerSection({ projectId }: { projectId: string }) {
return (
- Server
+ {t.title}
- {loading === "stop" ? "..." : confirmStop ? "Confirm Stop?" : "Stop"}
+ {loading === "stop" ? "..." : confirmStop ? t.confirmStop : t.stop}
- {loading === "restart" ? "..." : "Restart"}
+ {loading === "restart" ? "..." : t.restart}
- {loading === "reset" ? "..." : "Reset Agents"}
+ {loading === "reset" ? "..." : t.resetAgents}
{feedback && (
{feedback}
)}
{healthNote && !feedback && (
-
+
{healthNote}
)}
@@ -205,6 +305,8 @@ const AWAKE_AUTO_POLL_MS = 30_000;
const AWAKE_AUTO_DEFAULT_HOURS = 8;
function SystemSection({ projectId }: { projectId: string }) {
+ const { locale } = useLocale();
+ const t = COPY[locale].system;
const [active, setActive] = useState(false);
const [remaining, setRemaining] = useState
(null);
const [platform, setPlatform] = useState("");
@@ -403,12 +505,12 @@ function SystemSection({ projectId }: { projectId: string }) {
if (!prev) {
if (hasItems && !data.complete && !activeRef.current) {
autoStart();
- setAwakeAutoStatus("Batch active — auto-started caffeinate.");
+ setAwakeAutoStatus(t.awakeStatusActive);
}
// #462: First poll — batch already complete but caffeinate still running → auto-stop
if (hasItems && data.complete && activeRef.current) {
autoStop();
- setAwakeAutoStatus("Batch complete — awake paused.");
+ setAwakeAutoStatus(t.awakeStatusComplete);
}
return;
}
@@ -416,21 +518,21 @@ function SystemSection({ projectId }: { projectId: string }) {
// Batch just completed → auto-stop
if (hasItems && data.complete && !prev.complete && activeRef.current) {
autoStop();
- setAwakeAutoStatus("Batch complete — awake paused.");
+ setAwakeAutoStatus(t.awakeStatusComplete);
manualStopRef.current = false;
}
// New batch started (complete→active or empty→active) → auto-start
if (hasItems && !data.complete && (prev.complete || !prev.hasItems) && !activeRef.current && !manualStopRef.current) {
autoStart();
- setAwakeAutoStatus("New batch detected — auto-started caffeinate.");
+ setAwakeAutoStatus(t.awakeStatusNew);
}
} catch { /* non-fatal */ }
};
check();
const interval = setInterval(check, AWAKE_AUTO_POLL_MS);
return () => clearInterval(interval);
- }, [awakeAuto, projectId, autoStart, autoStop]);
+ }, [awakeAuto, projectId, autoStart, autoStop, t]);
// #425 / quadwork#311: Keep Awake is now a standalone subsection
// and renders even on non-darwin (the button just hides). The
@@ -452,10 +554,10 @@ function SystemSection({ projectId }: { projectId: string }) {
{showKeepAwakeSubsection && (
-
Keep Mac Awake
+
{t.keepAwake}
setShowKeepAwakeHelp((s) => !s)}
className="w-3.5 h-3.5 rounded-full border border-border text-[9px] leading-none text-text-muted hover:text-accent hover:border-accent inline-flex items-center justify-center"
>?
@@ -463,7 +565,7 @@ function SystemSection({ projectId }: { projectId: string }) {
{showKeepAwakeHelp && (
- Keep Mac Awake runs macOS caffeinate to stop the screen, disk, and system idle timers from sleeping your Mac during an overnight run. Make sure the laptop is plugged in — caffeinate blocks sleep but not battery drain.
+ {t.keepAwakeHelp}
)}
{awakeAutoStatus && (
@@ -485,10 +587,10 @@ function SystemSection({ projectId }: { projectId: string }) {
)}
{active && remaining !== null && remaining > 0
- ? `Awake for ${formatTime(remaining)} more — keep Mac plugged in`
+ ? t.awakeFor(formatTime(remaining))
: active && remaining === null
- ? "Awake indefinitely — keep Mac plugged in"
- : "Prevents your Mac from sleeping during overnight runs."}
+ ? t.awakeIndefinitely
+ : t.awakeDesc}
- {active ? "Awake" : "Start"}
+ {active ? t.awake : t.start}
{active && remaining !== null && remaining > 0 && (
{formatTime(remaining)}
)}
{active && remaining === null && (
- on
+ {t.on}
)}
@@ -523,21 +625,21 @@ function SystemSection({ projectId }: { projectId: string }) {
is now its own subsection with an always-visible descriptor. */}
- Notification Sound
+ {t.sound}
setShowSoundHelp((s) => !s)}
className="w-3.5 h-3.5 rounded-full border border-border text-[9px] leading-none text-text-muted hover:text-accent hover:border-accent inline-flex items-center justify-center"
>?
{showSoundHelp && (
- Notification Sound plays a brief chime when an agent posts a new message (not your own sends, not system events). Sound choice picks one of the bundled chimes. Background-only mode suppresses the chime while the tab is focused — ding only when you're looking elsewhere. All prefs persist in localStorage.
+ {t.soundHelp}
)}
- Plays a chime when an agent posts a new message.
+ {t.soundDesc}
- {soundEnabled ? "🔔" : "🔕"} Sound
+ {soundEnabled ? "🔔" : "🔕"} {t.soundLabel}
{soundEnabled && (
toggleBgOnly(e.target.checked)}
/>
- Only when tab is in background
+ {t.soundBgOnly}
)}
@@ -580,26 +682,24 @@ function SystemSection({ projectId }: { projectId: string }) {
{showPresets && !active && (
- Keep Awake
+ {t.keepAwakeModalTitle}
setShowHelp((s) => !s)}
className="w-3.5 h-3.5 rounded-full border border-border text-[9px] leading-none text-text-muted hover:text-accent hover:border-accent inline-flex items-center justify-center"
>?
{showHelp && (
- Keep Awake prevents your Mac from sleeping for the duration you set. Use this when you want agents to keep working overnight.
-
- Under the hood, this runs macOS's caffeinate command. While it's active your screen, disk, and system idle timers are all paused — make sure your Mac is plugged in to avoid draining the battery.
+ {t.keepAwakeModalHelp}
)}
- Make sure Mac is plugged in
+ {t.makeSurePluggedIn}
- for
+ {t.for}
- hours
+ {t.hours}
setUntilStopped(e.target.checked)}
/>
- Until stopped (no expiry)
+ {t.untilStopped}
- Start
+ {t.start}
)}
diff --git a/src/components/DiscordBridgeWidget.tsx b/src/components/DiscordBridgeWidget.tsx
index 76c29af..fe10c9d 100644
--- a/src/components/DiscordBridgeWidget.tsx
+++ b/src/components/DiscordBridgeWidget.tsx
@@ -3,6 +3,58 @@
import { useCallback, useEffect, useRef, useState } from "react";
import InfoTooltip from "./InfoTooltip";
import DiscordSetupModal from "./DiscordSetupModal";
+import { useLocale } from "@/components/LocaleProvider";
+
+const COPY = {
+ en: {
+ title: "Discord Bridge",
+ tooltip: (
+ <>
+
Discord Bridge forwards AgentChattr messages to a Discord channel so you can monitor from Discord. Bidirectional — replies from Discord appear in chat.
+ >
+ ),
+ autoOn: "Auto ON — bridge follows batch lifecycle",
+ autoOff: "Auto OFF — manual start/stop only",
+ notConfigured: "Not configured",
+ setUp: "Set up Discord Bridge",
+ running: "Running",
+ stopped: "Stopped",
+ stop: "Stop",
+ stopping: "Stopping…",
+ start: "Start",
+ starting: "Starting…",
+ howToSetUp: "How to set up",
+ editCredentials: "Edit credentials",
+ dismiss: "dismiss",
+ batchActive: "Batch active — auto-starting bridge.",
+ batchComplete: "Batch complete — bridge paused. Waiting for next batch.",
+ newBatch: "New batch detected — auto-starting bridge.",
+ },
+ ko: {
+ title: "디스코드 브릿지",
+ tooltip: (
+ <>
+
디스코드 브릿지 - AgentChattr 메시지를 디스코드 채널로 전달해서 디스코드에서 모니터링할 수 있게 합니다. 양방향이며 디스코드에서 보낸 답장도 채팅에 나타납니다.
+ >
+ ),
+ autoOn: "자동 모드 켬 — 브릿지가 배치 주기를 따릅니다",
+ autoOff: "자동 모드 끔 — 수동 시작/중지만 가능",
+ notConfigured: "설정되지 않음",
+ setUp: "디스코드 브릿지 설정",
+ running: "실행 중",
+ stopped: "중지됨",
+ stop: "중지",
+ stopping: "중지 중…",
+ start: "시작",
+ starting: "시작 중…",
+ howToSetUp: "설정 방법",
+ editCredentials: "인증 정보 수정",
+ dismiss: "닫기",
+ batchActive: "배치 실행 중 — 브릿지를 자동 시작합니다.",
+ batchComplete: "배치 완료 — 브릿지를 일시 중단했습니다. 다음 배치를 기다리는 중.",
+ newBatch: "새 배치 감지 — 브릿지를 자동 시작합니다.",
+ },
+} as const;
interface BatchState {
complete: boolean;
@@ -42,6 +94,8 @@ async function callDiscord(action: string, body: Record
) {
* scratch.
*/
export default function DiscordBridgeWidget({ projectId }: DiscordBridgeWidgetProps) {
+ const { locale } = useLocale();
+ const t = COPY[locale];
const [status, setStatus] = useState(null);
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState(null);
@@ -186,12 +240,12 @@ export default function DiscordBridgeWidget({ projectId }: DiscordBridgeWidgetPr
if (!prev) {
if (hasItems && !data.complete && !runningRef.current) {
- setAutoStatus("Batch active — auto-starting bridge.");
+ setAutoStatus(t.batchActive);
await callDiscord("start", { project_id: projectId }).catch(() => {});
await load();
}
if (hasItems && data.complete && runningRef.current) {
- setAutoStatus("Batch complete — bridge paused. Waiting for next batch.");
+ setAutoStatus(t.batchComplete);
setActionError(null); // #522: clear stale action errors on auto-stop
await callDiscord("stop", { project_id: projectId }).catch(() => {});
await load();
@@ -201,7 +255,7 @@ export default function DiscordBridgeWidget({ projectId }: DiscordBridgeWidgetPr
// Batch just completed → auto-stop
if (hasItems && data.complete && !prev.complete && runningRef.current) {
- setAutoStatus("Batch complete — bridge paused. Waiting for next batch.");
+ setAutoStatus(t.batchComplete);
setActionError(null); // #522: clear stale action errors on auto-stop
await callDiscord("stop", { project_id: projectId }).catch(() => {});
await load();
@@ -210,12 +264,12 @@ export default function DiscordBridgeWidget({ projectId }: DiscordBridgeWidgetPr
// New batch started → auto-start
if (hasItems && !data.complete && (prev.complete || !prev.hasItems) && !runningRef.current) {
- setAutoStatus("New batch detected — auto-starting bridge.");
+ setAutoStatus(t.newBatch);
await callDiscord("start", { project_id: projectId }).catch(() => {});
await load();
}
} catch { /* non-fatal */ }
- }, [projectId, load]);
+ }, [projectId, load, t]);
useEffect(() => {
if (!autoDiscord) return;
@@ -236,9 +290,9 @@ export default function DiscordBridgeWidget({ projectId }: DiscordBridgeWidgetPr
- Discord Bridge
+ {t.title}
- Discord Bridge forwards AgentChattr messages to a Discord channel so you can monitor from Discord. Bidirectional — replies from Discord appear in chat.
+ {t.tooltip}
@@ -246,7 +300,7 @@ export default function DiscordBridgeWidget({ projectId }: DiscordBridgeWidgetPr
- Not configured
+ {t.notConfigured}
setSetupOpen(true)}
disabled={busy}
className="self-start px-3 py-1 text-[11px] font-semibold text-bg bg-accent hover:bg-accent-dim disabled:opacity-50 transition-colors"
>
- Set up Discord Bridge
+ {t.setUp}
>
) : (
@@ -285,12 +339,12 @@ export default function DiscordBridgeWidget({ projectId }: DiscordBridgeWidgetPr
- Running
+ {t.running}
>
) : (
<>
- Stopped
+ {t.stopped}
>
)}
{status?.bot_username && (
@@ -307,7 +361,7 @@ export default function DiscordBridgeWidget({ projectId }: DiscordBridgeWidgetPr
disabled={busy}
className="px-3 py-1 text-[11px] text-text-muted border border-border hover:text-error hover:border-error/40 disabled:opacity-50 transition-colors"
>
- {busy ? "Stopping\u2026" : "Stop"}
+ {busy ? t.stopping : t.stop}
) : (
- {busy ? "Starting\u2026" : "Start"}
+ {busy ? t.starting : t.start}
)}
- How to set up
+ {t.howToSetUp}
setSetupOpen(true)}
disabled={busy}
className="px-3 py-1 text-[11px] text-text-muted border border-border hover:text-text disabled:opacity-50 transition-colors"
>
- Edit credentials
+ {t.editCredentials}
>
@@ -343,7 +397,7 @@ export default function DiscordBridgeWidget({ projectId }: DiscordBridgeWidgetPr
onClick={() => setRestartNotice(null)}
className="block mt-1 text-text-muted hover:text-text underline"
>
- dismiss
+ {t.dismiss}
)}
@@ -361,7 +415,7 @@ export default function DiscordBridgeWidget({ projectId }: DiscordBridgeWidgetPr
onClick={() => setActionError(null)}
className="block mt-1 text-text-muted hover:text-text underline"
>
- dismiss
+ {t.dismiss}
)}
diff --git a/src/components/GitHubPanel.tsx b/src/components/GitHubPanel.tsx
index b183e81..7bd8916 100644
--- a/src/components/GitHubPanel.tsx
+++ b/src/components/GitHubPanel.tsx
@@ -4,6 +4,64 @@ import { useState, useEffect, useCallback } from "react";
import InfoTooltip from "./InfoTooltip";
import OvernightQueueModal from "./OvernightQueueModal";
import BatchProgressPanel from "./BatchProgressPanel";
+import { useLocale } from "@/components/LocaleProvider";
+
+const COPY = {
+ en: {
+ rateLimitCritical: (reset: number) => `GitHub Rate Limit CRITICAL: 0 remaining. Resets in ${reset}m.`,
+ rateLimitLow: (rem: number, limit: number, reset: number) => `GitHub Rate Limit LOW: ${rem}/${limit} remaining. Resets in ${reset}m.`,
+ issues: (count: number) => `Issues (${count})`,
+ issuesHelp: (
+ <>
+ Issues — open issues in this repository. Agents will pick these up automatically if they are part of the overnight queue.
+ >
+ ),
+ noIssues: "No open issues.",
+ recentlyClosed: "Recently closed",
+ noneYet: "None yet.",
+ prs: (count: number) => `Pull Requests (${count})`,
+ prsHelp: (
+ <>
+ Pull Requests — active PRs in this repository. Shows review status from RE1/RE2 and CI status.
+ >
+ ),
+ noPrs: "No active PRs.",
+ recentlyMerged: "Recently merged / closed",
+ overnightQueueHelp: (
+ <>
+ OVERNIGHT-QUEUE.md — a list of issue numbers (one per line) for agents to process autonomously. Edit this file to add or remove tasks from the queue.
+ >
+ ),
+ edit: "Edit",
+ },
+ ko: {
+ rateLimitCritical: (reset: number) => `GitHub API 제한 위험: 잔여 0개. ${reset}분 후 초기화됩니다.`,
+ rateLimitLow: (rem: number, limit: number, reset: number) => `GitHub API 제한 낮음: ${rem}/${limit} 남음. ${reset}분 후 초기화됩니다.`,
+ issues: (count: number) => `이슈 (${count})`,
+ issuesHelp: (
+ <>
+ 이슈 - 이 저장소의 열려 있는 이슈들입니다. 야간 큐에 포함되면 에이전트가 자동으로 작업을 시작합니다.
+ >
+ ),
+ noIssues: "열린 이슈가 없습니다.",
+ recentlyClosed: "최근 닫힌 이슈",
+ noneYet: "아직 없습니다.",
+ prs: (count: number) => `풀 리퀘스트 (${count})`,
+ prsHelp: (
+ <>
+ 풀 리퀘스트 - 이 저장소의 활성화된 PR들입니다. RE1/RE2의 리뷰 상태와 CI 상태를 보여줍니다.
+ >
+ ),
+ noPrs: "활성 PR이 없습니다.",
+ recentlyMerged: "최근 머지/닫힘",
+ overnightQueueHelp: (
+ <>
+ OVERNIGHT-QUEUE.md - 에이전트가 자율적으로 처리할 이슈 번호 목록(한 줄에 하나씩)입니다. 이 파일을 편집하여 큐에 작업을 추가하거나 제거할 수 있습니다.
+ >
+ ),
+ edit: "편집",
+ },
+} as const;
interface Issue {
number: number;
@@ -96,6 +154,8 @@ interface RateLimitInfo {
}
export default function GitHubPanel({ projectId }: GitHubPanelProps) {
+ const { locale } = useLocale();
+ const t = COPY[locale];
const [issues, setIssues] = useState([]);
const [prs, setPrs] = useState([]);
// #411 / quadwork#281: recently closed issues + merged PRs.
@@ -181,8 +241,8 @@ export default function GitHubPanel({ projectId }: GitHubPanelProps) {
: "bg-[#ffcc00]/20 text-[#ffcc00]"
}`}>
{rateLimit.critical
- ? `GitHub API rate limited — showing cached data. Resets in ${rateLimit.resetInMinutes}m`
- : `GitHub API: ${rateLimit.remaining}/${rateLimit.limit} remaining. Resets in ${rateLimit.resetInMinutes}m`
+ ? t.rateLimitCritical(rateLimit.resetInMinutes)
+ : t.rateLimitLow(rateLimit.remaining, rateLimit.limit, rateLimit.resetInMinutes)
}
)}
@@ -192,15 +252,15 @@ export default function GitHubPanel({ projectId }: GitHubPanelProps) {
diff --git a/src/components/HomeDashboard.tsx b/src/components/HomeDashboard.tsx
index 6c60333..4374866 100644
--- a/src/components/HomeDashboard.tsx
+++ b/src/components/HomeDashboard.tsx
@@ -3,189 +3,172 @@
import { useState, useEffect } from "react";
import Link from "next/link";
import HomeEmptyState from "./HomeEmptyState";
+import { useLocale } from "@/components/LocaleProvider";
+
+const COPY = {
+ en: {
+ loading: "Loading dashboard...",
+ recentProjects: "Recent Projects",
+ lastModified: "Last modified",
+ justNow: "just now",
+ minsAgo: (mins: number) => `${mins}m ago`,
+ hoursAgo: (hours: number) => `${hours}h ago`,
+ daysAgo: (days: number) => `${days}d ago`,
+ newProject: "New Project",
+ globalFeed: "Global Activity Feed",
+ noActivity: "No recent activity.",
+ },
+ ko: {
+ loading: "대시보드 로딩 중...",
+ recentProjects: "최근 프로젝트",
+ lastModified: "마지막 수정",
+ justNow: "방금 전",
+ minsAgo: (mins: number) => `${mins}분 전`,
+ hoursAgo: (hours: number) => `${hours}시간 전`,
+ daysAgo: (days: number) => `${days}일 전`,
+ newProject: "새 프로젝트",
+ globalFeed: "전체 활동 피드",
+ noActivity: "최근 활동이 없습니다.",
+ },
+} as const;
interface Project {
id: string;
name: string;
repo: string;
- agentCount: number;
- openPrs: number;
- state: "active" | "idle";
- lastActivity: string | null;
+ modifiedAt: string;
}
-interface ActivityEvent {
- time: string;
- text: string;
- actor: string;
+interface FeedItem {
+ id: string;
+ projectId: string;
projectName: string;
+ agent: string;
+ text: string;
+ timestamp: string;
}
-function timeAgo(iso: string): string {
- const diff = Date.now() - new Date(iso).getTime();
+function formatRelative(timestamp: string, t: typeof COPY["en"] | typeof COPY["ko"]): string {
+ const diff = Date.now() - new Date(timestamp).getTime();
const mins = Math.floor(diff / 60000);
- if (mins < 1) return "just now";
- if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
- if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
- return `${days}d ago`;
+
+ if (mins < 1) return t.justNow;
+ if (mins < 60) return t.minsAgo(mins);
+ if (hours < 24) return t.hoursAgo(hours);
+ return t.daysAgo(days);
}
+/**
+ * Main landing page (#208).
+ *
+ * Shows the "Recent Projects" grid + a global feed of activity
+ * from all projects. If no projects exist, renders the
+ * HomeEmptyState hero instead.
+ */
export default function HomeDashboard() {
+ const { locale } = useLocale();
+ const t = COPY[locale];
const [projects, setProjects] = useState([]);
- const [activity, setActivity] = useState([]);
- // #229: track whether /api/projects has resolved (success OR
- // failure) so the empty-state hero doesn't flash the "no
- // projects" CTA before we actually know. Possible values:
- // "loading" — first paint, no answer yet
- // "loaded" — fetch resolved successfully
- // "error" — fetch failed; preserve last-known projects
- const [projectsState, setProjectsState] = useState<"loading" | "loaded" | "error">("loading");
+ const [feed, setFeed] = useState([]);
+ const [loading, setLoading] = useState(true);
useEffect(() => {
- fetch("/api/projects")
- .then((r) => {
- if (!r.ok) throw new Error(`${r.status}`);
- return r.json();
- })
- .then((data) => {
- if (data.projects && Array.isArray(data.projects)) setProjects(data.projects.filter((p: Project & { archived?: boolean }) => !p.archived));
- if (data.recentEvents && Array.isArray(data.recentEvents)) setActivity(data.recentEvents);
- setProjectsState("loaded");
+ Promise.all([
+ fetch("/api/config").then((r) => r.json()),
+ fetch("/api/feed").then((r) => r.json()),
+ ])
+ .then(([cfg, feedData]) => {
+ if (cfg?.projects) {
+ const sorted = [...cfg.projects].sort(
+ (a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()
+ );
+ setProjects(sorted);
+ }
+ if (Array.isArray(feedData)) {
+ setFeed(feedData.slice(0, 20));
+ }
})
- .catch(() => { setProjectsState("error"); });
+ .catch(() => {})
+ .finally(() => setLoading(false));
}, []);
- return (
-
- {/* #488: two-column layout at lg+ — hero+projects left, activity right.
- Collapses to current stacked layout below lg. Flex-1 + min-h-0
- lets the grid fill remaining height without a magic calc. */}
-
- {/* Left column: hero + header + project cards */}
-
- {/* #229: friendly empty-state hero. Only rendered after
- /api/projects resolves successfully — we don't want to
- flash the "no projects" onboarding CTA to existing users
- while the first fetch is in flight, or when the API
- errored and we have no idea which branch to show. */}
- {projectsState === "loaded" && (
-
- 0} />
-
- )}
- {projectsState === "error" && (
-
- Could not load projects from /api/projects. The dashboard may be out of date — check the server logs and reload.
-
- )}
-
- {/* Header */}
-
-
Projects
-
- {projects.length} configured project{projects.length !== 1 ? "s" : ""}
-
-
-
- {/* Project cards grid */}
-
- {projects.map((project) => (
-
-
-
-
- {project.name}
-
- {project.state}
-
-
-
- open →
-
-
-
-
-
- agents
- {project.agentCount}
-
-
- PRs
- {project.openPrs}
-
-
- repo
- {project.repo}
-
-
+ if (loading) {
+ return (
+
+ {t.loading}
+
+ );
+ }
- {project.lastActivity && (
-
- last activity: {timeAgo(project.lastActivity)}
-
- )}
-
- ))}
+ if (projects.length === 0) {
+ return
;
+ }
- {/* + New Project */}
+ return (
+
+ {/* 1. Projects Grid */}
+
+
+
+ {t.recentProjects}
+
+
+ + {t.newProject}
+
+
+
+ {projects.map((p) => (
-
+ New Project
+
+ {p.name}
+
+
+ {p.repo}
+
+
+ {t.lastModified}
+ {formatRelative(p.modifiedAt, t)}
+
-
-
- {/* #507: subtle Discord community link */}
-
+ ))}
+
- {/* Right column: activity feed — scrolls independently on desktop */}
-
-
Recent Activity
-
- {activity.length === 0 && (
-
No recent activity
+ {/* 2. Global Feed (#208 Quadrant 3 influence) */}
+
+
+
+ {t.globalFeed}
+
+
+ {feed.length === 0 && (
+
{t.noActivity}
)}
- {activity.map((item, i) => (
+ {feed.map((item) => (
-
- {item.time?.slice(0, 5) || ""}
-
-
- {item.projectName}
+
+ {formatRelative(item.timestamp, t)}
-
- {/* #420 / quadwork#307: widen column + mirror the
- RE1/RE2 short labels PR #272 (#263) already uses
- in the chat sender column. w-6 was 24px and
- overflowed re1/re2 into the adjacent
- message text column. */}
- {item.actor}
+
+ [{item.projectName}]
+
+
+ {item.agent}:
{item.text}
diff --git a/src/components/HomeEmptyState.tsx b/src/components/HomeEmptyState.tsx
index ed07c58..4d1bf2b 100644
--- a/src/components/HomeEmptyState.tsx
+++ b/src/components/HomeEmptyState.tsx
@@ -3,11 +3,35 @@
import Link from "next/link";
import { useState } from "react";
import HowToWorkModal from "./HowToWorkModal";
+import { useLocale } from "@/components/LocaleProvider";
interface HomeEmptyStateProps {
hasProjects: boolean;
}
+const COPY = {
+ en: {
+ headlineWithProjects: "Pick a project from the sidebar to start working",
+ headlineNoProjects: "Welcome to QuadWork — let's set up your first AI dev team",
+ subtextWithProjects: "Each project has its own 4-agent team and chat. Click any chip in the left sidebar to open one.",
+ subtextNoProjects: "QuadWork runs Head, Dev, and two Reviewers as a team. They open issues, write code, review PRs, and merge — while you sleep.",
+ lookSidebar: "← look at the left sidebar",
+ addProject: "Add Your First Project →",
+ howToWork: "How to Work",
+ helpClass: "",
+ },
+ ko: {
+ headlineWithProjects: "사이드바에서 프로젝트를 골라 작업을 시작하세요",
+ headlineNoProjects: "QuadWork에 오신 걸 환영합니다\n- 첫 AI 개발 팀을 설정해볼까요",
+ subtextWithProjects: "각 프로젝트는 자체 4인 에이전트 팀과 채팅을 가집니다.\n왼쪽 사이드바에서 아무 프로젝트나 눌러 열 수 있습니다.",
+ subtextNoProjects: "QuadWork는 Head, Dev, Reviewer 둘을 한 팀으로 운영합니다.\n이슈를 만들고, 코드를 작성하고, PR을 리뷰하고, 병합합니다.\n당신이 쉬는 동안에도요.",
+ lookSidebar: "← 왼쪽 사이드바를 보세요",
+ addProject: "첫 프로젝트 추가 →",
+ howToWork: "사용 방법",
+ helpClass: "ko-help",
+ },
+} as const;
+
/**
* Hero block for the home route (#229).
*
@@ -16,32 +40,32 @@ interface HomeEmptyStateProps {
* surfaces a "How to Work" button that opens the timeline modal.
*/
export default function HomeEmptyState({ hasProjects }: HomeEmptyStateProps) {
+ const { locale } = useLocale();
+ const t = COPY[locale];
const [howOpen, setHowOpen] = useState(false);
- const headline = hasProjects
- ? "Pick a project from the sidebar to start working"
- : "Welcome to QuadWork — let's set up your first AI dev team";
- const subtext = hasProjects
- ? "Each project has its own 4-agent team and chat. Click any chip in the left sidebar to open one."
- : "QuadWork runs Head, Dev, and two Reviewers as a team. They open issues, write code, review PRs, and merge — while you sleep.";
+ const headline = hasProjects ? t.headlineWithProjects : t.headlineNoProjects;
+ const subtext = hasProjects ? t.subtextWithProjects : t.subtextNoProjects;
return (
{/* #446: QuadWork symbol replaces the generic agent-team icon */}
-
{headline}
-
{subtext}
+
{headline}
+
{subtext}
{hasProjects ? (
- ← look at the left sidebar
+
+ {t.lookSidebar}
+
) : (
- Add Your First Project →
+ {t.addProject}
)}
setHowOpen(true)}
className="px-4 py-2 text-[12px] text-text-muted border border-border hover:text-text hover:border-text-muted transition-colors"
>
- How to Work
+ {t.howToWork}
diff --git a/src/components/HowToWorkModal.tsx b/src/components/HowToWorkModal.tsx
index 7a66d5d..01ab0be 100644
--- a/src/components/HowToWorkModal.tsx
+++ b/src/components/HowToWorkModal.tsx
@@ -1,34 +1,71 @@
"use client";
import { useEffect } from "react";
+import { useLocale } from "@/components/LocaleProvider";
interface HowToWorkModalProps {
open: boolean;
onClose: () => void;
}
-const STEPS: { title: string; body: string }[] = [
- {
- title: "You assign a task in the chat",
- body: "Tell @head what to build. Be as specific or as vague as you like.",
+const COPY = {
+ en: {
+ title: "How QuadWork builds your code",
+ subtitle: "Five steps from your one-line request to a merged pull request.",
+ close: "Close",
+ helpClass: "",
+ steps: [
+ {
+ title: "You assign a task in the chat",
+ body: "Tell @head what to build. Be as specific or as vague as you like.",
+ },
+ {
+ title: "Head creates a GitHub issue",
+ body: "Head opens an issue, adds it to the queue, and waits for your trigger.",
+ },
+ {
+ title: "Dev writes the code",
+ body: "Dev clones a branch, implements the change, and opens a pull request.",
+ },
+ {
+ title: "Reviewers check the work",
+ body: "RE1 and RE2 each review the PR independently. Both must approve before the PR is mergeable.",
+ },
+ {
+ title: "Head merges and continues",
+ body: "Head merges the approved PR and assigns the next ticket from the queue. The cycle continues all night while you sleep.",
+ },
+ ],
},
- {
- title: "Head creates a GitHub issue",
- body: "Head opens an issue, adds it to the queue, and waits for your trigger.",
+ ko: {
+ title: "QuadWork가 코드를 만드는 방식",
+ subtitle: "한 줄 요청에서 병합된 풀 리퀘스트까지 가는 5단계입니다.",
+ close: "닫기",
+ helpClass: "ko-help",
+ steps: [
+ {
+ title: "채팅에서 작업을 지시합니다",
+ body: "@head에게 만들 것을 말해주세요. 구체적으로 또는 모호하게 말해도 괜찮습니다.",
+ },
+ {
+ title: "Head가 GitHub 이슈를 만듭니다",
+ body: "Head가 이슈를 열고, 큐에 추가한 뒤, 당신의 트리거를 기다립니다.",
+ },
+ {
+ title: "Dev가 코드를 작성합니다",
+ body: "Dev가 브랜치를 만들고, 변경 사항을 구현한 뒤, 풀 리퀘스트를 엽니다.",
+ },
+ {
+ title: "리뷰어가 작업을 검토합니다",
+ body: "RE1과 RE2가 각각 독립적으로 PR을 리뷰합니다. 둘 다 승인해야 PR이 병합 가능 상태가 됩니다.",
+ },
+ {
+ title: "Head가 병합하고 계속 진행합니다",
+ body: "Head가 승인된 PR을 병합하고, 큐에서 다음 티켓을 할당합니다. 당신이 자는 동안에도 이 사이클은 밤새 계속됩니다.",
+ },
+ ],
},
- {
- title: "Dev writes the code",
- body: "Dev clones a branch, implements the change, and opens a pull request.",
- },
- {
- title: "Reviewers check the work",
- body: "RE1 and RE2 each review the PR independently. Both must approve before the PR is mergeable.",
- },
- {
- title: "Head merges and continues",
- body: "Head merges the approved PR and assigns the next ticket from the queue. The cycle continues all night while you sleep.",
- },
-];
+} as const;
/**
* "How to Work" modal (#229).
@@ -38,6 +75,9 @@ const STEPS: { title: string; body: string }[] = [
* Closes on Escape, backdrop click, or the X button.
*/
export default function HowToWorkModal({ open, onClose }: HowToWorkModalProps) {
+ const { locale } = useLocale();
+ const t = COPY[locale];
+
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
@@ -56,13 +96,13 @@ export default function HowToWorkModal({ open, onClose }: HowToWorkModalProps) {
aria-labelledby="how-to-work-title"
>
e.stopPropagation()}
>
@@ -70,15 +110,17 @@ export default function HowToWorkModal({ open, onClose }: HowToWorkModalProps) {
-
How QuadWork builds your code
+
+ {t.title}
+
- Five steps from your one-line request to a merged pull request.
+ {t.subtitle}
{/* Vertical accent line connecting the step circles. */}
- {STEPS.map((step, i) => (
+ {t.steps.map((step, i) => (
void;
+}
+
+const LocaleContext = createContext(null);
+
+export function LocaleProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [hydrated, setHydrated] = useState(false);
+ const [locale, setLocaleState] = useState("en");
+
+ useEffect(() => {
+ setLocaleState(detectBrowserLocale());
+ setHydrated(true);
+ }, []);
+
+ useEffect(() => {
+ document.documentElement.lang = locale;
+ try {
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
+ } catch {}
+ try {
+ document.cookie = `${LOCALE_COOKIE_KEY}=${locale}; path=/; max-age=31536000; samesite=lax`;
+ } catch {}
+ }, [locale]);
+
+ const value = useMemo(() => ({
+ hydrated,
+ locale,
+ setLocale: (next) => setLocaleState(normalizeLocale(next)),
+ }), [hydrated, locale]);
+
+ return {children} ;
+}
+
+export function useLocale() {
+ const value = useContext(LocaleContext);
+ if (!value) throw new Error("useLocale must be used within LocaleProvider");
+ return value;
+}
diff --git a/src/components/LoopGuardWidget.tsx b/src/components/LoopGuardWidget.tsx
index 881a03a..142482b 100644
--- a/src/components/LoopGuardWidget.tsx
+++ b/src/components/LoopGuardWidget.tsx
@@ -2,6 +2,46 @@
import { useEffect, useState } from "react";
import InfoTooltip from "./InfoTooltip";
+import { useLocale } from "@/components/LocaleProvider";
+
+const COPY = {
+ en: {
+ title: "Loop Guard",
+ tooltip: (
+ <>
+ Loop Guard pauses agent-to-agent message chains after this many hops with no human reply. Higher values let agents work longer overnight; lower values add safety against runaway loops. AgentChattr accepts 4–50 ; QuadWork defaults to 30 (about 5–6 full PR cycles). Posting any chat message yourself resets the counter immediately.
+ >
+ ),
+ pauseAfter: "Pause after",
+ hops: "hops",
+ apply: "Apply",
+ applying: "…",
+ applyTitle: "Apply (writes config.toml + live-pushes to AgentChattr)",
+ errorInteger: "Must be an integer between 4 and 50.",
+ liveUpdateFailed: "Saved to config.toml — live update failed; takes effect on next AC restart.",
+ autoContinue: "Auto-continue after pause",
+ wait: "— wait",
+ secondsBefore: "s before /continue",
+ },
+ ko: {
+ title: "루프 가드",
+ tooltip: (
+ <>
+ 루프 가드 - 사람의 응답 없이 에이전트끼리 메시지를 주고받는 횟수가 이 값에 도달하면 체인을 멈춥니다. 값을 높이면 야간 작업을 더 길게 돌릴 수 있고, 낮추면 runaway loop에 대한 안전성이 높아집니다. AgentChattr 허용 범위는 4-50 이며 QuadWork 기본값은 30 입니다. 직접 채팅을 한 번 보내면 카운터는 즉시 초기화됩니다.
+ >
+ ),
+ pauseAfter: "다음 횟수 후 일시정지:",
+ hops: "홉",
+ apply: "적용",
+ applying: "…",
+ applyTitle: "적용 (config.toml에 저장하고 AgentChattr에 실시간 반영)",
+ errorInteger: "4에서 50 사이의 정수여야 합니다.",
+ liveUpdateFailed: "config.toml에 저장되었습니다. 실시간 업데이트는 실패하여 다음 AC 재시작 때 적용됩니다.",
+ autoContinue: "일시정지 후 자동 재개",
+ wait: "—",
+ secondsBefore: "초 대기 후 /continue",
+ },
+} as const;
interface LoopGuardWidgetProps {
projectId: string;
@@ -18,6 +58,8 @@ interface LoopGuardWidgetProps {
* via update_settings ws event so the change is immediate.
*/
export default function LoopGuardWidget({ projectId }: LoopGuardWidgetProps) {
+ const { locale } = useLocale();
+ const t = COPY[locale];
const [value, setValue] = useState(30);
const [draft, setDraft] = useState("30");
const [saving, setSaving] = useState(false);
@@ -94,7 +136,7 @@ export default function LoopGuardWidget({ projectId }: LoopGuardWidgetProps) {
const apply = () => {
const n = parseInt(draft, 10);
if (!Number.isInteger(n) || n < 4 || n > 50) {
- setError("Must be an integer between 4 and 50.");
+ setError(t.errorInteger);
return;
}
setSaving(true);
@@ -122,13 +164,13 @@ export default function LoopGuardWidget({ projectId }: LoopGuardWidgetProps) {
return (
- Loop Guard
+ {t.title}
- Loop Guard pauses agent-to-agent message chains after this many hops with no human reply. Higher values let agents work longer overnight; lower values add safety against runaway loops. AgentChattr accepts 4–50 ; QuadWork defaults to 30 (about 5–6 full PR cycles). Posting any chat message yourself resets the counter immediately.
+ {t.tooltip}
- Pause after
+ {t.pauseAfter}
- hops
+ {t.hops}
- {saving ? "…" : "Apply"}
+ {saving ? t.applying : t.apply}
{error && (
@@ -154,7 +196,7 @@ export default function LoopGuardWidget({ projectId }: LoopGuardWidgetProps) {
)}
{live === false && !error && (
- Saved to config.toml — live update failed; takes effect on next AC restart.
+ {t.liveUpdateFailed}
)}
{/* #422 / quadwork#310: auto-continue opt-in. Default OFF so
@@ -175,8 +217,8 @@ export default function LoopGuardWidget({ projectId }: LoopGuardWidgetProps) {
});
}}
/>
- Auto-continue after pause
-
— wait
+ {t.autoContinue}
+
{t.wait}
-
s before /continue
+
{t.secondsBefore}
);
diff --git a/src/components/OperatorFeaturesPanel.tsx b/src/components/OperatorFeaturesPanel.tsx
index 4d74bc3..104b3fe 100644
--- a/src/components/OperatorFeaturesPanel.tsx
+++ b/src/components/OperatorFeaturesPanel.tsx
@@ -8,6 +8,26 @@ import DiscordBridgeWidget from "./DiscordBridgeWidget";
import LoopGuardWidget from "./LoopGuardWidget";
import ProjectHistoryWidget from "./ProjectHistoryWidget";
import AgentModelsWidget from "./AgentModelsWidget";
+import { useLocale } from "@/components/LocaleProvider";
+
+const COPY = {
+ en: {
+ label: "Operator Features",
+ tooltip: (
+ <>
+ Operator Features — tools for running autonomous overnight batches. Includes the Scheduled Trigger, Telegram Bridge, Discord Bridge, Loop Guard, Project History, and Agent Models.
+ >
+ ),
+ },
+ ko: {
+ label: "운영자 기능",
+ tooltip: (
+ <>
+ 운영자 기능 - 야간 자율 배치를 운영할 때 쓰는 도구 모음입니다. Scheduled Trigger, Telegram Bridge, Discord Bridge, Loop Guard, Project History, Agent Models가 포함됩니다.
+ >
+ ),
+ },
+} as const;
/**
* Bottom-right quadrant of the project dashboard (#208).
@@ -30,11 +50,13 @@ import AgentModelsWidget from "./AgentModelsWidget";
* clips in cramped split-view / mobile.
*/
export default function OperatorFeaturesPanel({ projectId }: { projectId: string }) {
+ const { locale } = useLocale();
+ const t = COPY[locale];
return (
-
- Operator Features — tools for running autonomous overnight batches. Includes the Scheduled Trigger, Telegram Bridge, Loop Guard, Project History, and Agent Models.
+ {t.tooltip}
} />
diff --git a/src/components/ProjectChatEmptyState.tsx b/src/components/ProjectChatEmptyState.tsx
index ac6f939..4ea142e 100644
--- a/src/components/ProjectChatEmptyState.tsx
+++ b/src/components/ProjectChatEmptyState.tsx
@@ -2,16 +2,34 @@
import { useState } from "react";
import HowToWorkModal from "./HowToWorkModal";
+import { useLocale } from "@/components/LocaleProvider";
interface ProjectChatEmptyStateProps {
onInsert: (text: string) => void;
}
-const EXAMPLES = [
- "@head start a new feature:
",
- "@head review the latest PR",
- "@head what's our current sprint?",
-];
+const COPY = {
+ en: {
+ ready: "Ready when you are",
+ tell: "Tell your team what to build. Try something like:",
+ howToWork: "How to Work",
+ examples: [
+ "@head start a new feature: ",
+ "@head review the latest PR",
+ "@head what's our current sprint?",
+ ],
+ },
+ ko: {
+ ready: "언제든지 시작하세요",
+ tell: "팀에게 무엇을 만들지 말해보세요. 예를 들어:",
+ howToWork: "사용 방법",
+ examples: [
+ "@head 새로운 기능 시작: <설명>",
+ "@head 최신 PR 리뷰해줘",
+ "@head 현재 스프린트 상황이 어때?",
+ ],
+ },
+} as const;
/**
* Empty state inside the AgentChattr chat panel (#229) for projects
@@ -20,6 +38,8 @@ const EXAMPLES = [
* chips.
*/
export default function ProjectChatEmptyState({ onInsert }: ProjectChatEmptyStateProps) {
+ const { locale } = useLocale();
+ const t = COPY[locale];
const [howOpen, setHowOpen] = useState(false);
return (
@@ -30,10 +50,10 @@ export default function ProjectChatEmptyState({ onInsert }: ProjectChatEmptyStat
- Ready when you are
- Tell your team what to build. Try something like:
+ {t.ready}
+ {t.tell}
- {EXAMPLES.map((ex) => (
+ {t.examples.map((ex) => (
setHowOpen(true)}
className="mt-2 text-[11px] text-text-muted underline underline-offset-2 hover:text-text"
>
- How to Work
+ {t.howToWork}
setHowOpen(false)} />
diff --git a/src/components/ProjectDashboard.tsx b/src/components/ProjectDashboard.tsx
index c92df58..cebe6bf 100644
--- a/src/components/ProjectDashboard.tsx
+++ b/src/components/ProjectDashboard.tsx
@@ -8,6 +8,7 @@ import GitHubPanel from "./GitHubPanel";
import ControlBar from "./ControlBar";
import AgentTerminalsGrid from "./AgentTerminalsGrid";
import OperatorFeaturesPanel from "./OperatorFeaturesPanel";
+import { useLocale } from "@/components/LocaleProvider";
const MIN_SIZE = 150; // px
const DIVIDER = 4; // px
@@ -18,7 +19,48 @@ interface ProjectDashboardProps {
projectId: string;
}
+const COPY = {
+ en: {
+ filterAgentsTitle: "Showing agent messages only — click to show all",
+ filterAllTitle: "Showing all messages — click to hide system/status noise",
+ filterOn: "Filter system log: on",
+ filterOff: "Filter system log: off",
+ chatLabel: "AgentChattr — primary chat",
+ chatTooltip: (
+ <>
+ Primary Chat — live chat between you and the 4 AI agents. Messages you type here trigger agent actions. Use @mentions to address specific agents.
+ >
+ ),
+ githubLabel: "GitHub",
+ githubTooltip: (
+ <>
+ GitHub — open issues and pull requests on this project's repo. Click any item to open it on GitHub. The batch progress panel tracks the active batch's lifecycle from queued to merged.
+ >
+ ),
+ },
+ ko: {
+ filterAgentsTitle: "에이전트 메시지만 표시 중 - 클릭하면 전체를 표시합니다",
+ filterAllTitle: "전체 메시지 표시 중 - 클릭하면 시스템/상태 로그를 숨깁니다",
+ filterOn: "시스템 로그 필터: 켜짐",
+ filterOff: "시스템 로그 필터: 꺼짐",
+ chatLabel: "AgentChattr — 메인 채팅",
+ chatTooltip: (
+ <>
+ 메인 채팅 - 당신과 4개의 AI 에이전트가 실시간으로 대화하는 공간입니다. 여기 입력한 메시지가 에이전트 동작을 시작시킵니다. 특정 에이전트를 부를 때는 @멘션을 사용하세요.
+ >
+ ),
+ githubLabel: "GitHub",
+ githubTooltip: (
+ <>
+ GitHub - 이 프로젝트 저장소의 열린 이슈와 PR을 보여줍니다. 항목을 클릭하면 GitHub에서 바로 열립니다. 아래 배치 진행 패널은 현재 배치가 대기에서 병합까지 어떻게 진행되는지 추적합니다.
+ >
+ ),
+ },
+} as const;
+
export default function ProjectDashboard({ projectId }: ProjectDashboardProps) {
+ const { locale } = useLocale();
+ const t = COPY[locale];
const containerRef = useRef(null);
const [colRatio, setColRatio] = useState(0.5);
const [rowRatio, setRowRatio] = useState(0.5);
@@ -71,16 +113,16 @@ export default function ProjectDashboard({ projectId }: ProjectDashboardProps) {
- {filterSystem ? "Filter system log: on" : "Filter system log: off"}
+ {filterSystem ? t.filterOn : t.filterOff}
- ), [filterSystem, toggleFilter]);
+ ), [filterSystem, t, toggleFilter]);
// Poll agent states
useEffect(() => {
@@ -170,9 +212,9 @@ export default function ProjectDashboard({ projectId }: ProjectDashboardProps) {
the primary interface (#208). 2px accent border + explicit
"primary chat" label in the panel header. */}
-
- Primary Chat — live chat between you and the 4 AI agents. Messages you type here trigger agent actions. Use @mentions to address specific agents.
+ {t.chatTooltip}
}>
{filterToggle}
@@ -219,9 +261,9 @@ export default function ProjectDashboard({ projectId }: ProjectDashboardProps) {
{/* Quadrant 3 (bottom-left): GitHub (#208). */}
-
- GitHub — open issues and pull requests on this project's repo. Click any item to open it on GitHub. The batch progress panel tracks the active batch's lifecycle from queued to merged.
+ {t.githubTooltip}
} />
diff --git a/src/components/ProjectHistoryWidget.tsx b/src/components/ProjectHistoryWidget.tsx
index 6c3e650..ba804fe 100644
--- a/src/components/ProjectHistoryWidget.tsx
+++ b/src/components/ProjectHistoryWidget.tsx
@@ -2,6 +2,60 @@
import { useEffect, useRef, useState } from "react";
import InfoTooltip from "./InfoTooltip";
+import { useLocale } from "@/components/LocaleProvider";
+
+const COPY = {
+ en: {
+ title: "Project History",
+ tooltip: (
+ <>
+
Project History — export or import the full AgentChattr chat history for this project. Useful for backup, migration, or resuming after a fresh install.
+ >
+ ),
+ export: (projectId: string) => `Export ${projectId} chat`,
+ import: "Import history…",
+ importing: "Importing…",
+ autoRestore: "Auto-restore newest snapshot after AC restart",
+ autoSnapshots: "Auto-snapshots (before restart)",
+ restore: "Restore",
+ restoreConfirm: (name: string) => `Restore snapshot ${name}? This will replay every message through AgentChattr (tagged by the original sender) and may duplicate history already in the chat. Continue?`,
+ importMismatchConfirm: (source: string, target: string) => `This export is from project '${source}' but you're importing into '${target}'. Continue anyway?`,
+ importReservedConfirm: (senders: string) => `This export contains messages attributed to reserved agent/system identities (${senders}). Importing will replay them as those agents — only do this for a legitimate disaster-recovery restore. Continue?`,
+ importDuplicateConfirm: (error: string) => `${error}\n\nThis file looks like it was already imported. Re-import will duplicate every message. Continue anyway?`,
+ importStatus: (imported: number, total: number, skipped: number, errors: number) => (
+ <>
+ Imported {imported} / {total}
+ {skipped > 0 && ` · skipped ${skipped}`}
+ {errors > 0 && ` · ${errors} errors`}
+ >
+ ),
+ },
+ ko: {
+ title: "프로젝트 히스토리",
+ tooltip: (
+ <>
+
프로젝트 히스토리 - 이 프로젝트의 전체 AgentChattr 채팅 기록을 내보내거나 가져옵니다. 백업, 마이그레이션, 재설치 후 복구에 유용합니다.
+ >
+ ),
+ export: (projectId: string) => `${projectId} 채팅 내보내기`,
+ import: "히스토리 가져오기…",
+ importing: "가져오는 중…",
+ autoRestore: "AC 재시작 후 최신 스냅샷 자동 복구",
+ autoSnapshots: "자동 스냅샷 (재시작 전)",
+ restore: "복구",
+ restoreConfirm: (name: string) => `스냅샷 ${name}을(를) 복구할까요? 모든 메시지가 AgentChattr를 통해 재생되며(원본 발신자 표시), 채팅에 이미 있는 내용이 중복될 수 있습니다. 계속하시겠습니까?`,
+ importMismatchConfirm: (source: string, target: string) => `이 내보내기 파일은 '${source}' 프로젝트에서 생성되었지만, 현재 '${target}' 프로젝트로 가져오려 합니다. 계속하시겠습니까?`,
+ importReservedConfirm: (senders: string) => `이 파일에는 예약된 에이전트/시스템 식별자(${senders})가 발신자로 표시된 메시지가 포함되어 있습니다. 가져오기를 진행하면 해당 에이전트가 말하는 것처럼 메시지가 재생됩니다. 재난 복구 상황에서만 사용하세요. 계속하시겠습니까?`,
+ importDuplicateConfirm: (error: string) => `${error}\n\n이 파일은 이미 가져온 것 같습니다. 다시 가져오면 모든 메시지가 중복됩니다. 계속하시겠습니까?`,
+ importStatus: (imported: number, total: number, skipped: number, errors: number) => (
+ <>
+ 가져옴: {imported} / {total}
+ {skipped > 0 && ` · 건너뜀 ${skipped}`}
+ {errors > 0 && ` · 오류 ${errors}`}
+ >
+ ),
+ },
+} as const;
interface ProjectHistoryWidgetProps {
projectId: string;
@@ -37,6 +91,8 @@ const MAX_BYTES = 10 * 1024 * 1024;
* project, and renders a small progress / result block.
*/
export default function ProjectHistoryWidget({ projectId }: ProjectHistoryWidgetProps) {
+ const { locale } = useLocale();
+ const t = COPY[locale];
const fileRef = useRef
(null);
const [busy, setBusy] = useState<"export" | "import" | "restore" | null>(null);
const [error, setError] = useState(null);
@@ -96,9 +152,7 @@ export default function ProjectHistoryWidget({ projectId }: ProjectHistoryWidget
}, [projectId]);
const restoreSnapshot = async (name: string) => {
- const ok = window.confirm(
- `Restore snapshot ${name}? This will replay every message through AgentChattr (tagged by the original sender) and may duplicate history already in the chat. Continue?`,
- );
+ const ok = window.confirm(t.restoreConfirm(name));
if (!ok) return;
setBusy("restore");
setError(null);
@@ -180,9 +234,7 @@ export default function ProjectHistoryWidget({ projectId }: ProjectHistoryWidget
// confusing 409 in the error block.
let allowMismatch = false;
if (parsed.project_id && parsed.project_id !== projectId) {
- const ok = window.confirm(
- `This export is from project '${parsed.project_id}' but you're importing into '${projectId}'. Continue anyway?`,
- );
+ const ok = window.confirm(t.importMismatchConfirm(parsed.project_id, projectId));
if (!ok) {
setBusy(null);
return;
@@ -208,9 +260,7 @@ export default function ProjectHistoryWidget({ projectId }: ProjectHistoryWidget
}
}
if (offenders.size > 0) {
- const ok = window.confirm(
- `This export contains messages attributed to reserved agent/system identities (${[...offenders].join(", ")}). Importing will replay them as those agents — only do this for a legitimate disaster-recovery restore. Continue?`,
- );
+ const ok = window.confirm(t.importReservedConfirm([...offenders].join(", ")));
if (!ok) {
setBusy(null);
return;
@@ -238,9 +288,7 @@ export default function ProjectHistoryWidget({ projectId }: ProjectHistoryWidget
let r = await post({});
let data = await r.json().catch(() => null);
if (r.status === 409 && data && typeof data.error === "string" && /already imported/i.test(data.error)) {
- const ok = window.confirm(
- `${data.error}\n\nThis file looks like it was already imported. Re-import will duplicate every message. Continue anyway?`,
- );
+ const ok = window.confirm(t.importDuplicateConfirm(data.error));
if (!ok) {
setBusy(null);
return;
@@ -264,9 +312,9 @@ export default function ProjectHistoryWidget({ projectId }: ProjectHistoryWidget
return (
- Project History
+ {t.title}
- Project History — export or import the full AgentChattr chat history for this project. Useful for backup, migration, or resuming after a fresh install.
+ {t.tooltip}
@@ -277,7 +325,7 @@ export default function ProjectHistoryWidget({ projectId }: ProjectHistoryWidget
className="px-2 py-0.5 text-[10px] text-accent border border-accent/40 rounded hover:bg-accent/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title="Download a JSON snapshot of this project's chat history"
>
- {busy === "export" ? "…" : `Export ${projectId} chat`}
+ {busy === "export" ? "…" : t.export(projectId)}
- {busy === "import" ? "Importing…" : "Import history…"}
+ {busy === "import" ? t.importing : t.import}
- Imported {result.imported} / {result.total}
- {result.skipped > 0 && ` · skipped ${result.skipped}`}
- {result.errors.length > 0 && ` · ${result.errors.length} errors`}
+ {t.importStatus(result.imported, result.total, result.skipped, result.errors.length)}
)}
@@ -320,12 +366,12 @@ export default function ProjectHistoryWidget({ projectId }: ProjectHistoryWidget
saveAutoRestore(next).catch(() => setAutoRestore(!next));
}}
/>
- Auto-restore newest snapshot after AC restart
+ {t.autoRestore}
{snapshots.length > 0 && (
- Auto-snapshots (before restart)
+ {t.autoSnapshots}
{snapshots.map((s) => {
const date = new Date(s.mtime);
@@ -348,7 +394,7 @@ export default function ProjectHistoryWidget({ projectId }: ProjectHistoryWidget
className="px-1.5 py-0.5 text-[10px] text-accent border border-accent/40 rounded hover:bg-accent/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title={`Restore ${s.name}`}
>
- {busy === "restore" ? "…" : "Restore"}
+ {busy === "restore" ? "…" : t.restore}
);
diff --git a/src/components/ScheduledTriggerWidget.tsx b/src/components/ScheduledTriggerWidget.tsx
index cb7167e..1821b04 100644
--- a/src/components/ScheduledTriggerWidget.tsx
+++ b/src/components/ScheduledTriggerWidget.tsx
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import InfoTooltip from "./InfoTooltip";
+import { useLocale } from "@/components/LocaleProvider";
interface ScheduledTriggerWidgetProps {
projectId: string;
@@ -28,6 +29,59 @@ interface TriggerInfo {
durationMin: number | null; // last-used, persisted for idle reloads
}
+const COPY = {
+ en: {
+ label: (running: boolean, auto: boolean) => `Scheduled Trigger${running ? (auto ? " (auto)" : " (running)") : ""}`,
+ tooltip: (
+ <>
+
Scheduled Trigger sends a periodic message to all agents on a timer. Use this to keep the autonomous workflow running overnight. First message fires after the configured interval, not immediately.
+ >
+ ),
+ autoTriggerOn: "Auto-trigger ON — trigger follows batch lifecycle",
+ autoTriggerOff: "Auto-trigger OFF — manual start/stop only",
+ auto: "Auto ",
+ message: "Message",
+ sendEvery: "Send every",
+ minFor: "min for",
+ hours: "hours",
+ starting: "Starting…",
+ startTrigger: "Start Trigger",
+ sending: "Sending:",
+ running: "Running",
+ next: "Next: ",
+ stopsIn: "Stops in: ",
+ untilStopped: "(until stopped)",
+ stopping: "Stopping…",
+ stopTrigger: "Stop Trigger",
+ autoStopStatus: "Batch complete — trigger paused. Waiting for next batch.",
+ },
+ ko: {
+ label: (running: boolean, auto: boolean) => `예약 트리거${running ? (auto ? " (자동)" : " (실행 중)") : ""}`,
+ tooltip: (
+ <>
+
예약 트리거 - 타이머에 따라 모든 에이전트에게 주기적으로 메시지를 보냅니다. 야간 자율 워크플로우를 계속 돌릴 때 사용하세요. 첫 메시지는 즉시가 아니라 설정한 간격 후에 전송됩니다.
+ >
+ ),
+ autoTriggerOn: "자동 트리거 ON - 배치 생명주기에 따라 트리거가 동작합니다",
+ autoTriggerOff: "자동 트리거 OFF - 수동 시작/중지만 가능합니다",
+ auto: "자동 ",
+ message: "메시지",
+ sendEvery: "전송 간격: ",
+ minFor: "분 마다, ",
+ hours: "시간 동안",
+ starting: "시작 중…",
+ startTrigger: "트리거 시작",
+ sending: "전송 중:",
+ running: "실행 중",
+ next: "다음 전송: ",
+ stopsIn: "종료까지: ",
+ untilStopped: "(중지할 때까지)",
+ stopping: "중지 중…",
+ stopTrigger: "트리거 중지",
+ autoStopStatus: "배치 완료 — 트리거 일시 중지. 다음 배치를 기다리는 중.",
+ },
+} as const;
+
// #406 / quadwork#269: trigger duration is now a free-typed numeric
// hours input. Defaults / bounds match the issue: default 3 hours,
// 0.1h min (≈6 minute test runs), 24h cap as a safety rail, decimals
@@ -86,6 +140,8 @@ function formatCountdown(ms: number): string {
* project picks up the last-used message + running status.
*/
export default function ScheduledTriggerWidget({ projectId }: ScheduledTriggerWidgetProps) {
+ const { locale } = useLocale();
+ const t = COPY[locale];
const [trigger, setTrigger] = useState
(null);
const [message, setMessage] = useState("");
const [intervalMin, setIntervalMin] = useState(15);
@@ -158,31 +214,31 @@ export default function ScheduledTriggerWidget({ projectId }: ScheduledTriggerWi
const r = await fetch("/api/triggers");
if (!r.ok) throw new Error(`${r.status}`);
const data: Record = await r.json();
- const t = data[projectId] || null;
- setTrigger(t);
- if (t) {
+ const tr = data[projectId] || null;
+ setTrigger(tr);
+ if (tr) {
// Read dirty flags + current message from refs, NOT from the
// closure — `load` is memoized on `projectId` alone so the
// polling effect can keep a stable 5s cadence. Without the
// refs, a later poll would still see the initial empty
// message / clean flags and overwrite mid-edit changes.
- if (t.message && !messageRef.current) {
- setMessage(t.message);
- messageRef.current = t.message;
+ if (tr.message && !messageRef.current) {
+ setMessage(tr.message);
+ messageRef.current = tr.message;
}
if (!intervalDirtyRef.current) {
- if (t.enabled && t.interval) {
- const mins = Math.max(1, Math.round(t.interval / 60000));
+ if (tr.enabled && tr.interval) {
+ const mins = Math.max(1, Math.round(tr.interval / 60000));
setIntervalMin(mins);
setIntervalDraft(String(mins));
- } else if (typeof t.intervalMin === "number" && t.intervalMin > 0) {
- setIntervalMin(t.intervalMin);
- setIntervalDraft(String(t.intervalMin));
+ } else if (typeof tr.intervalMin === "number" && tr.intervalMin > 0) {
+ setIntervalMin(tr.intervalMin);
+ setIntervalDraft(String(tr.intervalMin));
}
}
- if (!durationDirtyRef.current && typeof t.durationMin === "number" && t.durationMin >= 0) {
- setDurationMin(t.durationMin);
- setDurationHoursDraft(minutesToHoursStr(t.durationMin));
+ if (!durationDirtyRef.current && typeof tr.durationMin === "number" && tr.durationMin >= 0) {
+ setDurationMin(tr.durationMin);
+ setDurationHoursDraft(minutesToHoursStr(tr.durationMin));
}
}
setError(null);
@@ -350,7 +406,7 @@ export default function ScheduledTriggerWidget({ projectId }: ScheduledTriggerWi
if (hasItems && data.complete && triggerRef.current?.enabled) {
await fetch(`/api/triggers/${encodeURIComponent(projectId)}/stop`, { method: "POST" });
setAutoTriggered(false);
- setAutoStatus("Batch complete — trigger paused. Waiting for next batch.");
+ setAutoStatus(t.autoStopStatus);
await load();
}
return;
@@ -360,7 +416,7 @@ export default function ScheduledTriggerWidget({ projectId }: ScheduledTriggerWi
if (hasItems && data.complete && !prev.complete && triggerRef.current?.enabled) {
await fetch(`/api/triggers/${encodeURIComponent(projectId)}/stop`, { method: "POST" });
setAutoTriggered(false);
- setAutoStatus("Batch complete — trigger paused. Waiting for next batch.");
+ setAutoStatus(t.autoStopStatus);
await load();
return;
}
@@ -382,7 +438,7 @@ export default function ScheduledTriggerWidget({ projectId }: ScheduledTriggerWi
await load();
}
} catch { /* non-fatal */ }
- }, [projectId, durationHoursDraft, intervalDraft, initialMessage, load]);
+ }, [projectId, durationHoursDraft, intervalDraft, initialMessage, load, t.autoStopStatus]);
useEffect(() => {
if (!autoTrigger) return;
@@ -398,10 +454,10 @@ export default function ScheduledTriggerWidget({ projectId }: ScheduledTriggerWi
- Scheduled Trigger{running ? (autoTriggered ? " (auto)" : " (running)") : ""}
+ {t.label(running, autoTriggered)}
- Scheduled Trigger sends a periodic message to all agents on a timer. Use this to keep the autonomous workflow running overnight. First message fires after the configured interval, not immediately.
+ {t.tooltip}
@@ -410,14 +466,14 @@ export default function ScheduledTriggerWidget({ projectId }: ScheduledTriggerWi
- Auto {autoTrigger ? "●" : "○"}
+ {t.auto}{autoTrigger ? "●" : "○"}
@@ -430,7 +486,7 @@ export default function ScheduledTriggerWidget({ projectId }: ScheduledTriggerWi
{!running ? (
) : (
-
Sending:
+
{t.sending}
{(trigger?.message || message).slice(0, 400)}
@@ -499,18 +555,18 @@ export default function ScheduledTriggerWidget({ projectId }: ScheduledTriggerWi
- Running
+ {t.running}
- Next: {countdown}
- {expiresCountdown && Stops in: {expiresCountdown} }
- {!trigger?.expiresAt && (until stopped) }
+ {t.next}{countdown}
+ {expiresCountdown && {t.stopsIn}{expiresCountdown} }
+ {!trigger?.expiresAt && {t.untilStopped} }
- {busy ? "Stopping…" : "Stop Trigger"}
+ {busy ? t.stopping : t.stopTrigger}
)}
diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx
index 0bb825b..cc1df01 100644
--- a/src/components/SettingsPage.tsx
+++ b/src/components/SettingsPage.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useSearchParams } from "next/navigation";
+import { useLocale } from "@/components/LocaleProvider";
interface AgentConfig {
display_name: string;
@@ -53,6 +54,155 @@ const BACKENDS: { value: string; label: string }[] = [
];
const MODELS = ["opus", "sonnet", "haiku"];
+const COPY = {
+ en: {
+ loading: "Loading...",
+ title: "Settings",
+ save: "Save",
+ saving: "Saving...",
+ saved: "Saved",
+ operatorIdentity: "Operator Identity",
+ yourNameInChat: "Your name in chat",
+ language: "Language",
+ operatorHelp:
+ "Shows next to your messages in the AgentChattr chat panel. Defaults to user if blank. Allowed: 1-32 letters, digits, dash, underscore (matches AgentChattr name rules; other characters are stripped server-side). Reserved agent names like head, dev, re1, re2, and system are rejected and fall back to user.",
+ global: "Global",
+ dashboardPort: "QuadWork Dashboard Port",
+ agentChattrUrlGlobal: "AgentChattr URL (global override)",
+ globalHelp:
+ "The dashboard binds to the QuadWork port. The AgentChattr URL is the v1 fallback; new projects use a per-project AgentChattr clone and ignore this field.",
+ defaults: "Defaults",
+ defaultAgentCli: "Default agent CLI",
+ reviewerGithubUser: "Reviewer GitHub user",
+ reviewerGithubToken: "Reviewer GitHub token",
+ configured: "Configured",
+ notConfigured: "Not configured",
+ pasteNewToken: "Paste new token",
+ defaultsHelp:
+ "The default CLI seeds new project agents. The reviewer GitHub user/token are used by RE1/RE2 to post PR review comments without your personal token. The token is written to ~/.quadwork/reviewer-token (mode 0600) and is never returned by the API.",
+ system: "System",
+ keepAwake: "Keep Awake",
+ on: "on",
+ off: "off",
+ stop: "Stop",
+ start: "Start",
+ keepAwakeHelp:
+ "Prevents this machine from sleeping while agents are running. Machine-level (not per-project) - uses caffeinate on macOS.",
+ cleanup: "Cleanup",
+ cleanupIntro:
+ "Each project now has its own AgentChattr clone at ~/.quadwork/{id}/agentchattr (~77 MB). After all projects are migrated, the legacy global install can be removed:",
+ cleanupSingle: "To remove a single project's clone and config entry:",
+ cleanupHelp:
+ "Both commands prompt for confirmation. Worktrees and source repos are never touched. See npx quadwork --help or the README's Disk Usage section for details.",
+ activeProjects: "Active Projects",
+ projectName: "Project Name",
+ githubRepo: "GitHub Repo",
+ workingDirectory: "Working Directory",
+ agents: "Agents",
+ name: "Name",
+ command: "Command",
+ model: "Model",
+ cwd: "CWD",
+ agentsMd: "AGENTS.md",
+ owner: "Owner",
+ reviewer: "Reviewer",
+ builder: "Builder",
+ edit: "edit",
+ oneCliInstalled: "Only one CLI installed - install the other for more options",
+ agentsMdPlaceholder: "# AGENTS.md seed content for this agent...",
+ agentChattr: "AgentChattr",
+ agentChattrUrl: "AgentChattr URL",
+ sessionToken: "Session Token",
+ optional: "(optional)",
+ mcpHttpPort: "MCP HTTP Port",
+ mcpSsePort: "MCP SSE Port",
+ restoreProject: "Restore Project",
+ archive: "Archive",
+ remove: "Remove",
+ removeQuestion: "Remove?",
+ confirm: "Confirm",
+ cancel: "Cancel",
+ addProject: "+ Add Project",
+ archived: "Archived",
+ restore: "Restore",
+ confirmRemove: "Confirm Remove",
+ newProject: "New Project",
+ },
+ ko: {
+ loading: "로딩 중...",
+ title: "설정",
+ save: "저장",
+ saving: "저장 중...",
+ saved: "저장됨",
+ operatorIdentity: "운영자 정보",
+ yourNameInChat: "채팅에서의 이름",
+ language: "언어",
+ operatorHelp:
+ "AgentChattr 채팅 패널에서 내 메시지 옆에 표시됩니다. 비워두면 기본값은 user입니다. 허용: 1-32자의 영문, 숫자, 하이픈, 언더스코어(AgentChattr 이름 규칙과 동일). 다른 문자는 서버에서 제거됩니다. head, dev, re1, re2, system 같은 예약 이름은 거부되고 user로 대체됩니다.",
+ global: "전역",
+ dashboardPort: "QuadWork 대시보드 포트",
+ agentChattrUrlGlobal: "AgentChattr URL (전역 오버라이드)",
+ globalHelp:
+ "대시보드는 QuadWork 포트에 바인딩됩니다. AgentChattr URL은 v1 호환용 기본값이며, 새 프로젝트는 프로젝트별 AgentChattr 클론을 사용하므로 이 필드는 무시됩니다.",
+ defaults: "기본값",
+ defaultAgentCli: "기본 에이전트 CLI",
+ reviewerGithubUser: "리뷰어 GitHub 사용자",
+ reviewerGithubToken: "리뷰어 GitHub 토큰",
+ configured: "설정됨",
+ notConfigured: "미설정",
+ pasteNewToken: "새 토큰 붙여넣기",
+ defaultsHelp:
+ "기본 CLI는 새 프로젝트 에이전트의 초기값으로 사용됩니다. 리뷰어 GitHub 사용자/토큰은 개인 토큰 없이 RE1/RE2가 PR 리뷰 댓글을 남길 때 사용됩니다. 토큰은 ~/.quadwork/reviewer-token (권한 0600)에 저장되며 API로는 반환되지 않습니다.",
+ system: "시스템",
+ keepAwake: "절전 방지",
+ on: "켜짐",
+ off: "꺼짐",
+ stop: "중지",
+ start: "시작",
+ keepAwakeHelp:
+ "에이전트가 실행되는 동안 이 기기가 잠들지 않도록 합니다. 기기 전체 설정이며(프로젝트별 아님) macOS에서는 caffeinate를 사용합니다.",
+ cleanup: "정리",
+ cleanupIntro:
+ "각 프로젝트는 이제 ~/.quadwork/{id}/agentchattr (~77 MB)에 자체 AgentChattr 클론을 가집니다. 모든 프로젝트 마이그레이션이 끝나면 예전 전역 설치는 제거할 수 있습니다:",
+ cleanupSingle: "특정 프로젝트의 클론과 설정 항목만 제거하려면:",
+ cleanupHelp:
+ "두 명령 모두 확인 절차가 있습니다. 워크트리와 소스 저장소는 건드리지 않습니다. 자세한 내용은 npx quadwork --help 또는 README의 Disk Usage 섹션을 참고하세요.",
+ activeProjects: "활성 프로젝트",
+ projectName: "프로젝트 이름",
+ githubRepo: "GitHub 저장소",
+ workingDirectory: "작업 디렉터리",
+ agents: "에이전트",
+ name: "이름",
+ command: "명령어",
+ model: "모델",
+ cwd: "작업 디렉터리",
+ agentsMd: "AGENTS.md",
+ owner: "소유자",
+ reviewer: "검토자",
+ builder: "개발자",
+ edit: "편집",
+ oneCliInstalled: "CLI 하나만 설치됨 - 더 많은 옵션을 위해 다른 CLI를 설치하세요",
+ agentsMdPlaceholder: "# 이 에이전트의 AGENTS.md 초기 내용...",
+ agentChattr: "AgentChattr",
+ agentChattrUrl: "AgentChattr URL",
+ sessionToken: "세션 토큰",
+ optional: "(선택)",
+ mcpHttpPort: "MCP HTTP 포트",
+ mcpSsePort: "MCP SSE 포트",
+ restoreProject: "프로젝트 복원",
+ archive: "보관",
+ remove: "제거",
+ removeQuestion: "제거할까요?",
+ confirm: "확인",
+ cancel: "취소",
+ addProject: "+ 프로젝트 추가",
+ archived: "보관됨",
+ restore: "복원",
+ confirmRemove: "제거 확인",
+ newProject: "새 프로젝트",
+ },
+} as const;
+
function Input({ label, value, onChange, onBlur, type = "text", placeholder }: {
label: string;
value: string;
@@ -99,6 +249,8 @@ function Select({ label, value, onChange, options }: {
}
export default function SettingsPage() {
+ const { locale, setLocale } = useLocale();
+ const t = COPY[locale];
const searchParams = useSearchParams();
const [config, setConfig] = useState(null);
const [saving, setSaving] = useState(false);
@@ -299,7 +451,7 @@ export default function SettingsPage() {
}
const newProject: ProjectConfig = {
id,
- name: "New Project",
+ name: t.newProject,
repo: "owner/repo",
working_dir: "",
agents,
@@ -389,18 +541,18 @@ export default function SettingsPage() {
setConfirmDelete(null);
};
- if (!config) return Loading...
;
+ if (!config) return {t.loading}
;
return (
-
Settings
+ {t.title}
- {saving ? "Saving..." : saved ? "Saved" : "Save"}
+ {saving ? t.saving : saved ? t.saved : t.save}
@@ -408,28 +560,48 @@ export default function SettingsPage() {
dashboard chat messages. Server-side validated to AC's
registry name rules (1–32 alnum + dash + underscore). */}
- Operator Identity
-
+
{t.operatorIdentity}
+
updateGlobal("operator_name" as keyof Config, v)}
placeholder="user"
/>
+
+
{t.language}
+
+ {(["en", "ko"] as const).map((code) => {
+ const active = locale === code;
+ return (
+ setLocale(code)}
+ className={`px-3 py-1.5 text-[12px] border transition-colors ${
+ active
+ ? "border-accent bg-accent text-bg"
+ : "border-border text-text-muted hover:text-text hover:border-accent"
+ }`}
+ >
+ {code}
+
+ );
+ })}
+
+
- Shows next to your messages in the AgentChattr chat panel. Defaults to user if blank.
- Allowed: 1–32 letters, digits, dash, underscore (matches AgentChattr's name rules; other characters are stripped server-side).
- Reserved agent names like head, dev, re1, re2, and system are rejected and fall back to user.
+ {t.operatorHelp}
{/* Global Settings (#212: full-width grid, every section visible) */}
{/* Defaults — default agent CLI + reviewer credentials (#212) */}
- Defaults
+ {t.defaults}
updateGlobal("default_backend" as keyof Config, v)}
options={BACKENDS.map((b) => ({
@@ -467,17 +638,17 @@ export default function SettingsPage() {
}))}
/>
updateGlobal("reviewer_github_user" as keyof Config, v)}
placeholder="reviewer-bot"
/>
-
Reviewer GitHub token
+
{t.reviewerGithubToken}
- {reviewerTokenExists === null ? "…" : reviewerTokenExists ? "Configured" : "Not configured"}
+ {reviewerTokenExists === null ? "…" : reviewerTokenExists ? t.configured : t.notConfigured}
@@ -485,7 +656,7 @@ export default function SettingsPage() {
type="password"
value={reviewerTokenInput}
onChange={(e) => setReviewerTokenInput(e.target.value)}
- placeholder="Paste new token"
+ placeholder={t.pasteNewToken}
className="flex-1 bg-transparent border border-border px-2 py-1 text-[11px] text-text outline-none focus:border-accent font-mono"
/>
- {reviewerTokenSaving ? "Saving…" : "Save"}
+ {reviewerTokenSaving ? t.saving : t.save}
- The default CLI seeds new project agents. The reviewer GitHub user/token are
- used by RE1/RE2 to post PR review comments without your personal
- token. The token is written to{" "}
- ~/.quadwork/reviewer-token{" "}
- (mode 0600) and is never returned by the API.
+ {t.defaultsHelp}
{/* System — Keep Awake (#212) */}
- System
+ {t.system}
- Keep Awake — {keepAwakeActive ? "on" : "off"}
+ {t.keepAwake} - {keepAwakeActive ? t.on : t.off}
- {keepAwakeBusy ? "…" : keepAwakeActive ? "Stop" : "Start"}
+ {keepAwakeBusy ? "…" : keepAwakeActive ? t.stop : t.start}
- Prevents this machine from sleeping while agents are running. Machine-level
- (not per-project) — uses caffeinate on macOS.
+ {t.keepAwakeHelp}
{/* Cleanup commands (#212 / #189) */}
- Cleanup
+ {t.cleanup}
- Each project now has its own AgentChattr clone at
+ {t.cleanupIntro.split("~/.quadwork/{id}/agentchattr")[0]}
{" "}~/.quadwork/{id}/agentchattr
- {" "}(~77 MB). After all projects are migrated, the legacy global install can be removed:
+ {t.cleanupIntro.includes("~/.quadwork/{id}/agentchattr")
+ ? t.cleanupIntro.split("~/.quadwork/{id}/agentchattr")[1]
+ : ""}
npx quadwork cleanup --legacy
-
To remove a single project's clone and config entry:
+
{t.cleanupSingle}
npx quadwork cleanup --project <id>
-
- Both commands prompt for confirmation. Worktrees and source repos are never touched.
- See npx quadwork --help or the README's Disk Usage section for details.
-
+
{t.cleanupHelp}
@@ -550,7 +715,7 @@ export default function SettingsPage() {
{/* Per-project settings */}
- Active Projects
+ {t.activeProjects}
{config.projects.filter((p) => !p.archived).map((project) => {
const idx = config.projects.indexOf(project);
@@ -567,18 +732,18 @@ export default function SettingsPage() {
{/* Basic project info */}
renameProject(idx, v)}
/>
updateProject(idx, { repo: v })}
placeholder="owner/repo"
/>
updateProject(idx, { working_dir: v })}
placeholder="/path/to/project"
@@ -587,27 +752,24 @@ export default function SettingsPage() {
{/* Agents table */}
-
Agents
+
{t.agents}
{cliStatus && (cliStatus.claude ? !cliStatus.codex : cliStatus.codex) && (
- {cliStatus.claude ? "Only Claude Code" : "Only Codex CLI"} is installed.
-
-
- Install {cliStatus.claude ? "Codex" : "Claude Code"} for more backend options:
+ {t.oneCliInstalled}
-
+
{cliStatus.claude ? "npm install -g codex" : "npm install -g @anthropic-ai/claude-code"}
)}
- Name
- Command
- Model
- CWD
- AGENTS.md
+ {t.name}
+ {t.command}
+ {t.model}
+ {t.cwd}
+ {t.agentsMd}
{Object.entries(project.agents || {}).map(([agentId, agent]) => (
@@ -619,7 +781,7 @@ export default function SettingsPage() {
className="bg-transparent text-[11px] text-text font-semibold outline-none border border-border px-1 py-0.5 focus:border-accent"
/>
- {agentId === "head" ? "Owner" : agentId.startsWith("reviewer") ? "Reviewer" : "Builder"}
+ {agentId === "head" ? t.owner : agentId.startsWith("reviewer") ? t.reviewer : t.builder}
updateAgent(idx, agentId, { command: e.target.value })}
className="bg-transparent text-[11px] text-text outline-none border border-border px-1 py-0.5 focus:border-accent"
title={cliStatus && Object.values(cliStatus).filter(Boolean).length === 1
- ? `Only one CLI installed — install the other for more options`
+ ? t.oneCliInstalled
: undefined}
>
{BACKENDS.map((b) => (
@@ -660,7 +822,7 @@ export default function SettingsPage() {
onClick={() => setExpanded({ ...expanded, [`${project.id}-${agentId}-md`]: !expanded[`${project.id}-${agentId}-md`] })}
className="text-[10px] text-text-muted hover:text-accent transition-colors text-left px-1"
>
- {expanded[`${project.id}-${agentId}-md`] ? "▾ edit" : "▸ edit"}
+ {expanded[`${project.id}-${agentId}-md`] ? `▾ ${t.edit}` : `▸ ${t.edit}`}
{expanded[`${project.id}-${agentId}-md`] && (
@@ -668,7 +830,7 @@ export default function SettingsPage() {
diff --git a/src/components/SetupWizard.tsx b/src/components/SetupWizard.tsx
index ac56ff5..def867f 100644
--- a/src/components/SetupWizard.tsx
+++ b/src/components/SetupWizard.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
+import { useLocale } from "@/components/LocaleProvider";
/* ── Types ─────────────────────────────────────────────────────────────── */
@@ -23,14 +24,170 @@ interface Repo {
/* ── Constants ─────────────────────────────────────────────────────────── */
-const INITIAL_STEPS: Step[] = [
- { id: "name", label: "Project Name", subtitle: "Name your project", status: "active" },
- { id: "repo", label: "GitHub Repo", subtitle: "Connect a repository", status: "pending" },
- { id: "models", label: "Agent Models", subtitle: "Configure CLI backends", status: "pending" },
- { id: "workdir", label: "Working Directory", subtitle: "Set the local path", status: "pending" },
- { id: "workspaces", label: "Create Workspaces", subtitle: "Worktrees + seed files", status: "pending" },
- { id: "launch", label: "Ready to Launch", subtitle: "Review & start", status: "pending" },
-];
+const COPY = {
+ en: {
+ title: "Set Up Your AI Dev Team",
+ subtitle: "Configure agents, connect your repo, and launch a multi-agent development workflow in minutes.",
+ steps: {
+ name: { label: "Project Name", subtitle: "Name your project" },
+ repo: { label: "GitHub Repo", subtitle: "Connect a repository" },
+ models: { label: "Agent Models", subtitle: "Configure CLI backends" },
+ workdir: { label: "Working Directory", subtitle: "Set the local path" },
+ workspaces: { label: "Create Workspaces", subtitle: "Worktrees + seed files" },
+ launch: { label: "Ready to Launch", subtitle: "Review & start" },
+ },
+ workdir: {
+ title: "Where is your project?",
+ desc: "Your project's git repository on your local machine. QuadWork will create 4 agent workspaces next to this directory.",
+ scanning: "Scanning for existing clone...",
+ found: "Found existing clone",
+ useThis: "Use this",
+ chooseDifferent: "Choose different path",
+ noClone: (repo: string) => <>No local clone found for {repo} >,
+ setupWillClone: "Setup will clone it to:",
+ cloneHere: "Clone here & continue",
+ next: "Next",
+ layout: "Workspace layout",
+ },
+ nameStep: {
+ title: "Name your project",
+ desc: "This name identifies your project in the dashboard and agent configs.",
+ placeholder: "e.g. My DeFi App",
+ next: "Next",
+ },
+ repoStep: {
+ title: "Connect a GitHub repository",
+ desc: "Select an existing repo or enter one manually. Agents will work within this repo.",
+ showingRepos: (owner: string) => <>Showing repos for {owner} >,
+ searchPlaceholder: "Search repos...",
+ loading: "Loading...",
+ noRepos: "No repos found.",
+ enterManually: "Enter manually instead",
+ backToList: "Back to repo list",
+ private: "private",
+ enableProtection: (repo: string) => <>Enable branch protection on main>,
+ protectionDesc: "Run this after setup, or configure in GitHub UI:",
+ copy: "copy",
+ verifying: "Verifying...",
+ verify: "Verify & Continue",
+ },
+ modelsStep: {
+ title: "Configure agent CLI backends",
+ desc: "Each agent runs its own CLI instance. Pick the backend for each role.",
+ next: "Next",
+ },
+ workspacesStep: {
+ title: "Create workspaces",
+ desc: "This creates git worktrees for each agent and writes seed configuration files (AGENTS.md, CLAUDE.md) into each workspace.",
+ creating: "Creating...",
+ create: "Create Worktrees & Seed Files",
+ },
+ launchStep: {
+ title: "Ready to launch",
+ desc: "Everything is configured. Review the summary and launch your AI dev team.",
+ teamRoster: "Team Roster",
+ customPorts: "Custom ports",
+ autoDetected: (port: number) => `auto-detected: ${port}`,
+ redirecting: "Project saved. Redirecting to dashboard...",
+ launching: "Launching...",
+ launched: "Launched!",
+ launch: "Launch Project",
+ },
+ preview: {
+ title: "Configuration Preview",
+ project: "Project",
+ repo: "Repository",
+ branchProtection: "+ branch protection",
+ backends: "Backends",
+ reviewer: "Reviewer",
+ directory: "Directory",
+ status: "Status",
+ },
+ setupComplete: "Setup complete!",
+ redirectingToDashboard: "Redirecting to project dashboard...",
+ },
+ ko: {
+ title: "AI 개발 팀 설정하기",
+ subtitle: "에이전트를 설정하고, 저장소를 연결하고, 몇 분 안에 멀티 에이전트 개발 워크플로우를 시작하세요.",
+ steps: {
+ name: { label: "프로젝트 이름", subtitle: "프로젝트 이름 정하기" },
+ repo: { label: "GitHub 저장소", subtitle: "저장소 연결하기" },
+ models: { label: "에이전트 모델", subtitle: "CLI 백엔드 구성" },
+ workdir: { label: "작업 디렉터리", subtitle: "로컬 경로 지정" },
+ workspaces: { label: "워크스페이스 생성", subtitle: "워크트리 + 초기 파일" },
+ launch: { label: "실행 준비", subtitle: "검토 후 시작" },
+ },
+ workdir: {
+ title: "프로젝트 위치는 어디인가요?",
+ desc: "로컬 머신에 있는 프로젝트의 Git 저장소 경로입니다. QuadWork는 이 디렉터리 옆에 4개의 에이전트 워크스페이스를 생성합니다.",
+ scanning: "기존 클론을 찾는 중...",
+ found: "기존 클론을 찾았습니다",
+ useThis: "이 경로 사용",
+ chooseDifferent: "다른 경로 선택",
+ noClone: (repo: string) => <>{repo} 의 로컬 클론을 찾지 못했습니다>,
+ setupWillClone: "설치 시 다음 경로로 클론합니다:",
+ cloneHere: "여기에 클론하고 계속",
+ next: "다음",
+ layout: "워크스페이스 구조",
+ },
+ nameStep: {
+ title: "프로젝트 이름 정하기",
+ desc: "이 이름은 대시보드와 에이전트 설정에서 프로젝트를 식별하는 데 사용됩니다.",
+ placeholder: "예: 내 DeFi 앱",
+ next: "다음",
+ },
+ repoStep: {
+ title: "GitHub 저장소 연결",
+ desc: "기존 저장소를 선택하거나 직접 입력하세요. 에이전트는 이 저장소 안에서 작업합니다.",
+ showingRepos: (owner: string) => <>{owner} 의 저장소를 표시하는 중>,
+ searchPlaceholder: "저장소 검색...",
+ loading: "로딩 중...",
+ noRepos: "저장소를 찾지 못했습니다.",
+ enterManually: "직접 입력하기",
+ backToList: "저장소 목록으로 돌아가기",
+ private: "비공개",
+ enableProtection: (repo: string) => <> main 브랜치 보호 사용>,
+ protectionDesc: "설치 후 이 명령을 실행하거나 GitHub UI에서 직접 설정하세요:",
+ copy: "복사",
+ verifying: "확인 중...",
+ verify: "확인 후 계속",
+ },
+ modelsStep: {
+ title: "에이전트 CLI 백엔드 구성",
+ desc: "각 에이전트는 자체 CLI 인스턴스를 사용합니다. 역할별로 백엔드를 선택하세요.",
+ next: "다음",
+ },
+ workspacesStep: {
+ title: "워크스페이스 생성",
+ desc: "각 에이전트용 Git 워크트리를 만들고 각 워크스페이스에 초기 설정 파일(AGENTS.md, CLAUDE.md)을 작성합니다.",
+ creating: "생성 중...",
+ create: "워크트리 및 초기 파일 생성",
+ },
+ launchStep: {
+ title: "실행 준비 완료",
+ desc: "모든 설정이 끝났습니다. 요약을 확인하고 AI 개발 팀을 시작하세요.",
+ teamRoster: "팀 구성",
+ customPorts: "사용자 지정 포트",
+ autoDetected: (port: number) => `자동 감지: ${port}`,
+ redirecting: "프로젝트를 저장했습니다. 대시보드로 이동 중...",
+ launching: "실행 중...",
+ launched: "실행됨!",
+ launch: "프로젝트 실행",
+ },
+ preview: {
+ title: "설정 미리보기",
+ project: "프로젝트",
+ repo: "저장소",
+ branchProtection: "+ 브랜치 보호",
+ backends: "백엔드",
+ reviewer: "리뷰어",
+ directory: "디렉터리",
+ status: "상태",
+ },
+ setupComplete: "설정 완료!",
+ redirectingToDashboard: "프로젝트 대시보드로 이동 중...",
+ },
+} as const;
const BACKENDS: { value: string; label: string }[] = [
{ value: "claude", label: "Claude Code" },
@@ -49,6 +206,8 @@ const AGENTS = [
function WorkdirStep({ repo, workingDir, setWorkingDir, error, onNext }: {
repo: string; workingDir: string; setWorkingDir: (v: string) => void; error?: string; onNext: () => void;
}) {
+ const { locale } = useLocale();
+ const t = COPY[locale].workdir;
const [detecting, setDetecting] = useState(true);
const [detected, setDetected] = useState<{ found: boolean; path: string | null; suggested: string } | null>(null);
const [showManual, setShowManual] = useState(false);
@@ -69,23 +228,23 @@ function WorkdirStep({ repo, workingDir, setWorkingDir, error, onNext }: {
return (
-
Where is your project?
+
{t.title}
- Your project's git repository on your local machine. QuadWork will create 4 agent workspaces next to this directory.
+ {t.desc}
- {detecting &&
Scanning for existing clone...
}
+ {detecting &&
{t.scanning}
}
{!detecting && detected?.found && (
-
Found existing clone
+
{t.found}
{detected.path}
- Use this
+ {t.useThis}
{ setShowManual(true); setWorkingDir(""); }} className="px-3 py-1 text-[11px] text-text-muted border border-border hover:text-text transition-colors">
- Choose different path
+ {t.chooseDifferent}
@@ -93,15 +252,15 @@ function WorkdirStep({ repo, workingDir, setWorkingDir, error, onNext }: {
{!detecting && !detected?.found && !showManual && (
-
No local clone found for {repo}
-
Setup will clone it to:
+
{t.noClone(repo)}
+
{t.setupWillClone}
{detected?.suggested || `~/Projects/${slug}`}
- Clone here & continue
+ {t.cloneHere}
setShowManual(true)} className="px-3 py-1 text-[11px] text-text-muted border border-border hover:text-text transition-colors">
- Choose different path
+ {t.chooseDifferent}
@@ -116,7 +275,7 @@ function WorkdirStep({ repo, workingDir, setWorkingDir, error, onNext }: {
className="w-full bg-transparent border border-border px-2 py-1.5 text-[12px] text-text outline-none focus:border-accent mb-2"
/>
- Next
+ {t.next}
>
)}
@@ -124,7 +283,7 @@ function WorkdirStep({ repo, workingDir, setWorkingDir, error, onNext }: {
{error &&
{error}
}
-
Workspace layout
+
{t.layout}
{slug}/ ← your repo
{slug}-head/ ← Head agent
{slug}-dev/ ← Dev agent
@@ -137,7 +296,16 @@ function WorkdirStep({ repo, workingDir, setWorkingDir, error, onNext }: {
export default function SetupWizard() {
const router = useRouter();
- const [steps, setSteps] = useState
(INITIAL_STEPS);
+ const { locale, hydrated } = useLocale();
+ const t = COPY[locale];
+ const [steps, setSteps] = useState(() => [
+ { id: "name", label: t.steps.name.label, subtitle: t.steps.name.subtitle, status: "active" },
+ { id: "repo", label: t.steps.repo.label, subtitle: t.steps.repo.subtitle, status: "pending" },
+ { id: "models", label: t.steps.models.label, subtitle: t.steps.models.subtitle, status: "pending" },
+ { id: "workdir", label: t.steps.workdir.label, subtitle: t.steps.workdir.subtitle, status: "pending" },
+ { id: "workspaces", label: t.steps.workspaces.label, subtitle: t.steps.workspaces.subtitle, status: "pending" },
+ { id: "launch", label: t.steps.launch.label, subtitle: t.steps.launch.subtitle, status: "pending" },
+ ]);
const [currentStep, setCurrentStep] = useState(0);
// Form state
@@ -182,6 +350,17 @@ export default function SetupWizard() {
const [autoDetectedPorts, setAutoDetectedPorts] = useState({ chattr: 0, mcpHttp: 0, mcpSse: 0 });
const [cliStatus, setCliStatus] = useState<{ claude: boolean; codex: boolean } | null>(null);
+ useEffect(() => {
+ setSteps((prev) => [
+ { id: "name", label: t.steps.name.label, subtitle: t.steps.name.subtitle, status: prev[0].status, error: prev[0].error },
+ { id: "repo", label: t.steps.repo.label, subtitle: t.steps.repo.subtitle, status: prev[1].status, error: prev[1].error },
+ { id: "models", label: t.steps.models.label, subtitle: t.steps.models.subtitle, status: prev[2].status, error: prev[2].error },
+ { id: "workdir", label: t.steps.workdir.label, subtitle: t.steps.workdir.subtitle, status: prev[3].status, error: prev[3].error },
+ { id: "workspaces", label: t.steps.workspaces.label, subtitle: t.steps.workspaces.subtitle, status: prev[4].status, error: prev[4].error },
+ { id: "launch", label: t.steps.launch.label, subtitle: t.steps.launch.subtitle, status: prev[5].status, error: prev[5].error },
+ ]);
+ }, [t]);
+
// Fetch CLI status on mount
useEffect(() => {
fetch("/api/cli-status")
@@ -493,6 +672,10 @@ export default function SetupWizard() {
const step = steps[currentStep];
+ if (!hydrated) {
+ return
;
+ }
+
/* ── Render ────────────────────────────────────────────────────────────── */
return (
@@ -500,10 +683,10 @@ export default function SetupWizard() {
{/* Header */}
- Set Up Your AI Dev Team
+ {t.title}
- Configure agents, connect your repo, and launch a multi-agent development workflow in minutes.
+ {t.subtitle}
@@ -552,14 +735,14 @@ export default function SetupWizard() {
{/* Step 1: Project Name */}
{step?.id === "name" && (
-
Name your project
+
{t.nameStep.title}
- This name identifies your project in the dashboard and agent configs.
+ {t.nameStep.desc}
setProjectName(e.target.value)}
- placeholder="e.g. My DeFi App"
+ placeholder={t.nameStep.placeholder}
className="w-full bg-transparent border border-border px-2 py-1.5 text-[12px] text-text outline-none focus:border-accent mb-4"
autoFocus
/>
@@ -568,7 +751,7 @@ export default function SetupWizard() {
disabled={!projectName.trim()}
className="px-4 py-1.5 bg-accent text-bg text-[12px] font-semibold hover:bg-accent-dim transition-colors disabled:opacity-50"
>
- Next
+ {t.nameStep.next || "Next"}
)}
@@ -576,9 +759,9 @@ export default function SetupWizard() {
{/* Step 2: GitHub Repo */}
{step?.id === "repo" && (
-
Connect a GitHub repository
+
{t.repoStep.title}
- Select an existing repo or enter one manually. Agents will work within this repo.
+ {t.repoStep.desc}
{!repoManual && (
@@ -623,16 +806,16 @@ export default function SetupWizard() {
)}
{activeOwner && (
- Showing repos for {activeOwner}
+ {t.repoStep.showingRepos(activeOwner)}
)}
setRepoSearch(e.target.value)}
- placeholder="Search repos..."
+ placeholder={t.repoStep.searchPlaceholder}
className="w-full bg-transparent border border-border px-2 py-1.5 text-[12px] text-text outline-none focus:border-accent mb-2"
/>
- {reposLoading &&
Loading...
}
+ {reposLoading &&
{t.repoStep.loading}
}
{filteredRepos.map((r) => (
{r.name}
- {r.isPrivate && private }
+ {r.isPrivate && {t.repoStep.private} }
{r.description && {r.description} }
))}
{!reposLoading && filteredRepos.length === 0 && (
-
No repos found.
+
{t.repoStep.noRepos}
)}
setRepoManual(true)}
className="text-[11px] text-text-muted hover:text-accent transition-colors mb-3 block"
>
- Enter manually instead
+ {t.repoStep.enterManually}
>
)}
@@ -672,7 +855,7 @@ export default function SetupWizard() {
onClick={() => setRepoManual(false)}
className="text-[11px] text-text-muted hover:text-accent transition-colors mb-3 block"
>
- Back to repo list
+ {t.repoStep.backToList}
>
)}
@@ -686,13 +869,13 @@ export default function SetupWizard() {
className="accent-accent"
/>
- Enable branch protection on main
+ {t.repoStep.enableProtection(repo || "owner/repo")}
{enableProtection && (
-
Run this after setup, or configure in GitHub UI:
+
{t.repoStep.protectionDesc}
{`gh api repos/${repo || "owner/repo"}/branches/main/protection -X PUT -f "required_pull_request_reviews[required_approving_review_count]=1" -f "enforce_admins=false" -f "required_status_checks=null" -f "restrictions=null"`}
@@ -701,7 +884,7 @@ export default function SetupWizard() {
onClick={() => navigator.clipboard.writeText(`gh api repos/${repo}/branches/main/protection -X PUT -f "required_pull_request_reviews[required_approving_review_count]=1" -f "enforce_admins=false" -f "required_status_checks=null" -f "restrictions=null"`)}
className="text-[10px] text-text-muted hover:text-accent shrink-0"
>
- copy
+ {t.repoStep.copy}
@@ -713,7 +896,7 @@ export default function SetupWizard() {
disabled={!repo || loading}
className="px-4 py-1.5 bg-accent text-bg text-[12px] font-semibold hover:bg-accent-dim transition-colors disabled:opacity-50"
>
- {loading ? "Verifying..." : "Verify & Continue"}
+ {loading ? t.repoStep.verifying : t.repoStep.verify}
)}
@@ -721,9 +904,11 @@ export default function SetupWizard() {
{/* Step 3: Agent Models */}
{step?.id === "models" && (
-
Configure agent CLI backends
+
+ {t.modelsStep.title}
+
- Each agent runs its own CLI instance. Pick the backend for each role.
+ {t.modelsStep.desc}
{/* Single-CLI friendly message */}
@@ -894,7 +1079,7 @@ export default function SetupWizard() {
onClick={goNext}
className="px-4 py-1.5 bg-accent text-bg text-[12px] font-semibold hover:bg-accent-dim transition-colors"
>
- Next
+ {t.modelsStep.next}
)}
@@ -913,9 +1098,9 @@ export default function SetupWizard() {
{/* Step 5: Create Workspaces */}
{step?.id === "workspaces" && (
-
Create workspaces
+
{t.workspacesStep.title}
- This creates git worktrees for each agent and writes seed configuration files (AGENTS.md, CLAUDE.md) into each workspace.
+ {t.workspacesStep.desc}
{step.error &&
{step.error}
}
{workspaceLog.length > 0 && (
@@ -930,7 +1115,7 @@ export default function SetupWizard() {
disabled={loading}
className="px-4 py-1.5 bg-accent text-bg text-[12px] font-semibold hover:bg-accent-dim transition-colors disabled:opacity-50"
>
- {loading ? "Creating..." : "Create Worktrees & Seed Files"}
+ {loading ? t.workspacesStep.creating : t.workspacesStep.create}
)}
@@ -938,15 +1123,15 @@ export default function SetupWizard() {
{/* Step 6: Ready to Launch */}
{step?.id === "launch" && (
-
Ready to launch
+
{t.launchStep.title}
- Everything is configured. Review the summary and launch your AI dev team.
+ {t.launchStep.desc}
{/* Team roster */}
- Team Roster
+ {t.launchStep.teamRoster}
{AGENTS.map((agent) => (
@@ -966,7 +1151,7 @@ export default function SetupWizard() {
onChange={(e) => setShowAdvanced(e.target.checked)}
className="accent-accent"
/>
-
Custom ports
+
{t.launchStep.customPorts}
{showAdvanced && (
@@ -982,7 +1167,7 @@ export default function SetupWizard() {
className="bg-transparent border border-border px-2 py-1 text-[11px] text-text outline-none focus:border-accent"
/>
{autoDetectedPorts.chattr > 0 && (
- auto-detected: {autoDetectedPorts.chattr}
+ {t.launchStep.autoDetected(autoDetectedPorts.chattr)}
)}
@@ -996,7 +1181,7 @@ export default function SetupWizard() {
className="bg-transparent border border-border px-2 py-1 text-[11px] text-text outline-none focus:border-accent"
/>
{autoDetectedPorts.mcpHttp > 0 && (
- auto-detected: {autoDetectedPorts.mcpHttp}
+ {t.launchStep.autoDetected(autoDetectedPorts.mcpHttp)}
)}
@@ -1010,7 +1195,7 @@ export default function SetupWizard() {
className="bg-transparent border border-border px-2 py-1 text-[11px] text-text outline-none focus:border-accent"
/>
{autoDetectedPorts.mcpSse > 0 && (
- auto-detected: {autoDetectedPorts.mcpSse}
+ {t.launchStep.autoDetected(autoDetectedPorts.mcpSse)}
)}
@@ -1020,22 +1205,26 @@ export default function SetupWizard() {
{step.error &&
{step.error}
}
{launchStatus === "done" && (
-
Project saved. Redirecting to dashboard...
+
{t.launchStep.redirecting}
)}
- {launchStatus === "running" ? "Launching..." : launchStatus === "done" ? "Launched!" : "Launch Project"}
+ {launchStatus === "running"
+ ? t.launchStep.launching
+ : launchStatus === "done"
+ ? t.launchStep.launched
+ : t.launchStep.launch}
)}
{currentStep >= steps.length && (
-
Setup complete!
-
Redirecting to project dashboard...
+
{t.setupComplete}
+
{t.redirectingToDashboard}
)}
@@ -1044,20 +1233,20 @@ export default function SetupWizard() {
{/* Right: Live Preview Panel */}
- Configuration Preview
+ {t.preview.title}
- Project
+ {t.preview.project}
{projectName || "\u2014"}
- Repository
+ {t.preview.repo}
{repo || "\u2014"}
- {enableProtection && + branch protection }
+ {enableProtection && {t.preview.branchProtection} }
-
Backends
+
{t.preview.backends}
{Object.entries(backends).map(([agent, backend]) => (
{agent}
@@ -1067,16 +1256,16 @@ export default function SetupWizard() {
{showReviewerCreds && reviewerUser && (
- Reviewer
+ {t.preview.reviewer}
@{reviewerUser}
)}
- Directory
+ {t.preview.directory}
{workingDir || "\u2014"}
-
Status
+
{t.preview.status}
{steps.map((s) => (
diff --git a/src/components/TelegramBridgeWidget.tsx b/src/components/TelegramBridgeWidget.tsx
index 98fa72b..fcf7a88 100644
--- a/src/components/TelegramBridgeWidget.tsx
+++ b/src/components/TelegramBridgeWidget.tsx
@@ -3,6 +3,58 @@
import { useCallback, useEffect, useRef, useState } from "react";
import InfoTooltip from "./InfoTooltip";
import TelegramSetupModal from "./TelegramSetupModal";
+import { useLocale } from "@/components/LocaleProvider";
+
+const COPY = {
+ en: {
+ title: "Telegram Bridge",
+ tooltip: (
+ <>
+
Telegram Bridge forwards AgentChattr messages to a Telegram bot so you can monitor from your phone. Bidirectional — replies from Telegram appear in chat.
+ >
+ ),
+ autoOn: "Auto ON — bridge follows batch lifecycle",
+ autoOff: "Auto OFF — manual start/stop only",
+ notConfigured: "Not configured",
+ setUp: "Set up Telegram Bridge",
+ running: "Running",
+ stopped: "Stopped",
+ stop: "Stop",
+ stopping: "Stopping…",
+ start: "Start",
+ starting: "Starting…",
+ howToSetUp: "How to set up",
+ editCredentials: "Edit credentials",
+ dismiss: "dismiss",
+ batchActive: "Batch active — auto-starting bridge.",
+ batchComplete: "Batch complete — bridge paused. Waiting for next batch.",
+ newBatch: "New batch detected — auto-starting bridge.",
+ },
+ ko: {
+ title: "텔레그램 브릿지",
+ tooltip: (
+ <>
+
텔레그램 브릿지 - AgentChattr 메시지를 텔레그램 봇으로 전달해서 휴대폰에서 모니터링할 수 있게 합니다. 양방향이며 텔레그램에서 보낸 답장도 채팅에 나타납니다.
+ >
+ ),
+ autoOn: "자동 모드 켬 — 브릿지가 배치 주기를 따릅니다",
+ autoOff: "자동 모드 끔 — 수동 시작/중지만 가능",
+ notConfigured: "설정되지 않음",
+ setUp: "텔레그램 브릿지 설정",
+ running: "실행 중",
+ stopped: "중지됨",
+ stop: "중지",
+ stopping: "중지 중…",
+ start: "시작",
+ starting: "시작 중…",
+ howToSetUp: "설정 방법",
+ editCredentials: "인증 정보 수정",
+ dismiss: "닫기",
+ batchActive: "배치 실행 중 — 브릿지를 자동 시작합니다.",
+ batchComplete: "배치 완료 — 브릿지를 일시 중단했습니다. 다음 배치를 기다리는 중.",
+ newBatch: "새 배치 감지 — 브릿지를 자동 시작합니다.",
+ },
+} as const;
interface BatchState {
complete: boolean;
@@ -46,6 +98,8 @@ async function callTelegram(action: string, body: Record
) {
* scratch.
*/
export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidgetProps) {
+ const { locale } = useLocale();
+ const t = COPY[locale];
const [status, setStatus] = useState(null);
const [busy, setBusy] = useState(false);
// #372: split error state — actionError is set by the operator's
@@ -69,7 +123,6 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
// #518: Auto toggle — start/stop bridge with batch lifecycle
const [autoTelegram, setAutoTelegram] = useState(false);
const [autoStatus, setAutoStatus] = useState(null);
- const prevBatchRef = useRef<{ complete: boolean; hasItems: boolean } | null>(null);
const autoTelegramRef = useRef(autoTelegram);
const runningRef = useRef(false);
useEffect(() => { autoTelegramRef.current = autoTelegram; }, [autoTelegram]);
@@ -78,7 +131,6 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
const autoLoadedRef = useRef(false);
useEffect(() => {
autoLoadedRef.current = false;
- prevBatchRef.current = null;
setAutoTelegram(false);
setAutoStatus(null);
}, [projectId]);
@@ -196,6 +248,7 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
// #518: batch lifecycle polling — auto-start/stop bridge with batch
const AUTO_POLL_MS = 30_000;
+ const prevBatchRef = useRef<{ complete: boolean; hasItems: boolean } | null>(null);
const checkBatchLifecycle = useCallback(async () => {
if (!autoTelegramRef.current) return;
@@ -209,12 +262,12 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
if (!prev) {
if (hasItems && !data.complete && !runningRef.current) {
- setAutoStatus("Batch active — auto-starting bridge.");
+ setAutoStatus(t.batchActive);
await callTelegram("start", { project_id: projectId }).catch(() => {});
await load();
}
if (hasItems && data.complete && runningRef.current) {
- setAutoStatus("Batch complete — bridge paused. Waiting for next batch.");
+ setAutoStatus(t.batchComplete);
setActionError(null); // #522: clear stale action errors on auto-stop
await callTelegram("stop", { project_id: projectId }).catch(() => {});
await load();
@@ -224,7 +277,7 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
// Batch just completed → auto-stop
if (hasItems && data.complete && !prev.complete && runningRef.current) {
- setAutoStatus("Batch complete — bridge paused. Waiting for next batch.");
+ setAutoStatus(t.batchComplete);
setActionError(null); // #522: clear stale action errors on auto-stop
await callTelegram("stop", { project_id: projectId }).catch(() => {});
await load();
@@ -233,12 +286,12 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
// New batch started → auto-start
if (hasItems && !data.complete && (prev.complete || !prev.hasItems) && !runningRef.current) {
- setAutoStatus("New batch detected — auto-starting bridge.");
+ setAutoStatus(t.newBatch);
await callTelegram("start", { project_id: projectId }).catch(() => {});
await load();
}
} catch { /* non-fatal */ }
- }, [projectId, load]);
+ }, [projectId, load, t]);
useEffect(() => {
if (!autoTelegram) return;
@@ -267,9 +320,9 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
- Telegram Bridge
+ {t.title}
- Telegram Bridge forwards AgentChattr messages to a Telegram bot so you can monitor from your phone. Bidirectional — replies from Telegram appear in chat.
+ {t.tooltip}
@@ -277,7 +330,7 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
- Not configured
+ {t.notConfigured}
setSetupOpen(true)}
disabled={busy}
className="self-start px-3 py-1 text-[11px] font-semibold text-bg bg-accent hover:bg-accent-dim disabled:opacity-50 transition-colors"
>
- Set up Telegram Bridge
+ {t.setUp}
>
) : (
@@ -316,12 +369,12 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
- Running
+ {t.running}
>
) : (
<>
- Stopped
+ {t.stopped}
>
)}
{status?.bot_username && (
@@ -338,7 +391,7 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
disabled={busy}
className="px-3 py-1 text-[11px] text-text-muted border border-border hover:text-error hover:border-error/40 disabled:opacity-50 transition-colors"
>
- {busy ? "Stopping…" : "Stop"}
+ {busy ? t.stopping : t.stop}
) : (
- {busy ? "Starting…" : "Start"}
+ {busy ? t.starting : t.start}
)}
- How to set up
+ {t.howToSetUp}
setSetupOpen(true)}
disabled={busy}
className="px-3 py-1 text-[11px] text-text-muted border border-border hover:text-text disabled:opacity-50 transition-colors"
>
- Edit credentials
+ {t.editCredentials}
>
@@ -374,7 +427,7 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
onClick={() => setRestartNotice(null)}
className="block mt-1 text-text-muted hover:text-text underline"
>
- dismiss
+ {t.dismiss}
)}
@@ -392,7 +445,7 @@ export default function TelegramBridgeWidget({ projectId }: TelegramBridgeWidget
onClick={() => setActionError(null)}
className="block mt-1 text-text-muted hover:text-text underline"
>
- dismiss
+ {t.dismiss}
)}
diff --git a/src/lib/locale.ts b/src/lib/locale.ts
new file mode 100644
index 0000000..cdb1c0b
--- /dev/null
+++ b/src/lib/locale.ts
@@ -0,0 +1,19 @@
+export const LOCALE_STORAGE_KEY = "qw-locale";
+export const LOCALE_COOKIE_KEY = "qw-locale";
+
+export type Locale = "en" | "ko";
+
+export function normalizeLocale(value: unknown): Locale {
+ return value === "ko" ? "ko" : "en";
+}
+
+export function detectBrowserLocale(): Locale {
+ if (typeof window === "undefined") return "en";
+
+ try {
+ const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
+ if (stored) return normalizeLocale(stored);
+ } catch {}
+
+ return window.navigator.language.toLowerCase().startsWith("ko") ? "ko" : "en";
+}