From e11f923e2efc2f131393c1a18286a80781a8438a Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 9 Nov 2026 19:34:33 -0600 Subject: [PATCH 1/2] fix(web): show details for background commands - Preserve command and source item details for Claude background tasks - Show full commands and captured output in the background work dialog --- .../Adapters/ClaudeAdapterV2.test.ts | 57 +++++++++++ .../Adapters/ClaudeAdapterV2.ts | 32 +++++- .../BackgroundWorkDetails.browser.test.tsx | 71 ++++++++++++++ .../components/chat/BackgroundWorkDetails.tsx | 98 +++++++++++++++++++ .../components/chat/ThreadActivityBanners.tsx | 9 ++ .../components/chat/useThreadLifecycle.tsx | 1 + packages/contracts/src/orchestrationV2.ts | 3 + 7 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/chat/BackgroundWorkDetails.browser.test.tsx create mode 100644 apps/web/src/components/chat/BackgroundWorkDetails.tsx diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index fd1fd57481422..bca1d6769cbea 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -10049,6 +10049,32 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_background_command", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: WAKE_TOOL_USE_ID, + name: "Bash", + input: { + command: "python3 audit.py > audit.log 2>&1", + run_in_background: true, + }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "background-command-launch", + session_id: WAKE_NATIVE_SESSION, + }), + ); yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); yield* Queue.offer(harness.sdkMessages, turnOneResult); yield* harness.awaitTerminal(1); @@ -10062,6 +10088,37 @@ describe("ClaudeAdapterV2 background wake turns", () => { WAKE_TASK_ID, ); + const command = harness.events.find( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.nativeItemRef?.nativeId === WAKE_TOOL_USE_ID, + ); + assert.equal(command?.type, "turn_item.updated"); + if (command?.type !== "turn_item.updated") return; + const details = { + command: "python3 audit.py > audit.log 2>&1", + itemId: command.turnItem.id, + }; + assert.deepInclude( + afterStart.at(-1)?.providerThread.pendingBackgroundTasks?.[0], + details, + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: WAKE_TASK_ID, task_type: "local_bash", description: "Audit" }], + uuid: "background-command-snapshot", + session_id: WAKE_NATIVE_SESSION, + }), + ); + assert.deepInclude( + providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks?.[0], + details, + ); + yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index deacdfd617373..5c71e79d98278 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -5777,7 +5777,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } else { updated.set( nativeThreadId, - new Map(tasks.map((task) => [task.taskId, task] as const)), + new Map( + tasks.map((task) => { + const previous = current.get(nativeThreadId)?.get(task.taskId); + return [task.taskId, { ...previous, ...task }] as const; + }), + ), ); } return updated; @@ -8719,9 +8724,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (!isClaudeNonSubagentTask(message) || message.is_backgrounded === false) { return false; } - yield* upsertPendingBackgroundTask( - input.nativeThreadId, - claudePendingBackgroundTask({ + const launch = + message.tool_use_id === undefined || input.activeContext === null + ? undefined + : findToolCall(input.activeContext, message.tool_use_id); + const command = + launch?.classification.itemType === "command_execution" + ? firstStringInputField(launch.input, ["command"]) + : undefined; + yield* upsertPendingBackgroundTask(input.nativeThreadId, { + ...(launch === undefined || command === undefined + ? {} + : { + itemId: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: launch.nativeItemId, + }), + sourceThreadId: launch.threadId, + }), + ...(command === undefined ? {} : { command }), + ...claudePendingBackgroundTask({ taskId: message.task_id, taskType: claudeTaskTypeFromSdkMessage(message), startedByMonitor: yield* isClaudeMonitorTask({ @@ -8733,7 +8755,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( typeof message.description === "string" ? message.description : undefined, parentTaskId: Reflect.get(message, "parent_task_id"), }), - ); + }); rosterChanged = true; } else if (message.type === "system" && message.subtype === "task_notification") { yield* endClaudeMonitorTasks((taskId) => taskId === message.task_id); diff --git a/apps/web/src/components/chat/BackgroundWorkDetails.browser.test.tsx b/apps/web/src/components/chat/BackgroundWorkDetails.browser.test.tsx new file mode 100644 index 0000000000000..23e8bcbbdf7b2 --- /dev/null +++ b/apps/web/src/components/chat/BackgroundWorkDetails.browser.test.tsx @@ -0,0 +1,71 @@ +import "~/index.css"; +import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +vi.mock(import("./CommandOutputPanel"), () => ({ + useCommandOutputStreaming: () => true, + CommandOutputPanel: () =>
Command output stream
, +})); + +import { render } from "../../../test/render"; +import { backgroundWorkNowItem } from "./ThreadActivityBanners"; +import { ThreadNow } from "./ThreadNow"; + +it("opens full command details for short labels and keeps pending tasks current", async () => { + const command = "python3 audit.py -j 6\n> scratch/audit.log 2>&1"; + const view = (description?: string, status: "running" | "completed" = "completed") => ( + undefined, + onOpenThread: () => undefined, + })} + /> + ); + await using screen = await render(view("Audit")); + await screen.getByRole("button", { name: "Details", exact: true }).click(); + await vi.waitFor(() => expect(page.getByRole("dialog").element().textContent).toContain(command)); + await expect.element(page.getByText("Command output stream")).not.toBeInTheDocument(); + + await screen.rerender(view(command)); + await expect.element(page.getByRole("heading", { name: "Command", exact: true })).toBeVisible(); + await screen.rerender(view()); + await expect.element(page.getByRole("heading", { name: "Command", exact: true })).toBeVisible(); + + await screen.rerender(view("Check remaining modules", "running")); + await expect + .element(page.getByRole("heading", { name: "Check remaining modules" })) + .toBeVisible(); + await expect.element(page.getByText("Command output stream")).toBeVisible(); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/BackgroundWorkDetails.tsx b/apps/web/src/components/chat/BackgroundWorkDetails.tsx new file mode 100644 index 0000000000000..bacb066192255 --- /dev/null +++ b/apps/web/src/components/chat/BackgroundWorkDetails.tsx @@ -0,0 +1,98 @@ +import type { + EnvironmentId, + OrchestrationV2PendingBackgroundTask, + ThreadId, +} from "@t3tools/contracts"; +import type { ThreadStructure } from "@t3tools/client-runtime/state/shell"; +import { useState } from "react"; + +import { InlineButton } from "../ui/button"; +import { + Dialog, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { CommandOutputPanel, useCommandOutputStreaming } from "./CommandOutputPanel"; +import { ShellCommandBlock } from "./ShellCommandBlock"; + +export function BackgroundWorkDetails(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly tasks: ReadonlyArray; + readonly turnItems: ThreadStructure["turnItems"]; + readonly onOpenThread: (threadId: ThreadId) => void; +}) { + const [open, setOpen] = useState(false); + const streamsOutput = useCommandOutputStreaming(props.environmentId); + return ( + + }>Details + + + Background work + + +
+ {props.tasks.map((task) => { + const item = props.turnItems.findLast( + (candidate) => + candidate.id === task.itemId || + (candidate.nativeItemRef?.nativeId || candidate.id) === task.taskId, + ); + const command = + task.command ?? (item?.type === "command_execution" ? item.input : undefined); + const description = task.description?.trim(); + const title = + description && description !== command?.trim() + ? description + : command + ? "Command" + : task.kind.replaceAll("_", " "); + const itemId = + task.itemId ?? (item?.type === "command_execution" ? item.id : undefined); + const childThreadId = task.kind === "subagent" ? task.childThreadId : undefined; + return ( +
+
+

{title}

+ + {task.awaited ? "Waiting" : "Running"} + +
+ {command ? ( +
+ +
+ ) : null} + {itemId && streamsOutput && item?.status === "running" ? ( +
+

Captured output

+ +
+ ) : null} + {childThreadId ? ( + { + setOpen(false); + props.onOpenThread(childThreadId); + }} + > + Open thread + + ) : null} +
+ ); + })} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 434094f954a27..b12a6d1ae63b4 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -27,6 +27,7 @@ import { } from "lucide-react"; import { Fragment } from "react"; import { tickTextContent } from "~/lib/tickTextContent"; +import { BackgroundWorkDetails } from "./BackgroundWorkDetails"; import { Button, InlineButton } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; @@ -168,6 +169,7 @@ export function runStopNowItem(input: { /** Background commands and subagents, for the end of the timeline. */ export function backgroundWorkNowItem(input: { + readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly tasks: ReadonlyArray; readonly turnItems: ThreadStructure["turnItems"]; @@ -246,6 +248,13 @@ export function backgroundWorkNowItem(input: { className="tabular-nums" /> )} + {onlyThreadId === undefined ? null : ( onOpenThread(onlyThreadId)}> Open diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index d2807671f0472..74654e1979a97 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -596,6 +596,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { : null; const backgroundWorkNow = activeThread ? backgroundWorkNowItem({ + environmentId, threadId: activeThread.id, tasks: activeBackgroundTasks, turnItems, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index ca72b708ad6e4..856cf89bafcb6 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1023,6 +1023,9 @@ function kindUnionWithFallback< } const PendingBackgroundTaskFields = { + command: Schema.optional(Schema.String), + itemId: Schema.optional(TurnItemId), + sourceThreadId: Schema.optional(ThreadId), taskId: TrimmedNonEmptyString, description: Schema.optional(TrimmedNonEmptyString), /** The subagent task that started this work and waits on it. */ From a0326ab6242c1c429d1611ecbedd3d08e8293349 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 9 Nov 2026 20:07:55 -0600 Subject: [PATCH 2/2] fix(web): keep background details bound to the command item --- .../BackgroundWorkDetails.browser.test.tsx | 66 +++++++++++++------ .../components/chat/BackgroundWorkDetails.tsx | 11 ++-- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/chat/BackgroundWorkDetails.browser.test.tsx b/apps/web/src/components/chat/BackgroundWorkDetails.browser.test.tsx index 23e8bcbbdf7b2..01311a60ecf4f 100644 --- a/apps/web/src/components/chat/BackgroundWorkDetails.browser.test.tsx +++ b/apps/web/src/components/chat/BackgroundWorkDetails.browser.test.tsx @@ -1,5 +1,5 @@ import "~/index.css"; -import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; +import { EnvironmentId, ProviderDriverKind, ThreadId, TurnItemId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; @@ -14,7 +14,30 @@ import { ThreadNow } from "./ThreadNow"; it("opens full command details for short labels and keeps pending tasks current", async () => { const command = "python3 audit.py -j 6\n> scratch/audit.log 2>&1"; - const view = (description?: string, status: "running" | "completed" = "completed") => ( + const commandItem = (status: "running" | "completed") => ({ + id: TurnItemId.make("bash-1"), + threadId: ThreadId.make("thread"), + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status, + title: null, + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-11-09T00:00:00Z"), + type: "command_execution" as const, + input: command, + output: "Command running in background with ID: native-task", + }); + const view = ( + description?: string, + status: "running" | "completed" = "completed", + explicitItem = true, + ) => (
{props.tasks.map((task) => { - const item = props.turnItems.findLast( - (candidate) => - candidate.id === task.itemId || - (candidate.nativeItemRef?.nativeId || candidate.id) === task.taskId, + const item = props.turnItems.findLast((candidate) => + task.itemId === undefined + ? (candidate.nativeItemRef?.nativeId || candidate.id) === task.taskId + : candidate.id === task.itemId, ); const command = task.command ?? (item?.type === "command_execution" ? item.input : undefined); @@ -68,8 +68,7 @@ export function BackgroundWorkDetails(props: {
) : null} {itemId && streamsOutput && item?.status === "running" ? ( -
-

Captured output

+