From 6d262926e0558a5c19f8fc85d372fbb16f8382e0 Mon Sep 17 00:00:00 2001 From: SivanCola Date: Tue, 11 Aug 2026 19:07:20 +0800 Subject: [PATCH] fix(desktop): streamline task and toolbar controls (#8392) Problem: The task monitor exposed duplicate stop and cancel actions with a cramped confirmation flow, while the topic toolbar repeated command and shortcut entry points and placed the workspace control inconsistently. Root cause: The desktop UI surfaced backend control aliases as separate primary actions and accumulated utility controls without a clear priority order or complete localization. Fix: Merge active-task termination into a single accessible stop flow, localize task state and session-summary labels, remove redundant toolbar entry points, and place session summary before the workspace toggle. Keep the underlying cancel APIs and keyboard/settings access paths intact. Verification: - pnpm exec tsx src/components/TaskMonitorPanel.test.tsx - pnpm exec tsx src/__tests__/app-chrome-tabs.test.ts - pnpm check:css - pnpm typecheck - Browser verification at 127.0.0.1:5174 --- desktop/frontend/src/App.tsx | 44 ++----- .../src/__tests__/topicbar-controls.test.ts | 19 +++ .../src/components/TaskMonitorPanel.test.tsx | 70 +++++++++++- .../src/components/TaskMonitorPanel.tsx | 108 +++++++++++++----- desktop/frontend/src/locales/en.ts | 5 +- desktop/frontend/src/locales/zh-TW.ts | 11 +- desktop/frontend/src/locales/zh.ts | 11 +- desktop/frontend/src/styles.css | 17 ++- 8 files changed, 194 insertions(+), 91 deletions(-) create mode 100644 desktop/frontend/src/__tests__/topicbar-controls.test.ts diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index da3e85be0d..a4edf9f482 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -2,7 +2,6 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand"; import { Activity, - CircleHelp, Command, Copy as RestoreIcon, Download, @@ -4759,33 +4758,15 @@ export default function App() { {!sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && ( )} - + - - - {(sidebarCreation || workbenchChromeHidden) && ( @@ -4806,19 +4787,8 @@ export default function App() { )} - - - {tasksOpen && ( -
+
'); +const workspaceToggleIndex = appSource.indexOf(''); +assert.ok(taskSummaryControlIndex >= 0, "topic bar renders the localized Session summary control"); +assert.ok(workspaceToggleIndex > taskSummaryControlIndex, "Session summary precedes the right-edge workspace toggle"); +assert.ok(!appSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label"); + +process.stdout.write("topicbar controls: 2 contracts passed\n"); diff --git a/desktop/frontend/src/components/TaskMonitorPanel.test.tsx b/desktop/frontend/src/components/TaskMonitorPanel.test.tsx index f3c8d568fd..b558e772ef 100644 --- a/desktop/frontend/src/components/TaskMonitorPanel.test.tsx +++ b/desktop/frontend/src/components/TaskMonitorPanel.test.tsx @@ -68,6 +68,7 @@ let listTasksImpl: () => Promise = async () => []; let listEventsImpl: () => Promise = async () => []; const listTaskTabIDs: string[] = []; const listEventCalls: unknown[][] = []; +const stopCalls: unknown[][] = []; const requeueCalls: unknown[][] = []; const mockApp = { ListTasks: () => listTasksImpl(), @@ -97,7 +98,10 @@ const mockApp = { listEventCalls.push(args); return listEventsImpl(); }, - StopTaskForTab: async () => ({ schema_version: 1, command: "stop", task_id: "", accepted: true, idempotent: false }), + StopTaskForTab: async (...args: unknown[]) => { + stopCalls.push(args); + return { schema_version: 1, command: "stop", task_id: "", accepted: true, idempotent: false }; + }, CancelTaskForTab: async () => ({ schema_version: 1, command: "cancel", task_id: "", accepted: true, idempotent: false }), RequeueTaskForTab: async (...args: unknown[]) => { requeueCalls.push(args); @@ -154,6 +158,7 @@ async function cleanup() { listEventsImpl = async () => []; listTaskTabIDs.length = 0; listEventCalls.length = 0; + stopCalls.length = 0; requeueCalls.length = 0; } @@ -178,6 +183,13 @@ async function click(button: HTMLButtonElement) { }); } +async function keyDown(element: HTMLElement, key: string) { + await act(async () => { + element.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true })); + await flush(); + }); +} + async function openPanel() { await click(buttonByLabel("Expand tasks")); } @@ -238,6 +250,62 @@ await check("separates lifecycle state from runtime liveness", async () => { return text.includes("Exited") && text.includes("Runtime unknown"); }); +await check("offers one working stop action for active tasks", async () => { + listTasksImpl = async () => [snap()]; + await renderPanel(); + await openPanel(); + await click(buttonByLabel("Task task-000 — Running")); + const actionLabels = Array.from(document.querySelectorAll(".taskmonitor__actions button")) + .map((button) => button.textContent?.trim()); + const hasMergedActions = actionLabels.filter((label) => label === "Stop").length === 1 + && !actionLabels.includes("Cancel") + && actionLabels.includes("Open session"); + await click(buttonByText("Stop")); + const confirmStop = buttonByText("Stop"); + const detailValues = Array.from(document.querySelectorAll(".taskmonitor__detail dd")) + .map((value) => value.textContent?.trim()); + const hasReplacementConfirmation = document.querySelector(".taskmonitor__actions") === null + && document.activeElement === confirmStop + && detailValues.includes("Running") + && !detailValues.includes("running"); + await click(confirmStop); + return hasMergedActions + && hasReplacementConfirmation + && JSON.stringify(stopCalls[0]) === JSON.stringify([ + "tab-a", + "task-0001", + 1, + "desktop request", + "desktop-stop-task-0001-1", + ]); +}); + +await check("dismisses stop confirmation with Escape and restores focus", async () => { + listTasksImpl = async () => [snap()]; + await renderPanel(); + await openPanel(); + await click(buttonByLabel("Task task-000 — Running")); + await click(buttonByText("Stop")); + await keyDown(buttonByText("Stop"), "Escape"); + const restoredStop = buttonByText("Stop"); + return document.querySelector(".taskmonitor__confirm") === null + && document.activeElement === restoredStop + && stopCalls.length === 0; +}); + +await check("dismisses stop confirmation when polling observes a terminal task", async () => { + listTasksImpl = async () => [snap()]; + await renderPanel(); + await openPanel(); + await click(buttonByLabel("Task task-000 — Running")); + await click(buttonByText("Stop")); + listTasksImpl = async () => [snap({ state: "succeeded", runtime_state: "exited", version: 2 })]; + await click(buttonByLabel("Refresh")); + return document.querySelector(".taskmonitor__confirm") === null + && document.body.textContent?.includes("Succeeded") === true + && stopCalls.length === 0; +}); + await check("requeues failed exited tasks", async () => { listTasksImpl = async () => [snap({ task_id: "failed-1", state: "failed", runtime_state: "exited", version: 7 })]; await renderPanel(); diff --git a/desktop/frontend/src/components/TaskMonitorPanel.tsx b/desktop/frontend/src/components/TaskMonitorPanel.tsx index 9426243a3a..c05af27a93 100644 --- a/desktop/frontend/src/components/TaskMonitorPanel.tsx +++ b/desktop/frontend/src/components/TaskMonitorPanel.tsx @@ -58,6 +58,10 @@ function isTerminalState(state: string): boolean { return state === "succeeded" || state === "failed" || state === "cancelled" || state === "stale"; } +function isStoppableState(state: string): boolean { + return state === "queued" || state === "running" || state === "waiting"; +} + function elapsed(task: TaskTimerSnapshot, nowMs: number): string { if (!task.created_at) return "—"; const startMs = new Date(task.created_at).getTime(); @@ -90,7 +94,7 @@ function eventSummary(ev: TaskEvent, t: ReturnType): string { if (ev.error_code) return t("task.event.error", { code: ev.error_code }); switch (ev.event_type) { case "state_change": - return t("task.event.stateChange", { state: ev.state, runtime: runtimeConfig(ev.runtime_state, t).label }); + return t("task.event.stateChange", { state: stateConfig(ev.state, t).label, runtime: runtimeConfig(ev.runtime_state, t).label }); case "error": return ev.error_summary || t("task.error"); default: @@ -127,7 +131,9 @@ export function TaskMonitorPanel({ const [actionError, setActionError] = useState(null); const [actionMessage, setActionMessage] = useState(null); const [nowMs, setNowMs] = useState(() => Date.now()); - const [pendingAction, setPendingAction] = useState<{ task: TaskSnapshot; action: "stop" | "cancel" } | null>(null); + const [pendingStop, setPendingStop] = useState(null); + const stopButtonRefs = useRef>(new Map()); + const confirmStopRef = useRef(null); // Per-task event state const [taskEvents, setTaskEvents] = useState>( @@ -216,6 +222,24 @@ export function TaskMonitorPanel({ return () => clearInterval(interval); }, [tasks]); + useEffect(() => { + if (pendingStop) confirmStopRef.current?.focus(); + }, [pendingStop]); + + useEffect(() => { + if (!pendingStop) return; + const current = tasks.find((task) => task.task_id === pendingStop.task_id); + if (!current || !isStoppableState(current.state)) setPendingStop(null); + }, [pendingStop, tasks]); + + const dismissStopConfirmation = () => { + const taskID = pendingStop?.task_id; + setPendingStop(null); + if (taskID) { + requestAnimationFrame(() => stopButtonRefs.current.get(taskID)?.focus()); + } + }; + const toggleTask = (id: string) => { setExpanded((prev) => { const next = new Set(prev); @@ -232,12 +256,8 @@ export function TaskMonitorPanel({ }); }; - const controlTask = async (task: TaskSnapshot, action: "stop" | "cancel" | "requeue" | "open") => { - if ((action === "stop" || action === "cancel") && (!pendingAction || pendingAction.task.task_id !== task.task_id || pendingAction.action !== action)) { - setPendingAction({ task, action }); - return; - } - setPendingAction(null); + const controlTask = async (task: TaskSnapshot, action: "stop" | "requeue" | "open") => { + setPendingStop(null); setActionTask(task.task_id); setActionError(null); setActionMessage(null); @@ -249,11 +269,9 @@ export function TaskMonitorPanel({ } const result = action === "stop" ? await app.StopTaskForTab(tabID, task.task_id, task.version, "desktop request", `desktop-${action}-${task.task_id}-${task.version}`) - : action === "cancel" - ? await app.CancelTaskForTab(tabID, task.task_id, task.version, "desktop request", `desktop-${action}-${task.task_id}-${task.version}`) - : action === "requeue" - ? await app.RequeueTaskForTab(tabID, task.task_id, task.version, `desktop-${action}-${task.task_id}-${task.version}`) - : await app.OpenTaskSessionForTab(tabID, task.task_id); + : action === "requeue" + ? await app.RequeueTaskForTab(tabID, task.task_id, task.version, `desktop-${action}-${task.task_id}-${task.version}`) + : await app.OpenTaskSessionForTab(tabID, task.task_id); if (result.error) { setActionError(`${result.error.code}: ${result.error.message}`); } else if (action === "open") { @@ -408,7 +426,7 @@ export function TaskMonitorPanel({
{t("summary.sessionId")}
{task.session_id || "—"}
{t("summary.state")}
-
{task.state}
+
{cfg.label}
{t("summary.runtime")}
{runtime.label}
{t("summary.updated")}
@@ -485,23 +503,51 @@ export function TaskMonitorPanel({ )}
-
- {(task.state === "queued" || task.state === "running" || task.state === "waiting") && ( - <> - - - - )} - {(task.state === "failed" || task.state === "stale") && ( - - )} - -
- {pendingAction?.task.task_id === task.task_id && ( -
- {t(pendingAction.action === "stop" ? "summary.confirmStop" : "summary.confirmCancel")} - - + {pendingStop?.task_id === task.task_id ? ( +
{ + if (event.key === "Escape") { + event.preventDefault(); + dismissStopConfirmation(); + } + }} + > + {t("summary.confirmStop")} +
+ + +
+
+ ) : ( +
+ {isStoppableState(task.state) && ( + + )} + {(task.state === "failed" || task.state === "stale") && ( + + )} +
)}
diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 08943b26bc..1b7d03f980 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -75,12 +75,10 @@ export const en = { "summary.loadingEvents": "Loading events…", "summary.noEvents": "No events yet", "summary.stop": "Stop", - "summary.cancel": "Cancel", "summary.requeue": "Requeue", "summary.openSession": "Open session", "summary.confirmStop": "Stop this task?", - "summary.confirmCancel": "Cancel this task?", - "summary.keep": "Keep", + "summary.keep": "Keep running", "task.error": "Error", "task.event.error": "Error: {code}", "task.event.stateChange": "State → {state} · Runtime → {runtime}", @@ -188,7 +186,6 @@ export const en = { // topic bar "topicBar.renameSession": "Rename session", "topicBar.more": "More", - "topicBar.command": "Command", "topicBar.copyAll": "Copy session", "topicBar.export": "Export session", "topicBar.exportMarkdown": "Export Markdown", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 4d9b90cd1f..ba8baefb28 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -66,16 +66,14 @@ export const zhTW: Record = { "summary.loadingEvents": "載入…", "summary.noEvents": "無事件", "summary.stop": "停止", - "summary.cancel": "取消", "summary.requeue": "重新排隊", - "summary.openSession": "開啟", - "summary.confirmStop": "停止?", - "summary.confirmCancel": "取消?", - "summary.keep": "保留", + "summary.openSession": "開啟會話", + "summary.confirmStop": "停止此任務?", + "summary.keep": "繼續執行", "task.error": "錯誤", "task.event.error": "錯誤:{code}", "task.event.stateChange": "狀態 {state} · 執行時 {runtime}", - "task.runtime.live": "執行中", + "task.runtime.live": "在線", "task.runtime.exited": "已退出", "task.runtime.unknown": "執行時未知", "task.state.queued": "排隊中", @@ -2330,7 +2328,6 @@ export const zhTW: Record = { "botDetail.flowReceive": "IM 訊息進入本地話題", "botDetail.flowDesktop": "桌面端處理模型和工具", "botDetail.flowReply": "階段結果回傳到 IM", - "topicBar.command": "命令", "topicBar.exportPdf": "匯出 PDF", "topicBar.exportImage": "匯出圖片", "topicBar.exportSuccess": "已匯出 {count} 個檔案。", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index a50e403622..9872a70455 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -76,16 +76,14 @@ export const zh: Record = { "summary.loadingEvents": "加载中…", "summary.noEvents": "暂无事件", "summary.stop": "停止", - "summary.cancel": "取消", "summary.requeue": "重新排队", - "summary.openSession": "打开", - "summary.confirmStop": "停止?", - "summary.confirmCancel": "取消?", - "summary.keep": "保留", + "summary.openSession": "打开会话", + "summary.confirmStop": "停止此任务?", + "summary.keep": "继续运行", "task.error": "错误", "task.event.error": "错误:{code}", "task.event.stateChange": "状态 {state} · 运行 {runtime}", - "task.runtime.live": "运行中", + "task.runtime.live": "在线", "task.runtime.exited": "已退出", "task.runtime.unknown": "未知", "task.state.queued": "排队中", @@ -189,7 +187,6 @@ export const zh: Record = { // 话题栏 "topicBar.renameSession": "重命名会话", "topicBar.more": "更多", - "topicBar.command": "命令", "topicBar.copyAll": "复制会话", "topicBar.export": "导出会话", "topicBar.exportMarkdown": "导出 Markdown", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index e1b6ddec52..be7c9fdac5 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -37668,8 +37668,8 @@ body > .mermaid-diagram--fullscreen { .taskmonitor { background:var(--bg,#111); } .taskmonitor--popover { max-height:inherit; } .taskmonitor__head { display:flex; align-items:center; gap:6px; width:100%; padding:8px 12px; font-size:13px; } -.taskmonitor__toggle,.taskmonitor__refresh,.taskmonitor__close { background:none; border:0; color:inherit; cursor:pointer; } -.taskmonitor__toggle { display:flex; padding:0; } +.taskmonitor__toggle,.taskmonitor__refresh,.taskmonitor__close { display:inline-flex; align-items:center; justify-content:center; width:28px; height:28px; padding:0; border:0; border-radius:6px; background:none; color:inherit; cursor:pointer; } +.taskmonitor__toggle:hover,.taskmonitor__refresh:hover,.taskmonitor__close:hover,.taskmonitor__toggle:focus-visible,.taskmonitor__refresh:focus-visible,.taskmonitor__close:focus-visible { background:var(--bg-elev-2); outline:none; } .taskmonitor__title { flex:1; text-align:left; font-weight:600; } .taskmonitor__count { background:var(--muted,#555); color:#fff; border-radius:10px; padding:0 6px; font-size:11px; line-height:18px; } .taskmonitor__body { max-height:360px; overflow-y:auto; } @@ -37679,7 +37679,8 @@ body > .mermaid-diagram--fullscreen { .taskmonitor__state--error { color:#ef4444; } .taskmonitor__task { border-bottom:1px solid var(--border,#2a2a2a); } .taskmonitor__task-head { display:flex; align-items:center; } -.taskmonitor__expand { display:flex; align-items:center; gap:6px; width:100%; padding:6px 12px; background:none; border:0; color:inherit; cursor:pointer; font-size:12px; } +.taskmonitor__expand { display:flex; align-items:center; gap:6px; width:100%; min-height:32px; padding:6px 12px; background:none; border:0; color:inherit; cursor:pointer; font-size:12px; } +.taskmonitor__expand:hover,.taskmonitor__expand:focus-visible { background:var(--bg-elev-2); outline:none; } .taskmonitor__dot { font-size:10px; width:14px; text-align:center; } .taskmonitor__id { font-family:monospace; font-size:11px; color:var(--muted,#888); } .taskmonitor__badge { font-size:10px; padding:1px 6px; border-radius:8px; font-weight:600; text-transform:uppercase; } @@ -37692,7 +37693,15 @@ body > .mermaid-diagram--fullscreen { .taskmonitor__events { margin-top:8px; border-top:1px solid var(--border,#333); padding-top:6px; } .taskmonitor__event { display:flex; gap:8px; line-height:20px; } .taskmonitor__actions { display:flex; gap:8px; margin-top:10px; } -.taskmonitor__actions button,.taskmonitor__confirm button { padding:4px 10px; border:1px solid var(--border,#555); border-radius:4px; background:var(--surface,#222); color:inherit; cursor:pointer; } +.taskmonitor__actions button,.taskmonitor__confirm button { min-height:32px; padding:0 12px; border:1px solid var(--border,#555); border-radius:6px; background:var(--surface,#222); color:inherit; cursor:pointer; } +.taskmonitor__actions button:hover:not(:disabled),.taskmonitor__confirm button:hover:not(:disabled),.taskmonitor__actions button:focus-visible,.taskmonitor__confirm button:focus-visible { border-color:var(--accent); outline:2px solid color-mix(in srgb,var(--accent) 38%,transparent); outline-offset:1px; } +.taskmonitor__actions button:disabled,.taskmonitor__confirm button:disabled { cursor:not-allowed; opacity:.55; } +.taskmonitor__actions .taskmonitor__stop { border-color:color-mix(in srgb,var(--danger,#ef4444) 48%,var(--border)); color:var(--danger,#ef4444); } +.taskmonitor__confirm { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-top:10px; padding:10px 12px; border:1px solid color-mix(in srgb,var(--danger,#ef4444) 30%,var(--border)); border-radius:8px; background:color-mix(in srgb,var(--danger,#ef4444) 7%,var(--surface,#222)); } +.taskmonitor__confirm-copy { flex:1 1 150px; font-weight:600; line-height:1.4; } +.taskmonitor__confirm-actions { display:flex; gap:8px; } +.taskmonitor__confirm .taskmonitor__confirm-stop { border-color:var(--danger,#ef4444); background:var(--danger,#ef4444); color:#fff; font-weight:650; } +.taskmonitor__confirm .taskmonitor__confirm-stop:hover:not(:disabled),.taskmonitor__confirm .taskmonitor__confirm-stop:focus-visible { border-color:var(--danger,#ef4444); outline-color:color-mix(in srgb,var(--danger,#ef4444) 42%,transparent); } .taskmonitor__error { color:#ef4444; margin-top:6px; } .taskmonitor__message { color:#4ade80; margin-top:6px; }