diff --git a/docs/automation/tasks.md b/docs/automation/tasks.md index ca700fc6b7ba..38ce5919498a 100644 --- a/docs/automation/tasks.md +++ b/docs/automation/tasks.md @@ -291,9 +291,9 @@ For the full operator ledger, use the CLI: `openclaw tasks list`. The web Control UI has a **Tasks** page in the sidebar with live active and recent background tasks. Use it to inspect progress, open linked sessions, refresh the ledger, or cancel queued and running tasks. -Chat panes also have a collapsible **Background tasks** rail scoped to the pane's agent: running tasks and subagents with a stop control, a finished section, and View transcript links into each task's child session. Open it from the activity toggle in the pane header (or the floating activity button in single-pane chat). +Chat panes also have a collapsible **Background tasks** rail scoped to the pane's agent, with running work, stop controls, and a finished section. Open it from the activity toggle in the pane header (or the floating activity button in single-pane chat). -Select a task in the rail to inspect its bounded input prompt and latest output or error summary. Running work stays separate from finished work, and finished rows show whether the task completed or failed. On iOS, open **Chat actions → Background Tasks**; on Android, open the Chat overflow menu and select **Background tasks**. Both mobile views use the same Running and Finished grouping and open task details on selection. +Select a task to replace the list with a compact detail view inside the rail; use the back button to return to the list. The detail view shows the bounded input prompt, latest output or error summary, timing, and current tool activity. Subagent details stay in the rail rather than opening their child conversation in the main chat pane; linked-session actions remain available for task runtimes intended for direct inspection. On iOS, open **Chat actions → Background Tasks**; on Android, open the Chat overflow menu and select **Background tasks**. Both mobile views use the same Running and Finished grouping and open task details on selection. ## Status integration (task pressure) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 7edb0a08d91d..00aa8afadc7b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -261,7 +261,7 @@ select it to open the owning Approvals page. - Automations (cron jobs): stat cards (automation count, failing count, scheduler state, next wake) above an Automations/Run history tab switch; the Automations tab lists jobs in a filterable table (All/Active/Paused, search, schedule and last-run filters, per-row action menu) with starter suggestions below, and the Run history tab shows recent runs across all automations (`cron.*`). - - Tasks: live active and recent background task ledger with linked sessions and cancellation (`tasks.*`). Chat's Background tasks rail groups running and finished work; select a row to inspect its bounded prompt and output or error summary. + - Tasks: live active and recent background task ledger with linked sessions and cancellation (`tasks.*`). Chat's Background tasks rail groups running and finished work; selecting a row opens a compact in-rail detail view with a back button, bounded prompt, live activity, and output or error summary. - Plugins: browse the installed inventory and curated store, search ClawHub, install and remove plugin code, and enable or disable installed plugins (`plugins.*`); MCP server rows edit `mcp.servers` through the config methods. - Skills: status, enable/disable, install, API key updates (`skills.*`). - Devices: one inventory joins paired device records, the node catalog, and live presence (`device.pair.list`, `node.list`, `system-presence`). The Gateway host is pinned first; paired clients show connection status, roles, tokens, capabilities, and commands. Duplicate pairings collapse into an expandable group, and **Clean up N stale** bulk-removes admin-confirmed offline duplicates that were auto-approved (silent local, trusted-CIDR, or SSH-verified) or predate approval provenance. Entries can be removed (`node.pair.remove`, `device.pair.remove`), device pairing and node re-approvals handled inline (`device.pair.*`, `node.pair.approve`/`reject`), and mobile setup codes created from the same card. @@ -433,7 +433,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das - Every Chat pane has a title bar. Click the session title to rename it; the workspace chip copies the checkout path or branch and can reveal local Gateway workspaces in the host file manager. Remote and exec-node sessions keep copy actions but hide reveal. - The thread workspace rail in each Chat pane lists thread files, project files, and artifacts. It docks to the pane's right edge by default; drag its header (or use the dock button) to move it to the bottom, and the choice is stored in the current browser profile. A collapsed rail takes no space at all: reopen it with ⇧⌘B or the files toggle in the title bar, which carries a changed-file count badge. The separate file, tool, and Canvas detail panel is unaffected. - Clicking a file reference in chat, a file path in an expanded read/edit/write tool card, or a file row in the workspace rail opens the file detail panel: a CodeMirror-based code view with syntax highlighting, line numbers, jump-to-line, in-file search, copy actions, and an open-in-external-editor menu. When the Gateway advertises `sessions.files.set` to an `operator.admin` connection, the panel adds an Edit mode with dirty tracking and Cmd/Ctrl-S save; unsaved drafts survive file, panel, and session navigation in the current browser tab until explicitly saved or discarded. Saves are compare-and-swap on a content hash returned by `sessions.files.get`: if the file changed on disk since it was loaded (for example because the agent kept working), the panel shows a conflict notice with Reload (take the latest content) and Overwrite (keep the local edit) actions. Writes go through the same fs-safe workspace guards as reads — path containment, symlink/hardlink rejection, and a 256 KB UTF-8 cap — and only overwrite existing files; the editor never creates or deletes them. - - The background tasks rail in each Chat pane lists the current agent's background tasks and subagents (`tasks.list` scoped by agent, kept live by `task` events): running work shows a live elapsed timer, tool-use count, the tool currently in use, and a stop control; the collapsible finished section adds run durations; and a View transcript link opens the task's child session in the pane. Open it with the title-bar activity toggle; the task snapshot loads eagerly, so it carries a running-count badge without opening the rail first. The Tasks page remains the full cross-agent ledger. + - The background tasks rail in each Chat pane lists the current agent's background tasks and subagents (`tasks.list` scoped by agent, kept live by `task` events): running work shows a live elapsed timer, tool-use count, the tool currently in use, and a stop control, while the collapsible finished section adds run durations. Selecting a row replaces the list with a compact detail view in the same rail; its back button returns to the list, and subagent inspection never replaces the main conversation with the child transcript. Open the rail with the title-bar activity toggle; the task snapshot loads eagerly, so it carries a running-count badge without opening the rail first. The Tasks page remains the full cross-agent ledger. - The workspace rail, background tasks rail, and detail panel adapt to each pane's own width rather than the window: in a narrow pane or compact window both rails present as bottom strips (side-dock controls hide until the pane widens; the workspace rail keeps first claim on the side slot when only one column fits), and the detail panel stacks below the thread with a horizontal resize handle instead of sharing the row with it. Phone-sized viewports still open the detail panel full-screen. - The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. - **Split view:** open it from the chat title bar (beside the thread diff, background tasks, and thread files toggles), then split the active pane right or down for as many panes as fit. Each pane has its own thread, transcript, composer, and tool stream. diff --git a/scripts/control-ui-mock-background-tasks.ts b/scripts/control-ui-mock-background-tasks.ts index 05cb9d10e8bf..e1ded41aed50 100644 --- a/scripts/control-ui-mock-background-tasks.ts +++ b/scripts/control-ui-mock-background-tasks.ts @@ -2,9 +2,73 @@ function historyMessage(role: "assistant" | "user", text: string, timestamp: num return { content: [{ type: "text", text }], role, timestamp }; } +function finishedTask(n: number, now: number) { + const task = { + id: `task-mock-finished-${n}`, + taskId: `task-mock-finished-${n}`, + status: n === 3 ? "failed" : "completed", + runtime: "subagent", + agentId: "openclaw-mock", + title: `Finished mock task number ${n} with a fairly long title`, + createdAt: now - n * 600_000, + startedAt: now - n * 600_000, + endedAt: now - n * 500_000, + updatedAt: now - n * 500_000, + }; + return n === 3 + ? { ...task, error: "Mock task stopped after finding an invalid event scope." } + : { ...task, terminalSummary: `Mock task ${n} completed its assigned inspection.` }; +} + +function taskDetailCase(task: { id: string; title: string } & Record) { + return { + match: { taskId: task.id }, + response: { + task: { + ...task, + prompt: `Inspect ${task.title.toLowerCase()} and report the current execution path.`, + }, + }, + }; +} + export function buildBackgroundTasksMock(baseTime: number) { const now = Date.now(); const taskSessionKey = "agent:openclaw-mock:subagent:mock-task-1"; + const tasks = [ + { + id: "task-mock-running", + taskId: "task-mock-running", + status: "running", + runtime: "subagent", + agentId: "openclaw-mock", + title: "Map run-status indicator code", + createdAt: now - 25_000, + startedAt: now - 25_000, + updatedAt: now, + toolUseCount: 7, + lastToolName: "read", + progressSummary: "Tracing task events through the background task rail", + childSessionKey: taskSessionKey, + }, + { + id: "task-mock-running-2", + taskId: "task-mock-running-2", + status: "running", + runtime: "subagent", + agentId: "openclaw-mock", + title: "Audit gateway event scope guards", + createdAt: now - 95_000, + startedAt: now - 95_000, + updatedAt: now - 1_000, + progressSummary: "Comparing agent-scoped task event paths", + }, + finishedTask(1, now), + finishedTask(2, now), + finishedTask(3, now), + finishedTask(4, now), + finishedTask(5, now), + ]; return { "chat.history": { cases: [ @@ -30,46 +94,7 @@ export function buildBackgroundTasksMock(baseTime: number) { ], }, // One live subagent task exercises the rail, collapsed badge, and running-task status row. - "tasks.list": { - tasks: [ - { - id: "task-mock-running", - taskId: "task-mock-running", - status: "running", - runtime: "subagent", - agentId: "openclaw-mock", - title: "Map run-status indicator code", - createdAt: now - 25_000, - startedAt: now - 25_000, - updatedAt: now, - toolUseCount: 7, - lastToolName: "read", - childSessionKey: taskSessionKey, - }, - { - id: "task-mock-running-2", - taskId: "task-mock-running-2", - status: "running", - runtime: "subagent", - agentId: "openclaw-mock", - title: "Audit gateway event scope guards", - createdAt: now - 95_000, - startedAt: now - 95_000, - updatedAt: now - 1_000, - }, - ...[1, 2, 3, 4, 5].map((n) => ({ - id: `task-mock-finished-${n}`, - taskId: `task-mock-finished-${n}`, - status: n === 3 ? "failed" : "completed", - runtime: "subagent", - agentId: "openclaw-mock", - title: `Finished mock task number ${n} with a fairly long title`, - createdAt: now - n * 600_000, - startedAt: now - n * 600_000, - endedAt: now - n * 500_000, - updatedAt: now - n * 500_000, - })), - ], - }, + "tasks.list": { tasks }, + "tasks.get": { cases: tasks.map(taskDetailCase) }, }; } diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index a857c5c79876..310aaf5501cd 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4403,7 +4403,8 @@ export const en: TranslationMap = { toolUseOne: "1 tool use", toolUseMany: "{count} tool uses", expandTask: "Show details for {title}", - collapseTask: "Hide details for {title}", + detailTitle: "Task details", + backToTasks: "Back to background tasks", detailLoading: "Loading task details…", detailFailed: "Could not load task details.", prompt: "Prompt", diff --git a/ui/src/pages/chat/background-tasks.e2e.test.ts b/ui/src/pages/chat/background-tasks.e2e.test.ts index 0097b1fe5bdc..0a4597d5534c 100644 --- a/ui/src/pages/chat/background-tasks.e2e.test.ts +++ b/ui/src/pages/chat/background-tasks.e2e.test.ts @@ -173,12 +173,17 @@ describeControlUiE2e("Control UI chat background-tasks rail mocked Gateway E2E", } await page.screenshot({ path: path.join(artifactDir, "01-rail-open.png"), fullPage: true }); + const chatUrl = page.url(); await rail .locator('[data-task-id="task-subagent"]') .getByRole("button", { name: "Show details for Map model routing code" }) .click(); await rail.getByText("Trace model routing across provider and session boundaries.").waitFor(); expect(await rail.textContent()).toContain("Reading provider catalogs"); + expect(await rail.getByRole("button", { name: "Back to background tasks" }).count()).toBe(1); + expect(await rail.getByRole("button", { name: "View transcript" }).count()).toBe(0); + expect(page.url()).toBe(chatUrl); + await page.getByText("Background tasks rail proof.").waitFor({ state: "visible" }); const detailRequest = await gateway.waitForRequest("tasks.get"); expect(detailRequest.params).toEqual({ taskId: "task-subagent" }); await page.screenshot({ @@ -195,17 +200,19 @@ describeControlUiE2e("Control UI chat background-tasks rail mocked Gateway E2E", terminalSummary: "Routing map complete", }, }); + await rail.getByText("Routing map complete").waitFor({ state: "visible" }); + await page.screenshot({ + path: path.join(artifactDir, "03-pushed-completion.png"), + fullPage: true, + }); + + await rail.getByRole("button", { name: "Back to background tasks" }).click(); await rail .locator('[data-tasks-section="finished"] [data-task-id="task-subagent"]') .waitFor({ state: "visible" }); await rail .locator('[data-tasks-section="running"] [data-task-id="task-subagent"]') .waitFor({ state: "detached" }); - expect(await rail.textContent()).toContain("Routing map complete"); - await page.screenshot({ - path: path.join(artifactDir, "03-pushed-completion.png"), - fullPage: true, - }); await rail .locator('[data-task-id="task-cron"]') @@ -213,27 +220,16 @@ describeControlUiE2e("Control UI chat background-tasks rail mocked Gateway E2E", .click(); const cancelRequest = await gateway.waitForRequest("tasks.cancel"); expect(cancelRequest.params).toEqual({ taskId: "task-cron" }); - - const transcriptButton = rail - .locator('[data-task-id="task-subagent"]') - .getByRole("button", { name: "View transcript" }); - await transcriptButton.click(); - await expect - .poll(() => new URL(page.url()).searchParams.get("session")) - .toBe("agent:main:subagent:routing"); - await page.getByText("Subagent transcript proof.").waitFor({ state: "visible" }); - await page.getByText("Background tasks rail proof.").waitFor({ state: "detached" }); - await expect - .poll(async () => - (await gateway.getRequests("chat.history")).some( - (request) => - (request.params as { sessionKey?: string }).sessionKey === - runningSubagent.childSessionKey, - ), - ) - .toBe(true); + expect(page.url()).toBe(chatUrl); + expect( + (await gateway.getRequests("chat.history")).some( + (request) => + (request.params as { sessionKey?: string }).sessionKey === + runningSubagent.childSessionKey, + ), + ).toBe(false); await page.screenshot({ - path: path.join(artifactDir, "04-transcript-open.png"), + path: path.join(artifactDir, "04-back-to-list.png"), fullPage: true, }); } finally { diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index d01ab896005b..51ade04dcb97 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -1818,7 +1818,8 @@ describe("chat composer workbench", () => { onToggleFinished: () => undefined, onRefresh: () => undefined, onCancel: () => undefined, - onToggleTask: () => undefined, + onSelectTask: () => undefined, + onBackToList: () => undefined, onOpenSession: () => undefined, }; @@ -1864,7 +1865,8 @@ describe("chat composer workbench", () => { onToggleFinished: () => undefined, onRefresh: () => undefined, onCancel: () => undefined, - onToggleTask: () => undefined, + onSelectTask: () => undefined, + onBackToList: () => undefined, onOpenSession: () => undefined, }; const messages = [{ role: "assistant", content: "done", timestamp: 1 }]; diff --git a/ui/src/pages/chat/components/chat-background-task-row.ts b/ui/src/pages/chat/components/chat-background-task-row.ts index a93d3f28f5a9..85aaaf10716b 100644 --- a/ui/src/pages/chat/components/chat-background-task-row.ts +++ b/ui/src/pages/chat/components/chat-background-task-row.ts @@ -19,71 +19,183 @@ import { } from "./chat-background-tasks-shared.ts"; import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; -export function renderTaskRow(task: TaskSummary, props: BackgroundTasksProps): TemplateResult { +type TaskDisplayFacts = { + active: boolean; + finishedDuration?: string; + startedMs: number; + timestamp: number; + title: string; + toolUseCount: number; + transcriptSessionKey?: string; +}; + +function taskDisplayFacts(task: TaskSummary): TaskDisplayFacts { const active = isActiveTask(task); - const title = taskTitle(task); - const detail = taskDetail(task); - const timestamp = taskTimestampMs(task.updatedAt ?? task.createdAt); const startedMs = taskTimestampMs(task.startedAt ?? task.createdAt); const endedMs = taskTimestampMs(task.endedAt); - const finishedDuration = - !active && endedMs > startedMs && startedMs > 0 - ? formatDurationCompact(endedMs - startedMs, { spaced: true }) - : undefined; - const toolUseCount = task.toolUseCount ?? 0; - const transcriptSessionKey = task.childSessionKey ?? task.sessionKey; - const cancelling = props.cancellingTaskIds.has(task.id); + return { + active, + finishedDuration: + !active && endedMs > startedMs && startedMs > 0 + ? formatDurationCompact(endedMs - startedMs, { spaced: true }) + : undefined, + startedMs, + timestamp: taskTimestampMs(task.updatedAt ?? task.createdAt), + title: taskTitle(task), + toolUseCount: task.toolUseCount ?? 0, + transcriptSessionKey: task.childSessionKey ?? task.sessionKey, + }; +} + +function renderTaskMeta( + task: TaskSummary, + props: BackgroundTasksProps, + facts: TaskDisplayFacts, +): TemplateResult { const tone = STATUS_TONES[task.status]; - const expanded = props.selectedTaskId === task.id; - const detailedTask = props.taskDetails.get(task.id); - const detailLoading = props.taskDetailLoadingIds.has(task.id); - const detailError = props.taskDetailErrors.get(task.id); - // List/events and lookup can race; newest activity owns mutable output. - const output = taskDetail(newestTaskSnapshot(task, detailedTask)); - const disclosureLabel = expanded - ? t("chat.backgroundTasks.collapseTask", { title }) - : t("chat.backgroundTasks.expandTask", { title }); + const showTranscript = task.runtime !== "subagent" && facts.transcriptSessionKey; + return html` +
+ ${backgroundTaskStatusLabel(task)} + + ${taskRuntimeLabel(task)} + ${facts.active && facts.startedMs > 0 + ? html` + ` + : nothing} + ${facts.finishedDuration + ? html` + ${facts.finishedDuration}` + : nothing} + ${!facts.active && facts.timestamp > 0 + ? html` + ${formatRelativeTimestamp(facts.timestamp)}` + : nothing} + ${facts.toolUseCount > 0 + ? html` + ${facts.toolUseCount === 1 + ? t("chat.backgroundTasks.toolUseOne") + : t("chat.backgroundTasks.toolUseMany", { + count: String(facts.toolUseCount), + })}` + : nothing} + ${facts.active && task.lastToolName + ? html` + ${task.lastToolName}` + : nothing} + ${showTranscript + ? html` + + ` + : nothing} +
+ `; +} + +export function renderTaskRow(task: TaskSummary, props: BackgroundTasksProps): TemplateResult { + const facts = taskDisplayFacts(task); + const detail = taskDetail(task); + const cancelling = props.cancellingTaskIds.has(task.id); return html`
{ const target = event.target; - if ( - target instanceof Element && - target.closest("button, a, .chat-tasks-rail__task-inspector") - ) { + if (target instanceof Element && target.closest("button, a")) { return; } - props.onToggleTask(task); + props.onSelectTask(task); }} >
- ${active && props.canCancel + ${facts.active && props.canCancel ? html` - + + + ` + : nothing} +
+ ${renderTaskMeta(task, props, facts)} + ${detail ? html`
${detail}
` : nothing} +
+ `; +} + +export function renderTaskDetail(task: TaskSummary, props: BackgroundTasksProps): TemplateResult { + const detailedTask = props.taskDetails.get(task.id); + const newest = newestTaskSnapshot(task, detailedTask); + const facts = taskDisplayFacts(newest); + const output = taskDetail(newest); + const detailLoading = props.taskDetailLoadingIds.has(task.id); + const detailError = props.taskDetailErrors.get(task.id); + const cancelling = props.cancellingTaskIds.has(task.id); + return html` +
+
+
+ ${newest.status === "running" + ? html`` + : nothing} + ${renderTaskMeta(newest, props, facts)} +
+ ${facts.active && props.canCancel + ? html` + +
-
- ${backgroundTaskStatusLabel(task)} - - ${taskRuntimeLabel(task)} - ${active && startedMs > 0 - ? html` - ` - : nothing} - ${finishedDuration - ? html` - ${finishedDuration}` - : nothing} - ${!active && timestamp > 0 - ? html` - ${formatRelativeTimestamp(timestamp)}` - : nothing} - ${toolUseCount > 0 - ? html` - ${toolUseCount === 1 - ? t("chat.backgroundTasks.toolUseOne") - : t("chat.backgroundTasks.toolUseMany", { count: String(toolUseCount) })}` - : nothing} - ${active && task.lastToolName - ? html` - ${task.lastToolName}` - : nothing} - ${transcriptSessionKey - ? html` - - ` - : nothing} -
- ${detail ? html`
${detail}
` : nothing} - ${expanded - ? html` -
- ${detailLoading - ? html`
- ${t("chat.backgroundTasks.detailLoading")} -
` - : detailError - ? html`
- ${detailError} -
` - : html` -
-
- ${t("chat.backgroundTasks.prompt")} -
-
-${detailedTask?.prompt ?? t("chat.backgroundTasks.promptUnavailable")}
-
-
-
- ${t("chat.backgroundTasks.output")} -
-
${output ?? t("chat.backgroundTasks.outputPending")}
-
- `} -
- ` + ${newest.progressSummary + ? html`
+ ${newest.lastToolName ?? taskRuntimeLabel(newest)} + ${newest.progressSummary} +
` : nothing} + ${detailError + ? html`
+ ${detailError} +
` + : nothing} +
+
+
+ ${t("chat.backgroundTasks.prompt")} +
+
+${detailLoading
+              ? t("chat.backgroundTasks.detailLoading")
+              : (detailedTask?.prompt ?? t("chat.backgroundTasks.promptUnavailable"))}
+
+
+
+ ${t("chat.backgroundTasks.output")} +
+
${output ?? t("chat.backgroundTasks.outputPending")}
+
+
`; } diff --git a/ui/src/pages/chat/components/chat-background-tasks.test.ts b/ui/src/pages/chat/components/chat-background-tasks.test.ts index b88783cc0408..77c6f67cd783 100644 --- a/ui/src/pages/chat/components/chat-background-tasks.test.ts +++ b/ui/src/pages/chat/components/chat-background-tasks.test.ts @@ -17,6 +17,12 @@ function flushAsync() { }); } +function flushAnimationFrame() { + return new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()); + }); +} + function makeTask(overrides: Partial & { id: string }): TaskSummary { return { taskId: overrides.id, @@ -156,13 +162,69 @@ describe("background tasks rail state", () => { createBackgroundTasksProps(host, openSession); await flushAsync(); - createBackgroundTasksProps(host, openSession).onToggleTask(running); + createBackgroundTasksProps(host, openSession).onSelectTask(running); await flushAsync(); expect(request).toHaveBeenCalledWith("tasks.get", { taskId: "task-1" }); const props = createBackgroundTasksProps(host, openSession); expect(props.selectedTaskId).toBe("task-1"); expect(props.taskDetails.get("task-1")?.prompt).toBe("Audit the background task UI"); + + props.onBackToList(); + expect(createBackgroundTasksProps(host, openSession).selectedTaskId).toBeNull(); + }); + + it("moves focus into task details and restores it to the selected row", async () => { + const running = makeTask({ id: "task-1", progressSummary: "Reading files" }); + const completed = makeTask({ + id: "task-1", + status: "completed", + updatedAt: 3_000, + terminalSummary: "Audit complete", + prompt: "Audit the task rail", + }); + const { host } = createHost({ + request: (method) => + method === "tasks.get" + ? Promise.resolve({ task: completed }) + : Promise.resolve({ tasks: [running] }), + }); + createBackgroundTasksProps(host, openSession); + await flushAsync(); + + const container = document.createElement("div"); + document.body.append(container); + const renderRail = () => { + render( + html`${renderBackgroundTasksRail(createBackgroundTasksProps(host, openSession))}`, + container, + ); + }; + host.requestUpdate = renderRail; + const initialProps = createBackgroundTasksProps(host, openSession); + initialProps.onToggleCollapsed(); + initialProps.onToggleFinished(); + renderRail(); + + const disclosure = container.querySelector( + ".chat-tasks-rail__task-disclosure", + ); + disclosure?.focus(); + disclosure?.click(); + await flushAnimationFrame(); + + const back = container.querySelector(".chat-tasks-rail__back"); + expect(document.activeElement).toBe(back); + back?.click(); + await flushAnimationFrame(); + + expect(createBackgroundTasksProps(host, openSession).finishedCollapsed).toBe(false); + expect( + container.querySelector('[data-tasks-section="finished"] [data-task-id="task-1"]'), + ).not.toBeNull(); + expect(document.activeElement).toBe( + container.querySelector(".chat-tasks-rail__task-disclosure"), + ); }); it("promotes a newer detail snapshot into the grouped task list", async () => { @@ -183,7 +245,7 @@ describe("background tasks rail state", () => { createBackgroundTasksProps(host, openSession); await flushAsync(); - createBackgroundTasksProps(host, openSession).onToggleTask(running); + createBackgroundTasksProps(host, openSession).onSelectTask(running); await flushAsync(); const props = createBackgroundTasksProps(host, openSession); @@ -225,7 +287,7 @@ describe("background tasks rail state", () => { await flushAsync(); createBackgroundTasksProps(host, openSession).onRefresh(); - createBackgroundTasksProps(host, openSession).onToggleTask(running); + createBackgroundTasksProps(host, openSession).onSelectTask(running); await flushAsync(); resolveActive?.({ tasks: [running] }); resolveRecent?.({ tasks: [running] }); @@ -253,7 +315,7 @@ describe("background tasks rail state", () => { createBackgroundTasksProps(host, openSession); await flushAsync(); - createBackgroundTasksProps(host, openSession).onToggleTask(running); + createBackgroundTasksProps(host, openSession).onSelectTask(running); handleBackgroundTasksEvent(host, { action: "deleted", taskId: "task-1" }); resolveDetail?.({ task: { ...running, prompt: "Deleted task prompt" } }); await flushAsync(); @@ -345,7 +407,7 @@ describe("background tasks rail events", () => { }); createBackgroundTasksProps(host, openSession); await flushAsync(); - createBackgroundTasksProps(host, openSession).onToggleTask(running); + createBackgroundTasksProps(host, openSession).onSelectTask(running); await flushAsync(); handleBackgroundTasksEvent(host, { @@ -364,9 +426,10 @@ describe("background tasks rail events", () => { }); describe("background tasks rail rendering", () => { - it("renders running and finished sections with stop and transcript actions", () => { + it("keeps subagents in the rail and preserves linked sessions for other runtimes", () => { const onCancel = vi.fn(); const onOpenSession = vi.fn(); + const onSelectTask = vi.fn(); const container = document.createElement("div"); document.body.append(container); render( @@ -385,7 +448,13 @@ describe("background tasks rail rendering", () => { taskId: "runtime-task-1", childSessionKey: "agent:main:subagent:abc", }), - makeTask({ id: "task-2", status: "completed", title: "Finished work" }), + makeTask({ + id: "task-2", + status: "completed", + runtime: "cli", + title: "Finished work", + sessionKey: "agent:main:cli:finished", + }), ], cancellingTaskIds: new Set(), finishedCollapsed: false, @@ -397,7 +466,8 @@ describe("background tasks rail rendering", () => { onToggleFinished: () => {}, onRefresh: () => {}, onCancel, - onToggleTask: () => {}, + onSelectTask, + onBackToList: () => {}, onOpenSession, })}`, container, @@ -410,13 +480,19 @@ describe("background tasks rail rendering", () => { expect(stop).not.toBeNull(); stop?.click(); expect(onCancel).toHaveBeenCalledWith("task-1"); + expect(onSelectTask).not.toHaveBeenCalled(); - const transcript = container.querySelector( + const subagent = container.querySelector('[data-task-id="task-1"]'); + expect(subagent?.querySelector(".chat-tasks-rail__task-transcript")).toBeNull(); + + const cliTask = container.querySelector('[data-task-id="task-2"]'); + const transcript = cliTask?.querySelector( ".chat-tasks-rail__task-transcript", ); expect(transcript).not.toBeNull(); transcript?.click(); - expect(onOpenSession).toHaveBeenCalledWith("agent:main:subagent:abc"); + expect(onOpenSession).toHaveBeenCalledWith("agent:main:cli:finished"); + expect(onSelectTask).not.toHaveBeenCalled(); }); it("shows live tool activity for running tasks and duration for finished tasks", () => { @@ -453,7 +529,8 @@ describe("background tasks rail rendering", () => { onToggleFinished: () => {}, onRefresh: () => {}, onCancel: () => {}, - onToggleTask: () => {}, + onSelectTask: () => {}, + onBackToList: () => {}, onOpenSession: () => {}, })}`, container, @@ -470,8 +547,8 @@ describe("background tasks rail rendering", () => { expect(finished?.querySelector("openclaw-elapsed-time")).toBeNull(); }); - it("opens a task inspector with prompt and output", () => { - const onToggleTask = vi.fn(); + it("opens a compact task detail view with prompt, output, and back navigation", () => { + const onBackToList = vi.fn(); const task = makeTask({ id: "task-1", status: "completed", @@ -505,24 +582,23 @@ describe("background tasks rail rendering", () => { onToggleFinished: () => {}, onRefresh: () => {}, onCancel: () => {}, - onToggleTask, + onSelectTask: () => {}, + onBackToList, onOpenSession: () => {}, })}`, container, ); - const inspector = container.querySelector('[data-task-inspector="task-1"]'); - expect(inspector?.textContent).toContain("Review running tasks"); - expect(inspector?.textContent).toContain("Audit complete"); - expect(inspector?.textContent).not.toContain("Stale running progress"); - inspector?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(onToggleTask).not.toHaveBeenCalled(); - const disclosure = container.querySelector( - ".chat-tasks-rail__task-disclosure", - ); - expect(disclosure?.getAttribute("aria-expanded")).toBe("true"); - disclosure?.click(); - expect(onToggleTask).toHaveBeenCalledWith(task); + const detail = container.querySelector('[data-task-detail="task-1"]'); + expect(detail?.textContent).toContain("Review running tasks"); + expect(detail?.textContent).toContain("Audit complete"); + expect(detail?.textContent).not.toContain("Stale running progress"); + expect(container.querySelector(".chat-tasks-rail__task")).toBeNull(); + + const back = container.querySelector(".chat-tasks-rail__back"); + expect(back?.getAttribute("aria-label")).toBe("Back to background tasks"); + back?.click(); + expect(onBackToList).toHaveBeenCalledTimes(1); }); it("uses a newer lookup snapshot for output", () => { @@ -562,15 +638,16 @@ describe("background tasks rail rendering", () => { onToggleFinished: () => {}, onRefresh: () => {}, onCancel: () => {}, - onToggleTask: () => {}, + onSelectTask: () => {}, + onBackToList: () => {}, onOpenSession: () => {}, })}`, container, ); - const inspector = container.querySelector('[data-task-inspector="task-1"]'); - expect(inspector?.textContent).toContain("Finished in lookup"); - expect(inspector?.textContent).not.toContain("Still running"); + const detail = container.querySelector('[data-task-detail="task-1"]'); + expect(detail?.textContent).toContain("Finished in lookup"); + expect(detail?.textContent).not.toContain("Still running"); }); it("collapses the finished section", () => { @@ -597,7 +674,8 @@ describe("background tasks rail rendering", () => { onToggleFinished: () => {}, onRefresh: () => {}, onCancel: () => {}, - onToggleTask: () => {}, + onSelectTask: () => {}, + onBackToList: () => {}, onOpenSession: () => {}, })}`, container, @@ -632,7 +710,8 @@ describe("running-tasks status row", () => { onToggleFinished: () => {}, onRefresh: () => {}, onCancel: () => {}, - onToggleTask: () => {}, + onSelectTask: () => {}, + onBackToList: () => {}, onOpenSession: () => {}, ...overrides, }; diff --git a/ui/src/pages/chat/components/chat-background-tasks.ts b/ui/src/pages/chat/components/chat-background-tasks.ts index 9399d67120bc..3fb401cfcbb0 100644 --- a/ui/src/pages/chat/components/chat-background-tasks.ts +++ b/ui/src/pages/chat/components/chat-background-tasks.ts @@ -16,9 +16,10 @@ import { normalizeTasksListResult, partitionTasks, sortTasks, + taskTitle, type TaskSummary, } from "../../../lib/tasks/data.ts"; -import { renderTaskRow } from "./chat-background-task-row.ts"; +import { renderTaskDetail, renderTaskRow } from "./chat-background-task-row.ts"; import { newestTaskSnapshot } from "./chat-background-tasks-shared.ts"; import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; import { paneSessionAgentId } from "./chat-session-workspace.ts"; @@ -293,15 +294,52 @@ async function loadBackgroundTaskDetail( } } -function toggleBackgroundTaskDetail( +function focusBackgroundTaskControl( + state: BackgroundTasksState, + target: "back" | { taskId: string }, +) { + window.requestAnimationFrame(() => { + const rail = document.getElementById(`${state.statusRowId}-rail`); + if (target === "back") { + rail?.querySelector(".chat-tasks-rail__back")?.focus(); + return; + } + const row = [...(rail?.querySelectorAll("[data-task-id]") ?? [])].find( + (candidate) => candidate.dataset.taskId === target.taskId, + ); + row?.querySelector(".chat-tasks-rail__task-disclosure")?.focus(); + }); +} + +function selectBackgroundTaskDetail( host: BackgroundTasksHost, state: BackgroundTasksState, task: TaskSummary, ) { - state.selectedTaskId = state.selectedTaskId === task.id ? null : task.id; + state.selectedTaskId = task.id; host.requestUpdate?.(); - if (state.selectedTaskId === task.id) { - void loadBackgroundTaskDetail(host, state, task); + focusBackgroundTaskControl(state, "back"); + void loadBackgroundTaskDetail(host, state, task); +} + +function showBackgroundTaskList(host: BackgroundTasksHost, state: BackgroundTasksState) { + const taskId = state.selectedTaskId; + const listedTask = state.tasks?.find((task) => task.id === taskId); + const detailedTask = taskId ? state.taskDetails.get(taskId) : undefined; + const selectedTask = listedTask ? newestTaskSnapshot(listedTask, detailedTask) : detailedTask; + if (selectedTask && state.tasks) { + state.tasks = sortTasks([ + selectedTask, + ...state.tasks.filter((task) => task.id !== selectedTask.id), + ]); + if (!isActiveTask(selectedTask)) { + state.finishedCollapsed = false; + } + } + state.selectedTaskId = null; + host.requestUpdate?.(); + if (taskId) { + focusBackgroundTaskControl(state, { taskId }); } } @@ -402,7 +440,8 @@ export function createBackgroundTasksProps( }, onRefresh: () => loadBackgroundTasks(host, state, true), onCancel: (taskId) => void cancelBackgroundTask(host, state, taskId), - onToggleTask: (task) => toggleBackgroundTaskDetail(host, state, task), + onSelectTask: (task) => selectBackgroundTaskDetail(host, state, task), + onBackToList: () => showBackgroundTaskList(host, state), onOpenSession: opts.onOpenSession, }; } @@ -463,44 +502,72 @@ export function renderBackgroundTasksRail( if (!backgroundTasks || backgroundTasks.collapsed) { return nothing; } + const selectedTask = backgroundTasks.tasks?.find( + (task) => task.id === backgroundTasks.selectedTaskId, + ); const { active, recent } = partitionTasks(backgroundTasks.tasks ?? []); const loaded = backgroundTasks.tasks !== null; const empty = loaded && active.length === 0 && recent.length === 0; + const collapseButton = html` + + + + `; return html` -