diff --git a/src/app/globals.css b/src/app/globals.css index 7f4a519..4d68ec1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -89,3 +89,30 @@ h1, h2, h3, h4, h5, h6 { .animate-name-shimmer { animation: qw-name-shimmer 1.6s ease-in-out infinite; } + +/* Korean help text should wrap by word, not by individual syllable. + Keep long URLs/tokens from overflowing narrow tooltips and modals. */ +.ko-help { + word-break: keep-all; + overflow-wrap: normal; + line-break: strict; + text-wrap: pretty; +} + +.ko-help p, +.ko-help li, +.ko-help div, +.ko-help span, +.ko-help b, +.ko-help strong { + word-break: inherit; + overflow-wrap: inherit; + line-break: inherit; +} + +.ko-help code, +.ko-help pre, +.ko-help .break-anywhere { + word-break: normal; + overflow-wrap: anywhere; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9e2d391..a071db1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import Sidebar from "@/components/Sidebar"; import TopHeader from "@/components/TopHeader"; import GlobalNotificationListener from "@/components/GlobalNotificationListener"; +import { LocaleProvider } from "@/components/LocaleProvider"; const geistMono = Geist_Mono({ variable: "--font-geist-mono", @@ -23,12 +24,14 @@ export default function RootLayout({ return ( - - -
- -
{children}
-
+ + + +
+ +
{children}
+
+
); diff --git a/src/components/AgentModelsWidget.tsx b/src/components/AgentModelsWidget.tsx index 4ebd483..e4f0632 100644 --- a/src/components/AgentModelsWidget.tsx +++ b/src/components/AgentModelsWidget.tsx @@ -21,6 +21,66 @@ import { useCallback, useEffect, useState } from "react"; import InfoTooltip from "./InfoTooltip"; +import { useLocale } from "@/components/LocaleProvider"; + +const COPY = { + en: { + title: "Agent Models", + loading: "Loading…", + noAgents: "No agents configured.", + restartRequired: "restart required", + restartRequiredTooltip: "Config changed — running session is still on the old model/effort. Click Restart to apply.", + default: "(default)", + custom: "(custom)", + restart: "Restart", + restartTooltip: "Restart this agent to pick up the new model / reasoning setting", + help: ( + <> + Codex reasoning effort defaults to medium for new projects. Blank model falls back to the CLI default. Click Restart to apply changes to a live session. + + ), + summary: (id: string, backend: string) => ( + <> + {id} + : {backend} + + ), + configure: "Configure →", + tooltip: ( + <> + Agent Models — configure which LLM model and reasoning effort each agent uses. Changes require an agent restart to take effect. + + ), + }, + ko: { + title: "에이전트 모델", + loading: "로딩 중…", + noAgents: "설정된 에이전트가 없습니다.", + restartRequired: "재시작 필요", + restartRequiredTooltip: "설정이 변경되었습니다. 실행 중인 세션에는 이전 설정이 적용되어 있습니다. 재시작을 클릭하여 적용하세요.", + default: "(기본값)", + custom: "(사용자 정의)", + restart: "재시작", + restartTooltip: "에이전트를 재시작하여 새로운 모델/추론 설정을 적용합니다", + help: ( + <> + Codex 추론 수준은 새 프로젝트의 경우 medium으로 기본 설정됩니다. 모델을 비워두면 CLI 기본값이 사용됩니다. 변경 사항을 적용하려면 재시작을 클릭하세요. + + ), + summary: (id: string, backend: string) => ( + <> + {id} + : {backend} + + ), + configure: "설정 →", + tooltip: ( + <> + 에이전트 모델 - 각 에이전트가 어떤 LLM 모델과 추론 수준을 사용할지 설정합니다. 변경 사항은 에이전트를 재시작해야 적용됩니다. + + ), + }, +} as const; interface AgentRow { agent_id: string; @@ -68,6 +128,8 @@ function optionsForBackend(backend: string) { // state. Mounted only when the modal is open so we don't run the // fetch until the operator actually wants to configure something. function AgentModelsModal({ projectId, onClose }: { projectId: string; onClose: () => void }) { + const { locale } = useLocale(); + const t = COPY[locale]; const [rows, setRows] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(null); @@ -172,14 +234,14 @@ function AgentModelsModal({ projectId, onClose }: { projectId: string; onClose:
-

Agent Models

+

{t.title}

{error && err: {error}}
- {!rows &&
Loading…
} + {!rows &&
{t.loading}
} {rows && rows.length === 0 && ( -
No agents configured.
+
{t.noAgents}
)} {rows && rows.map((row) => (
@@ -188,9 +250,9 @@ function AgentModelsModal({ projectId, onClose }: { projectId: string; onClose: {needsRestart.has(row.agent_id) && ( - restart required + {t.restartRequired} )} {/* #343: backend-specific model dropdown. Empty value @@ -210,7 +272,7 @@ function AgentModelsModal({ projectId, onClose }: { projectId: string; onClose: (e.g. operator hand-edited config.json), keep it selectable so their override doesn't vanish. */} {row.model && !optionsForBackend(row.backend).some((o) => o.value === row.model) && ( - + )} {row.reasoning_supported ? ( @@ -220,7 +282,7 @@ function AgentModelsModal({ projectId, onClose }: { projectId: string; onClose: onChange={(e) => update(row.agent_id, { reasoning_effort: e.target.value })} className="bg-transparent border border-border px-1 py-0.5 text-[11px] text-text outline-none focus:border-accent cursor-pointer disabled:opacity-50" > - + {REASONING_LEVELS.map((lvl) => ( ))} @@ -232,15 +294,15 @@ function AgentModelsModal({ projectId, onClose }: { projectId: string; onClose: type="button" onClick={() => restart(row.agent_id)} disabled={busy === row.agent_id} - title="Restart this agent to pick up the new model / reasoning setting" + title={t.restartTooltip} className="shrink-0 px-1.5 py-0.5 text-[10px] text-text-muted border border-border hover:text-accent hover:border-accent/40 disabled:opacity-50 transition-colors" > - {busy === row.agent_id ? "…" : "Restart"} + {busy === row.agent_id ? "…" : t.restart}
))}

- Codex reasoning effort defaults to medium for new projects. Blank model falls back to the CLI default. Click Restart to apply changes to a live session. + {t.help}

@@ -257,6 +319,8 @@ function AgentModelsModal({ projectId, onClose }: { projectId: string; onClose: // component init; we don't poll, since backends rarely change // outside the modal flow. export default function AgentModelsButton({ projectId }: AgentModelsWidgetProps) { + const { locale } = useLocale(); + const t = COPY[locale]; const [open, setOpen] = useState(false); const [summary, setSummary] = useState<{ id: string; backend: string }[] | null>(null); @@ -278,9 +342,9 @@ export default function AgentModelsButton({ projectId }: AgentModelsWidgetProps)
- Agent Models + {t.title} - Agent Models — configure which LLM model and reasoning effort each agent uses. Changes require an agent restart to take effect. + {t.tooltip}
{summary && summary.length > 0 && ( @@ -296,8 +360,7 @@ export default function AgentModelsButton({ projectId }: AgentModelsWidgetProps) {summary.map((s, i) => ( {i > 0 && · } - {s.id} - : {s.backend} + {t.summary(s.id, s.backend)} ))}
diff --git a/src/components/AgentTerminalsGrid.tsx b/src/components/AgentTerminalsGrid.tsx index 06965bf..2cea501 100644 --- a/src/components/AgentTerminalsGrid.tsx +++ b/src/components/AgentTerminalsGrid.tsx @@ -2,6 +2,28 @@ import { useState } from "react"; import TerminalGrid from "./TerminalGrid"; +import { useLocale } from "@/components/LocaleProvider"; + +const COPY = { + en: { + title: "Agent Terminals", + aboutLabel: "About agent terminals", + tooltip: ( + <> + These show what each agent is doing in their CLI session. Do not type here directly — use the AgentChattr chat above instead. Agents won't see messages typed in their terminals. + + ), + }, + ko: { + title: "에이전트 터미널", + aboutLabel: "에이전트 터미널 설명", + tooltip: ( + <> + 각 에이전트가 CLI 세션에서 무엇을 하고 있는지 보여주는 읽기 전용 터미널입니다. 여기에 직접 입력하지 마세요. 위의 AgentChattr 채팅을 사용해야 에이전트가 메시지를 볼 수 있습니다. + + ), + }, +} as const; // #208: the top-right quadrant must show all four agents // (Head, RE1, RE2, Dev) as a 2x2 grid. TerminalGrid's @@ -41,13 +63,15 @@ interface AgentTerminalsGridProps { * the terminals and their messages are lost to the other agents. */ export default function AgentTerminalsGrid({ projectId, agentStates, onStatusChange }: AgentTerminalsGridProps) { + const { locale } = useLocale(); + const t = COPY[locale]; const [tipOpen, setTipOpen] = useState(false); return (
- Agent Terminals + {t.title}
{tipOpen && (
- These show what each agent is doing in their CLI session.{" "} - Do not type here directly — use the AgentChattr chat - above instead. Agents won't see messages typed in their - terminals. + {t.tooltip}
)}
diff --git a/src/components/BatchProgressPanel.tsx b/src/components/BatchProgressPanel.tsx index 61b550e..a791b00 100644 --- a/src/components/BatchProgressPanel.tsx +++ b/src/components/BatchProgressPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from "react"; import InfoTooltip from "./InfoTooltip"; +import { useLocale } from "@/components/LocaleProvider"; interface BatchProgressItem { issue_number: number; @@ -27,6 +28,37 @@ interface BatchProgressPanelProps { projectId: string; } +const COPY = { + en: { + loading: "Loading batch progress…", + currentBatchNone: "Current Batch: (none)", + noActiveBatch: "No active batch. Ask Head to start one via the chat.", + currentBatch: (n: number | string) => `Current Batch: Batch ${n}`, + complete: "✅ COMPLETE", + allMerged: (n: number) => `All ${n} items merged. Waiting for the next batch.`, + itemsCount: (n: number) => `(${n} items)`, + tooltip: ( + <> + Current Batch — progress tracker for the active batch. Polls GitHub to resolve each issue's status (queued → in review → approved → merged). + + ), + }, + ko: { + loading: "배치 진행 상황 로딩 중...", + currentBatchNone: "현재 배치: (없음)", + noActiveBatch: "활성 배치가 없습니다. 채팅에서 Head에게 시작을 요청하세요.", + currentBatch: (n: number | string) => `현재 배치: ${n}번`, + complete: "✅ 완료", + allMerged: (n: number) => `${n}개 항목 모두 병합됨. 다음 배치를 기다리는 중.`, + itemsCount: (n: number) => `(${n}개 항목)`, + tooltip: ( + <> + 현재 배치 - 활성 배치 진행 상황 추적기입니다. GitHub를 조회해 각 이슈 상태를 대기 → 검토 중 → 승인 → 병합 순으로 추적합니다. + + ), + }, +} as const; + const BAR_SEGMENTS = 20; function ProgressBar({ percent }: { percent: number }) { @@ -50,6 +82,8 @@ function ProgressBar({ percent }: { percent: number }) { * GitHub panel. */ export default function BatchProgressPanel({ projectId }: BatchProgressPanelProps) { + const { locale } = useLocale(); + const t = COPY[locale]; const [data, setData] = useState(null); const load = useCallback(() => { @@ -68,7 +102,7 @@ export default function BatchProgressPanel({ projectId }: BatchProgressPanelProp if (!data) { return (
- Loading batch progress… + {t.loading}
); } @@ -79,11 +113,11 @@ export default function BatchProgressPanel({ projectId }: BatchProgressPanelProp
- Current Batch: (none) + {t.currentBatchNone}
- No active batch. Ask Head to start one via the chat. + {t.noActiveBatch}
); @@ -95,12 +129,12 @@ export default function BatchProgressPanel({ projectId }: BatchProgressPanelProp
- Current Batch: Batch {data.batch_number ?? "—"} + {t.currentBatch(data.batch_number ?? "—")} - ✅ COMPLETE + {t.complete}
- All {data.items.length} items merged. Waiting for the next batch. + {t.allMerged(data.items.length)}
); @@ -110,11 +144,11 @@ export default function BatchProgressPanel({ projectId }: BatchProgressPanelProp
- Current Batch: Batch {data.batch_number ?? "—"} + {t.currentBatch(data.batch_number ?? "—")} - ({data.items.length} items) + {t.itemsCount(data.items.length)} - Current Batch — progress tracker for the active batch. Polls GitHub to resolve each issue's status (queued → in review → approved → merged). + {t.tooltip}
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}
{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} @@ -463,7 +565,7 @@ function SystemSection({ projectId }: { projectId: string }) {
@@ -523,21 +625,21 @@ function SystemSection({ projectId }: { projectId: string }) { is now its own subsection with an always-visible descriptor. */}
- Notification Sound + {t.sound}
{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 && ( - hours + {t.hours}
)} 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 ) : ( @@ -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} ) : ( )}
@@ -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) {
- Issues ({issues.length}) + {t.issues(issues.length)} - Issues — open issues on the project's GitHub repo. Click any item to open it on GitHub. + {t.issuesHelp}
{issues.length === 0 && ( -
No issues
+
{t.noIssues}
)} {issues.map((issue) => ( - Recently closed + {t.recentlyClosed}
{closedIssues.length === 0 && ( -
None yet
+
{t.noneYet}
)} {closedIssues.map((issue) => (
- Pull Requests ({prs.length}) + {t.prs(prs.length)} - Pull Requests — open PRs awaiting review or merge. Click to open on GitHub. + {t.prsHelp}
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 ( +
+ - {/* 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} )}
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()} > -

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) => (
  1. 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}
    {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(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, 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)} - 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)}
    )} {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
    @@ -430,7 +486,7 @@ export default function ScheduledTriggerWidget({ projectId }: ScheduledTriggerWi {!running ? (
    - +