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
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-11 19:07:20 +08:00
1 parent ee17c62901
commit 6d262926e0
8 files changed
+194 -91

No files matched your search

+7 -37
View File
@@ -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 && (
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
<Tooltip label={t("shortcuts.cheatsheetTitle")}>
<Tooltip label={t("summary.session")}>
<button
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
className={`topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility${tasksOpen ? " topicbar__action-btn--active" : ""}`}
type="button"
aria-label={t("shortcuts.cheatsheetTitle")}
onClick={() => {
closeTransientOverlays();
setSettingsFocus(null);
setSettingsTarget("shortcuts");
}}
aria-label={t("summary.session")}
aria-expanded={tasksOpen}
onClick={() => setTasksOpen((open) => !open)}
>
<CircleHelp size={14} />
</button>
</Tooltip>
<Tooltip label={t("topicBar.command")}>
<button
className={
sidebarCreation
? "topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
: "topicbar__action-btn topicbar__action-btn--label topicbar__action-btn--accent"
}
type="button"
aria-label={t("topicBar.command")}
onClick={() => void openPalette()}
>
<Command size={14} />
{!sidebarCreation && <span>{t("topicBar.command")}</span>}
<Activity size={14} />
</button>
</Tooltip>
{(sidebarCreation || workbenchChromeHidden) && (
@@ -4806,19 +4787,8 @@ export default function App() {
</button>
</Tooltip>
)}
<Tooltip label="Session summary">
<button
className={`topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility${tasksOpen ? " topicbar__action-btn--active" : ""}`}
type="button"
aria-label="Session summary"
aria-expanded={tasksOpen}
onClick={() => setTasksOpen((open) => !open)}
>
<Activity size={14} />
</button>
</Tooltip>
{tasksOpen && (
<div className="taskmonitor-popover" role="dialog" aria-label="Session summary">
<div className="taskmonitor-popover" role="dialog" aria-label={t("summary.session")}>
<Suspense fallback={null}>
<TaskMonitorPanel
key={`${activeTab?.id || activeTabId || "none"}:${activeTab?.workspaceRoot || "global"}:${activeTab?.sessionPath || ""}`}
@@ -0,0 +1,19 @@
// Run: tsx src/__tests__/topicbar-controls.test.ts
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const testDir = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
assert.doesNotMatch(appSource, /t\("shortcuts\.cheatsheetTitle"\)|t\("topicBar\.command"\)/);
const taskSummaryControlIndex = appSource.indexOf('<Tooltip label={t("summary.session")}>');
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
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");
@@ -68,6 +68,7 @@ let listTasksImpl: () => Promise<Task[]> = async () => [];
let listEventsImpl: () => Promise<Event[]> = 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<HTMLButtonElement>(".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<HTMLElement>(".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();
@@ -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<typeof useT>): 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<string | null>(null);
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const [pendingAction, setPendingAction] = useState<{ task: TaskSnapshot; action: "stop" | "cancel" } | null>(null);
const [pendingStop, setPendingStop] = useState<TaskSnapshot | null>(null);
const stopButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const confirmStopRef = useRef<HTMLButtonElement | null>(null);
// Per-task event state
const [taskEvents, setTaskEvents] = useState<Map<string, TaskEvent[]>>(
@@ -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({
<dt>{t("summary.sessionId")}</dt>
<dd>{task.session_id || "—"}</dd>
<dt>{t("summary.state")}</dt>
<dd>{task.state}</dd>
<dd>{cfg.label}</dd>
<dt>{t("summary.runtime")}</dt>
<dd>{runtime.label}</dd>
<dt>{t("summary.updated")}</dt>
@@ -485,23 +503,51 @@ export function TaskMonitorPanel({
</ul>
)}
</div>
<div className="taskmonitor__actions">
{(task.state === "queued" || task.state === "running" || task.state === "waiting") && (
<>
<button disabled={actionTask === task.task_id} onClick={() => void controlTask(task, "stop")}>{t("summary.stop")}</button>
<button disabled={actionTask === task.task_id} onClick={() => void controlTask(task, "cancel")}>{t("summary.cancel")}</button>
</>
)}
{(task.state === "failed" || task.state === "stale") && (
<button disabled={actionTask === task.task_id || task.runtime_state === "alive"} onClick={() => void controlTask(task, "requeue")}>{t("summary.requeue")}</button>
)}
<button disabled={actionTask === task.task_id} onClick={() => void controlTask(task, "open")}>{t("summary.openSession")}</button>
</div>
{pendingAction?.task.task_id === task.task_id && (
<div className="taskmonitor__confirm">
<span>{t(pendingAction.action === "stop" ? "summary.confirmStop" : "summary.confirmCancel")}</span>
<button type="button" onClick={() => void controlTask(task, pendingAction.action)}>{t("common.confirm")}</button>
<button type="button" onClick={() => setPendingAction(null)}>{t("summary.keep")}</button>
{pendingStop?.task_id === task.task_id ? (
<div
className="taskmonitor__confirm"
role="group"
aria-label={t("summary.confirmStop")}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
dismissStopConfirmation();
}
}}
>
<span className="taskmonitor__confirm-copy">{t("summary.confirmStop")}</span>
<div className="taskmonitor__confirm-actions">
<button
ref={confirmStopRef}
type="button"
className="taskmonitor__confirm-stop"
disabled={actionTask === task.task_id}
onClick={() => void controlTask(task, "stop")}
>
{t("summary.stop")}
</button>
<button type="button" onClick={dismissStopConfirmation}>{t("summary.keep")}</button>
</div>
</div>
) : (
<div className="taskmonitor__actions">
{isStoppableState(task.state) && (
<button
ref={(node) => {
if (node) stopButtonRefs.current.set(task.task_id, node);
else stopButtonRefs.current.delete(task.task_id);
}}
className="taskmonitor__stop"
disabled={actionTask === task.task_id}
onClick={() => setPendingStop(task)}
>
{t("summary.stop")}
</button>
)}
{(task.state === "failed" || task.state === "stale") && (
<button disabled={actionTask === task.task_id || task.runtime_state === "alive"} onClick={() => void controlTask(task, "requeue")}>{t("summary.requeue")}</button>
)}
<button disabled={actionTask === task.task_id} onClick={() => void controlTask(task, "open")}>{t("summary.openSession")}</button>
</div>
)}
</div>
+1 -4
View File
@@ -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",
+4 -7
View File
@@ -66,16 +66,14 @@ export const zhTW: Record<DictKey, string> = {
"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<DictKey, string> = {
"botDetail.flowReceive": "IM 訊息進入本地話題",
"botDetail.flowDesktop": "桌面端處理模型和工具",
"botDetail.flowReply": "階段結果回傳到 IM",
"topicBar.command": "命令",
"topicBar.exportPdf": "匯出 PDF",
"topicBar.exportImage": "匯出圖片",
"topicBar.exportSuccess": "已匯出 {count} 個檔案。",
+4 -7
View File
@@ -76,16 +76,14 @@ export const zh: Record<DictKey, string> = {
"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<DictKey, string> = {
// 话题栏
"topicBar.renameSession": "重命名会话",
"topicBar.more": "更多",
"topicBar.command": "命令",
"topicBar.copyAll": "复制会话",
"topicBar.export": "导出会话",
"topicBar.exportMarkdown": "导出 Markdown",
+13 -4
View File
@@ -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; }