fix(webui): smooth automation detail overflow

This commit is contained in:
chengyongru
2026-06-15 20:29:18 +08:00
parent 03c79817ac
commit 85a3ff1372
11 changed files with 188 additions and 5 deletions
+57 -5
View File
@@ -3710,6 +3710,14 @@ function AutomationDetailPanel({
const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null;
const message = job.payload.message || tx("settings.automations.systemTask", "System-managed automation");
const schedule = formatAutomationSchedule(job, locale, tx);
const [messageExpanded, setMessageExpanded] = useState(false);
const [historyExpanded, setHistoryExpanded] = useState(false);
const messageNeedsExpansion = automationMessageNeedsExpansion(message);
useEffect(() => {
setMessageExpanded(false);
setHistoryExpanded(false);
}, [job.id]);
return (
<article className="min-w-0 overflow-hidden rounded-[24px] border border-border/45 bg-card/90 shadow-[0_24px_80px_rgba(15,23,42,0.065)] backdrop-blur-xl">
@@ -3745,9 +3753,25 @@ function AutomationDetailPanel({
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
{tx("settings.automations.fields.message", "Message")}
</div>
<div className="mt-3 max-h-64 overflow-y-auto whitespace-pre-wrap break-words text-[13px] leading-6 text-foreground/85">
<div
className={cn(
"mt-3 whitespace-pre-wrap break-words text-[13px] leading-6 text-foreground/85",
!messageExpanded && messageNeedsExpansion && "line-clamp-6",
)}
>
{message}
</div>
{messageNeedsExpansion ? (
<button
type="button"
className="mt-3 inline-flex text-[12px] font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onClick={() => setMessageExpanded((value) => !value)}
>
{messageExpanded
? tx("settings.automations.message.showLess", "Show less")
: tx("settings.automations.message.showMore", "Show full message")}
</button>
) : null}
</section>
<div className="grid gap-3 md:grid-cols-3">
@@ -3800,7 +3824,13 @@ function AutomationDetailPanel({
</div>
) : null}
<AutomationRunHistory history={history} locale={locale} tx={tx} />
<AutomationRunHistory
expanded={historyExpanded}
history={history}
locale={locale}
onExpandedChange={setHistoryExpanded}
tx={tx}
/>
</div>
<aside className="border-t border-border/35 bg-muted/20 p-4 text-[12px] text-muted-foreground sm:p-5 xl:border-l xl:border-t-0">
@@ -3917,17 +3947,26 @@ function AutomationActionGroup({
);
}
function automationMessageNeedsExpansion(message: string): boolean {
return message.length > 360 || message.split(/\r?\n/).length > 6;
}
function AutomationRunHistory({
expanded,
history,
locale,
onExpandedChange,
tx,
}: {
expanded: boolean;
history: AutomationRunRecord[];
locale: string;
onExpandedChange: (value: boolean) => void;
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string;
}) {
if (!history.length) return null;
const recent = history.slice(-4).reverse();
const visible = expanded ? [...history].reverse() : history.slice(-4).reverse();
const hiddenCount = history.length - visible.length;
return (
<section className="rounded-[18px] bg-muted/32 px-3 py-3">
@@ -3936,11 +3975,11 @@ function AutomationRunHistory({
{tx("settings.automations.history.timeline", "Run history")}
</h4>
<span className="text-[11px] leading-none text-muted-foreground tabular-nums">
{recent.length}
{history.length}
</span>
</div>
<div className="space-y-2">
{recent.map((run) => {
{visible.map((run) => {
const status = automationRunStatusLabel(run.status, tx);
const duration = run.duration_ms === undefined
? null
@@ -3975,6 +4014,19 @@ function AutomationRunHistory({
);
})}
</div>
{history.length > 4 ? (
<button
type="button"
className="mt-3 inline-flex text-[12px] font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onClick={() => onExpandedChange(!expanded)}
>
{expanded
? tx("settings.automations.history.showRecent", "Show recent runs")
: tx("settings.automations.history.showMore", "Show {{count}} more", {
count: hiddenCount,
})}
</button>
) : null}
</section>
);
}
+6
View File
@@ -582,8 +582,14 @@
"skipped": "Skipped",
"recent": "Last result",
"timeline": "Run history",
"showMore": "Show {{count}} more",
"showRecent": "Show recent runs",
"statusWithDuration": "{{status}} · {{duration}}"
},
"message": {
"showMore": "Show full message",
"showLess": "Show less"
},
"meta": {
"created": "Created {{time}}",
"updated": "Updated {{time}}"
+6
View File
@@ -582,8 +582,14 @@
"skipped": "Omitida",
"recent": "Último resultado",
"timeline": "Historial de ejecuciones",
"showMore": "Mostrar {{count}} más",
"showRecent": "Mostrar ejecuciones recientes",
"statusWithDuration": "{{status}} · {{duration}}"
},
"message": {
"showMore": "Mostrar mensaje completo",
"showLess": "Mostrar menos"
},
"meta": {
"created": "Creada {{time}}",
"updated": "Actualizada {{time}}"
+6
View File
@@ -582,8 +582,14 @@
"skipped": "Ignorée",
"recent": "Dernier résultat",
"timeline": "Historique dexécution",
"showMore": "Afficher {{count}} de plus",
"showRecent": "Afficher les exécutions récentes",
"statusWithDuration": "{{status}} · {{duration}}"
},
"message": {
"showMore": "Afficher le message complet",
"showLess": "Afficher moins"
},
"meta": {
"created": "Créée {{time}}",
"updated": "Mise à jour {{time}}"
+6
View File
@@ -582,8 +582,14 @@
"skipped": "Dilewati",
"recent": "Hasil terakhir",
"timeline": "Riwayat eksekusi",
"showMore": "Tampilkan {{count}} lagi",
"showRecent": "Tampilkan eksekusi terbaru",
"statusWithDuration": "{{status}} · {{duration}}"
},
"message": {
"showMore": "Tampilkan pesan lengkap",
"showLess": "Tampilkan lebih sedikit"
},
"meta": {
"created": "Dibuat {{time}}",
"updated": "Diperbarui {{time}}"
+6
View File
@@ -582,8 +582,14 @@
"skipped": "スキップ",
"recent": "最新結果",
"timeline": "実行履歴",
"showMore": "さらに {{count}} 件表示",
"showRecent": "最近の実行のみ表示",
"statusWithDuration": "{{status}} · {{duration}}"
},
"message": {
"showMore": "メッセージ全文を表示",
"showLess": "折りたたむ"
},
"meta": {
"created": "{{time}} 作成",
"updated": "{{time}} 更新"
+6
View File
@@ -582,8 +582,14 @@
"skipped": "건너뜀",
"recent": "최근 결과",
"timeline": "실행 기록",
"showMore": "{{count}}개 더 보기",
"showRecent": "최근 실행만 보기",
"statusWithDuration": "{{status}} · {{duration}}"
},
"message": {
"showMore": "전체 메시지 보기",
"showLess": "접기"
},
"meta": {
"created": "{{time}} 생성",
"updated": "{{time}} 업데이트"
+6
View File
@@ -582,8 +582,14 @@
"skipped": "Đã bỏ qua",
"recent": "Kết quả gần nhất",
"timeline": "Lịch sử chạy",
"showMore": "Hiển thị thêm {{count}}",
"showRecent": "Chỉ hiển thị lần chạy gần đây",
"statusWithDuration": "{{status}} · {{duration}}"
},
"message": {
"showMore": "Hiển thị toàn bộ tin nhắn",
"showLess": "Thu gọn"
},
"meta": {
"created": "Tạo lúc {{time}}",
"updated": "Cập nhật lúc {{time}}"
+6
View File
@@ -582,8 +582,14 @@
"skipped": "已跳过",
"recent": "最近结果",
"timeline": "运行记录",
"showMore": "再显示 {{count}} 条",
"showRecent": "只看最近记录",
"statusWithDuration": "{{status}} · {{duration}}"
},
"message": {
"showMore": "查看完整消息",
"showLess": "收起消息"
},
"meta": {
"created": "创建于 {{time}}",
"updated": "更新于 {{time}}"
+6
View File
@@ -582,8 +582,14 @@
"skipped": "已略過",
"recent": "最近結果",
"timeline": "執行記錄",
"showMore": "再顯示 {{count}} 筆",
"showRecent": "只看最近記錄",
"statusWithDuration": "{{status}} · {{duration}}"
},
"message": {
"showMore": "查看完整訊息",
"showLess": "收起訊息"
},
"meta": {
"created": "建立於 {{time}}",
"updated": "更新於 {{time}}"
+77
View File
@@ -558,6 +558,83 @@ describe("App layout", () => {
});
});
it("keeps long automation details expandable without nested scrolling", async () => {
const longMessage = [
"Review the release plan and prepare a concise status update for the channel.",
"Include blockers, owners, follow-up dates, and any risky assumptions that changed since yesterday.",
"Keep the output actionable and avoid repeating context that the team already confirmed in the thread.",
"If a dependency looks stale, call it out explicitly and ask for a fresh owner update.",
"This message is intentionally long enough to require progressive disclosure in the automation details panel.",
"The full content should remain available without forcing the user into a small nested scroll area.",
].join("\n");
const history = [
{ run_at_ms: Date.UTC(2026, 3, 12, 10, 0, 0), status: "error", duration_ms: 900, error: "oldest failure" },
{ run_at_ms: Date.UTC(2026, 3, 13, 10, 0, 0), status: "error", duration_ms: 800, error: "second oldest failure" },
{ run_at_ms: Date.UTC(2026, 3, 14, 10, 0, 0), status: "ok", duration_ms: 700 },
{ run_at_ms: Date.UTC(2026, 3, 15, 10, 0, 0), status: "ok", duration_ms: 600 },
{ run_at_ms: Date.UTC(2026, 3, 16, 10, 0, 0), status: "ok", duration_ms: 500 },
{ run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0), status: "ok", duration_ms: 400 },
];
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": {
jobs: [
{
id: "long-details",
name: "Long detail automation",
enabled: true,
protected: false,
delete_after_run: false,
schedule: { kind: "every", every_ms: 3_600_000 },
payload: {
message: longMessage,
kind: "agent_turn",
session_key: "websocket:chat-a",
},
state: {
next_run_at_ms: Date.UTC(2026, 3, 18, 10, 0, 0),
last_status: "ok",
pending: false,
run_history: history,
},
origin: {
session_key: "websocket:chat-a",
channel: "websocket",
chat_id: "chat-a",
title: "Release prep",
preview: "Check release blockers",
},
},
],
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Automations" }));
const detailHeading = await screen.findByRole("heading", { name: "Long detail automation" });
const detailPanel = detailHeading.closest("article") as HTMLElement;
expect(detailPanel).not.toBeNull();
const message = Array.from(detailPanel.querySelectorAll("section div")).find(
(node) => node.textContent === longMessage,
) as HTMLElement | undefined;
expect(message).toBeTruthy();
expect(message!).toHaveClass("line-clamp-6");
fireEvent.click(within(detailPanel).getByRole("button", { name: "Show full message" }));
expect(within(detailPanel).getByRole("button", { name: "Show less" })).toBeInTheDocument();
expect(message!).not.toHaveClass("line-clamp-6");
expect(screen.queryByText(/oldest failure/)).not.toBeInTheDocument();
fireEvent.click(within(detailPanel).getByRole("button", { name: "Show 2 more" }));
expect(screen.getAllByText(/oldest failure/)).toHaveLength(2);
fireEvent.click(within(detailPanel).getByRole("button", { name: "Show recent runs" }));
expect(screen.queryByText(/oldest failure/)).not.toBeInTheDocument();
});
it("localizes the Automations surface", async () => {
await i18n.changeLanguage("zh-CN");
mockFetchRoutes({