From d7e73609d37debcd939bb2c10b18a32d13fffa49 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 15 Jun 2026 16:35:15 +0800 Subject: [PATCH] fix(webui): redesign automation management layout --- .../src/components/settings/SettingsView.tsx | 499 +++++++++++++----- webui/src/i18n/locales/en/common.json | 5 +- webui/src/i18n/locales/es/common.json | 5 +- webui/src/i18n/locales/fr/common.json | 5 +- webui/src/i18n/locales/id/common.json | 5 +- webui/src/i18n/locales/ja/common.json | 5 +- webui/src/i18n/locales/ko/common.json | 5 +- webui/src/i18n/locales/vi/common.json | 5 +- webui/src/i18n/locales/zh-CN/common.json | 5 +- webui/src/i18n/locales/zh-TW/common.json | 5 +- webui/src/tests/app-layout.test.tsx | 12 +- 11 files changed, 403 insertions(+), 153 deletions(-) diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index a39d48bc..57f4fc4b 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -3429,10 +3429,14 @@ function AutomationsSettings({ const tx = (key: string, fallback: string, values?: Record) => t(key, { defaultValue: fallback, ...(values ?? {}) }); const jobs = payload?.jobs ?? []; - const normalizedQuery = query.trim().toLowerCase(); - const filtered = sortAutomationJobs(jobs, sort) - .filter((job) => automationMatchesFilter(job, filter)) - .filter((job) => !normalizedQuery || automationSearchText(job).includes(normalizedQuery)); + const locale = i18n.resolvedLanguage || i18n.language; + const [selectedJobId, setSelectedJobId] = useState(null); + const filtered = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + return sortAutomationJobs(jobs, sort) + .filter((job) => automationMatchesFilter(job, filter)) + .filter((job) => !normalizedQuery || automationSearchText(job).includes(normalizedQuery)); + }, [filter, jobs, query, sort]); const activeCount = jobs.filter((job) => { const key = automationStatusKey(job); return key === "active" || key === "running"; @@ -3440,12 +3444,12 @@ function AutomationsSettings({ const pausedCount = jobs.filter((job) => automationStatusKey(job) === "paused").length; const failedCount = jobs.filter(automationNeedsAttention).length; const systemCount = jobs.filter((job) => job.protected).length; - const filterOptions = [ - { value: "all", label: tx("settings.automations.filters.all", "All") }, - { value: "active", label: tx("settings.automations.filters.active", "Active") }, - { value: "paused", label: tx("settings.automations.filters.paused", "Paused") }, - { value: "failed", label: tx("settings.automations.filters.failed", "Needs attention") }, - { value: "system", label: tx("settings.automations.filters.system", "System") }, + const summaryOptions: Array<{ value: AutomationFilter; label: string; count: number }> = [ + { value: "all", label: tx("settings.automations.filters.all", "All"), count: jobs.length }, + { value: "active", label: tx("settings.automations.filters.active", "Active"), count: activeCount }, + { value: "paused", label: tx("settings.automations.filters.paused", "Paused"), count: pausedCount }, + { value: "failed", label: tx("settings.automations.filters.failed", "Needs attention"), count: failedCount }, + { value: "system", label: tx("settings.automations.filters.system", "System"), count: systemCount }, ]; const sortLabel = { next: tx("settings.automations.sort.next", "Next run"), @@ -3453,33 +3457,56 @@ function AutomationsSettings({ updated: tx("settings.automations.sort.updated", "Updated"), name: tx("settings.automations.sort.name", "Name"), } satisfies Record; + const selectedJob = filtered.find((job) => job.id === selectedJobId) ?? filtered[0] ?? null; + + useEffect(() => { + if (!filtered.length) { + if (selectedJobId !== null) setSelectedJobId(null); + return; + } + if (!selectedJobId || !filtered.some((job) => job.id === selectedJobId)) { + setSelectedJobId(filtered[0].id); + } + }, [filtered, selectedJobId]); return (
-
-
- - - - -
- -
-
- - onQueryChange(event.target.value)} - placeholder={tx("settings.automations.search", "Search automation, message, session, or cron expression")} - className="h-9 rounded-full bg-background/85 pl-9 text-[13px]" - /> +
+
+
+ {summaryOptions.map((option) => ( + + ))}
-
+ +
+
+ + onQueryChange(event.target.value)} + placeholder={tx("settings.automations.search", "Search automation, message, session, or cron expression")} + className="h-9 rounded-full bg-background/85 pl-9 text-[13px]" + /> +
@@ -3518,19 +3540,29 @@ function AutomationsSettings({ {tx("settings.automations.loading", "Loading automations...")}
- ) : filtered.length ? ( -
- {filtered.map((job) => ( - - ))} + ) : filtered.length && selectedJob ? ( +
+
+
+ {filtered.map((job) => ( + setSelectedJobId(job.id)} + /> + ))} +
+
+
) : (
@@ -3554,16 +3586,83 @@ function AutomationsSettings({ ); } -function AutomationStat({ label, value }: { label: string; value: number }) { +type AutomationRunRecord = NonNullable[number]; + +function AutomationListItem({ + job, + locale, + selected, + onSelect, +}: { + job: SessionAutomationJob; + locale: string; + selected: boolean; + onSelect: () => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + const status = automationStatus(job, tx); + const origin = automationOriginLabel(job, tx); + const nextRun = formatAutomationNext(job, tx); + return ( -
-
{label}
-
{value}
+
+
); } -function AutomationRow({ +function AutomationDetailPanel({ job, locale, actionKey, @@ -3589,30 +3688,41 @@ function AutomationRow({ const history = job.state.run_history ?? []; const latestRun = history[history.length - 1]; const lastResult = automationLastResult(job, latestRun, locale, tx); - const canManage = !job.protected; - const canRun = canManage && job.enabled && !job.state.pending; - const toggleAction: AutomationAction = job.enabled ? "disable" : "enable"; - const toggleBusy = actionKey === `${toggleAction}:${job.id}`; const needsRecreation = automationNeedsRecreation(job); + const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null; + const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null; return ( -
-
-
-
- - {job.name || job.id} - - {status.label} - {job.delete_after_run ? ( - {tx("settings.automations.oneShot", "One-time")} - ) : null} +
+
+
+
+
+

+ {job.name || job.id} +

+ {status.label} + {job.delete_after_run ? ( + {tx("settings.automations.oneShot", "One-time")} + ) : null} +
+

+ {job.payload.message || tx("settings.automations.systemTask", "System-managed automation")} +

-

- {job.payload.message || tx("settings.automations.systemTask", "System-managed automation")} -

+ +
+
-
+
+
+
- {lastResult.primary} + {lastResult.primary} @@ -3668,77 +3778,175 @@ function AutomationRow({
) : null} -
- {job.created_at_ms ? ( - - {tx("settings.automations.meta.created", "Created {{time}}", { - time: fmtDateTime(job.created_at_ms, locale), - })} - - ) : null} - {job.updated_at_ms ? ( - - {tx("settings.automations.meta.updated", "Updated {{time}}", { - time: fmtDateTime(job.updated_at_ms, locale), - })} - - ) : null} -
+
-
- {canManage ? ( - <> - onRequestEdit(job)} - > - - - void onAction("run", job)} - > - - - void onAction(toggleAction, job)} - > - {job.enabled ? ( - - ) : ( - - )} - - onRequestDelete(job)} - > - - - - ) : ( - - {tx("settings.automations.protected", "Protected")} - - )} -
+
); } +function AutomationActionGroup({ + job, + actionKey, + onAction, + onRequestEdit, + onRequestDelete, +}: { + job: SessionAutomationJob; + actionKey: string | null; + onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise; + onRequestEdit: (job: SessionAutomationJob) => void; + onRequestDelete: (job: SessionAutomationJob) => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + const canManage = !job.protected; + const canRun = canManage && job.enabled && !job.state.pending; + const toggleAction: AutomationAction = job.enabled ? "disable" : "enable"; + const toggleBusy = actionKey === `${toggleAction}:${job.id}`; + + if (!canManage) { + return ( + + {tx("settings.automations.protected", "Protected")} + + ); + } + + return ( +
+ onRequestEdit(job)} + > + + + void onAction("run", job)} + > + + + void onAction(toggleAction, job)} + > + {job.enabled ? ( + + ) : ( + + )} + + onRequestDelete(job)} + > + + +
+ ); +} + +function AutomationRunHistory({ + history, + locale, + tx, +}: { + history: AutomationRunRecord[]; + locale: string; + tx: (key: string, fallback: string, values?: Record) => string; +}) { + if (!history.length) return null; + const recent = history.slice(-4).reverse(); + + return ( +
+
+

+ {tx("settings.automations.history.timeline", "Run history")} +

+ + {recent.length} + +
+
+ {recent.map((run) => { + const status = automationRunStatusLabel(run.status, tx); + const duration = run.duration_ms === undefined + ? null + : formatAutomationRunDuration(run.duration_ms, locale, tx); + return ( +
+ + + + {status} + {run.error ? ` · ${run.error}` : ""} + + + {fmtDateTime(run.run_at_ms, locale)} + + + {duration ? ( + + {duration} + + ) : null} +
+ ); + })} +
+
+ ); +} + function AutomationDetail({ label, title, @@ -3751,12 +3959,12 @@ function AutomationDetail({ children: ReactNode; }) { return ( -
+
{label}
-
+
{children}
{secondary ? ( @@ -4455,6 +4663,21 @@ function automationResultDotClass(tone: "neutral" | "success" | "warning" | "dan return "bg-muted-foreground/45"; } +function automationStatusDotClass(job: SessionAutomationJob): string { + const status = automationStatusKey(job); + if (status === "active" || status === "running") return "bg-emerald-500"; + if (status === "failed" || status === "needs_setup") return "bg-amber-500"; + if (status === "system") return "bg-blue-500"; + return "bg-muted-foreground/45"; +} + +function automationRunDotClass(status: string): string { + if (status === "ok") return "bg-emerald-500"; + if (status === "error") return "bg-destructive"; + if (status === "skipped") return "bg-amber-500"; + return "bg-muted-foreground/45"; +} + function automationRunStatusLabel( status: string | null | undefined, tx: (key: string, fallback: string, values?: Record) => string, diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index ef3a9386..03e95a53 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -497,7 +497,9 @@ "schedule": "Schedule", "next": "Next", "last": "Last", - "origin": "Linked chat" + "origin": "Linked chat", + "created": "Created", + "updated": "Updated" }, "runNow": "Run now", "pause": "Pause", @@ -571,6 +573,7 @@ "error": "Error", "skipped": "Skipped", "recent": "Last result", + "timeline": "Run history", "statusWithDuration": "{{status}} · {{duration}}" }, "meta": { diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 34f0e027..2cbb1a68 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -497,7 +497,9 @@ "schedule": "Programación", "next": "Siguiente", "last": "Última", - "origin": "Chat vinculado" + "origin": "Chat vinculado", + "created": "Creada", + "updated": "Actualizada" }, "runNow": "Ejecutar ahora", "pause": "Pausar", @@ -571,6 +573,7 @@ "error": "Error", "skipped": "Omitida", "recent": "Último resultado", + "timeline": "Historial de ejecuciones", "statusWithDuration": "{{status}} · {{duration}}" }, "meta": { diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index b27f7f59..3c1cf155 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -497,7 +497,9 @@ "schedule": "Planning", "next": "Prochaine", "last": "Dernière", - "origin": "Discussion liée" + "origin": "Discussion liée", + "created": "Créée", + "updated": "Modifiée" }, "runNow": "Exécuter maintenant", "pause": "Mettre en pause", @@ -571,6 +573,7 @@ "error": "Erreur", "skipped": "Ignorée", "recent": "Dernier résultat", + "timeline": "Historique d’exécution", "statusWithDuration": "{{status}} · {{duration}}" }, "meta": { diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index e1457b71..b5ecdb93 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -497,7 +497,9 @@ "schedule": "Jadwal", "next": "Berikutnya", "last": "Terakhir", - "origin": "Chat tertaut" + "origin": "Chat tertaut", + "created": "Dibuat", + "updated": "Diperbarui" }, "runNow": "Jalankan sekarang", "pause": "Jeda", @@ -571,6 +573,7 @@ "error": "Error", "skipped": "Dilewati", "recent": "Hasil terakhir", + "timeline": "Riwayat eksekusi", "statusWithDuration": "{{status}} · {{duration}}" }, "meta": { diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 75223b8c..2883195f 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -497,7 +497,9 @@ "schedule": "スケジュール", "next": "次回", "last": "前回", - "origin": "関連チャット" + "origin": "関連チャット", + "created": "作成", + "updated": "更新" }, "runNow": "今すぐ実行", "pause": "一時停止", @@ -571,6 +573,7 @@ "error": "エラー", "skipped": "スキップ", "recent": "最新結果", + "timeline": "実行履歴", "statusWithDuration": "{{status}} · {{duration}}" }, "meta": { diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 44051a03..aae79d3e 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -497,7 +497,9 @@ "schedule": "일정", "next": "다음", "last": "마지막", - "origin": "연결된 채팅" + "origin": "연결된 채팅", + "created": "생성", + "updated": "업데이트" }, "runNow": "지금 실행", "pause": "일시 중지", @@ -571,6 +573,7 @@ "error": "오류", "skipped": "건너뜀", "recent": "최근 결과", + "timeline": "실행 기록", "statusWithDuration": "{{status}} · {{duration}}" }, "meta": { diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index c7baf93f..4a720a0e 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -497,7 +497,9 @@ "schedule": "Lịch", "next": "Tiếp theo", "last": "Lần trước", - "origin": "Cuộc trò chuyện liên kết" + "origin": "Cuộc trò chuyện liên kết", + "created": "Đã tạo", + "updated": "Đã cập nhật" }, "runNow": "Chạy ngay", "pause": "Tạm dừng", @@ -571,6 +573,7 @@ "error": "Lỗi", "skipped": "Đã bỏ qua", "recent": "Kết quả gần nhất", + "timeline": "Lịch sử chạy", "statusWithDuration": "{{status}} · {{duration}}" }, "meta": { diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index efdd240e..cec2917c 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -497,7 +497,9 @@ "schedule": "计划", "next": "下次", "last": "上次", - "origin": "关联会话" + "origin": "关联会话", + "created": "创建于", + "updated": "更新于" }, "runNow": "立即运行", "pause": "暂停", @@ -571,6 +573,7 @@ "error": "错误", "skipped": "已跳过", "recent": "最近结果", + "timeline": "运行记录", "statusWithDuration": "{{status}} · {{duration}}" }, "meta": { diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index befc58aa..f7e5ce9a 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -497,7 +497,9 @@ "schedule": "排程", "next": "下次", "last": "上次", - "origin": "關聯會話" + "origin": "關聯會話", + "created": "建立於", + "updated": "更新於" }, "runNow": "立即執行", "pause": "暫停", @@ -571,6 +573,7 @@ "error": "錯誤", "skipped": "已略過", "recent": "最近結果", + "timeline": "執行記錄", "statusWithDuration": "{{status}} · {{duration}}" }, "meta": { diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 53b3ab43..b73a2eab 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -434,9 +434,9 @@ describe("App layout", () => { fireEvent.click(automationsButton); expect(await screen.findByRole("heading", { name: "Automations" })).toBeInTheDocument(); - expect(screen.getByText("Daily repo check")).toBeInTheDocument(); - expect(screen.getByText("Check the repo status")).toBeInTheDocument(); - expect(screen.getByText("Release prep")).toBeInTheDocument(); + expect(screen.getAllByText("Daily repo check").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Check the repo status").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Release prep").length).toBeGreaterThanOrEqual(1); expect(screen.getByText("english-quiz")).toBeInTheDocument(); expect(screen.getByText("Recreate in target chat")).toBeInTheDocument(); expect(screen.queryByText("unified:default")).not.toBeInTheDocument(); @@ -498,7 +498,7 @@ describe("App layout", () => { const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); fireEvent.click(within(sidebar).getByRole("button", { name: "Automations" })); - expect(await screen.findByText("Past one-shot")).toBeInTheDocument(); + expect((await screen.findAllByText("Past one-shot")).length).toBeGreaterThanOrEqual(1); fireEvent.click(screen.getByRole("button", { name: "Edit" })); expect(screen.queryByText("Run time must be in the future.")).not.toBeInTheDocument(); expect( @@ -582,8 +582,8 @@ describe("App layout", () => { expect(await screen.findByRole("heading", { name: "自动任务" })).toBeInTheDocument(); expect(screen.getByText("任务队列")).toBeInTheDocument(); - expect(screen.getByText("每日检查")).toBeInTheDocument(); - expect(screen.getByText("检查仓库状态")).toBeInTheDocument(); + expect(screen.getAllByText("每日检查").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("检查仓库状态").length).toBeGreaterThanOrEqual(1); expect(screen.getByText("每 1天")).toBeInTheDocument(); expect(screen.getByText("最近结果")).toBeInTheDocument(); expect(screen.getByText("完成 · 不到 1 秒")).toBeInTheDocument();