diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 63abed982e9d..4bd102700b59 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -503,7 +503,7 @@ Capability toggles stay disabled until the Gateway, session, and runtime config - 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. - File paths recognized in chat messages read as their basename with a small glyph for the file type in front — a Markdown page, a `package.json` manifest, a TypeScript source, a `.tsx` component, a config or data file, a shell script, and an image each get their own mark, and anything else falls back to a plain document. When two links in the same message share a basename, each keeps just enough of its trailing path to stay distinct. The full path stays on the link: it is what the tooltip shows, what opens in the file panel, and what the message's **Copy** action returns, since copy hands back the original Markdown. Labels you write yourself in a `[label](path)` link are never rewritten. The glyph is drawn from the bundled icon set, never fetched from the network, and is decorative only: it is not read by screen readers and is not part of copied text. Text that is not a recognizable path — anything carrying spaces, parentheses, a `#` fragment, or a `?` query — stays plain prose. - 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. UTF-8 text files use 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. AVIF, GIF, JPEG, PNG, and WebP images no larger than 256 KiB render inline; other binary files show metadata without lossy text decoding. When the Gateway advertises `sessions.files.set` to an `operator.admin` connection, the text 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 KiB 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, 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 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 rail row replaces the list with a compact detail view in the same rail; its back button returns to the list. Clicking an inline subagent activity row instead opens that subagent's live status and child transcript in the detail sidebar, without replacing the main conversation. 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 e1ded41aed50..a24c97b0dd09 100644 --- a/scripts/control-ui-mock-background-tasks.ts +++ b/scripts/control-ui-mock-background-tasks.ts @@ -35,6 +35,8 @@ function taskDetailCase(task: { id: string; title: string } & Record { timestamp: Date.now(), }, ], - methodResponses: { "tasks.list": { tasks: [] } }, + methodResponses: { + "chat.history": { + cases: [ + { + match: { sessionKey: "agent:main:subagent:parallel-one" }, + response: { + messages: [ + { + content: [ + { type: "text", text: "Inspecting session ownership boundaries." }, + ], + role: "assistant", + timestamp: Date.now(), + }, + ], + sessionId: "parallel-one-child", + thinkingLevel: null, + }, + }, + ], + }, + "tasks.list": { tasks: [] }, + }, }); const response = await page.goto(`${suite.server.baseUrl}chat`); @@ -384,6 +406,27 @@ suite.define(() => { fullPage: true, }); + await firstRow.click(); + const detailPanel = page.locator("[data-subagent-detail-panel]"); + await detailPanel.waitFor({ state: "visible" }); + await detailPanel.getByText("Inspecting session ownership boundaries.").waitFor(); + expect(await detailPanel.textContent()).toContain("Review session ownership"); + expect(await detailPanel.textContent()).toContain("Running"); + await expect + .poll(async () => + (await gateway.getRequests("chat.history")).some( + (request) => requestSessionKey(request) === first.childSessionKey, + ), + ) + .toBe(true); + const childHistoryRequest = (await gateway.getRequests("chat.history")).find( + (request) => requestSessionKey(request) === first.childSessionKey, + ); + expect(childHistoryRequest?.params).toEqual({ + sessionKey: first.childSessionKey, + limit: 100, + }); + await gateway.emitGatewayEvent("task", { action: "upserted", task: { @@ -416,6 +459,7 @@ suite.define(() => { }); await firstRow.getByText("Subagent finished").waitFor(); + await detailPanel.getByText("Completed").waitFor(); expect(await firstRow.textContent()).toContain("Ownership review complete"); expect(await firstRow.locator(".chat-diffstat__add").textContent()).toBe("+14"); expect(await firstRow.locator(".chat-diffstat__del").textContent()).toBe("-3"); @@ -425,6 +469,8 @@ suite.define(() => { path: path.join(activityDir, "02-one-subagent-finished.png"), fullPage: true, }); + await page.getByRole("button", { name: "Close Details" }).click(); + await detailPanel.waitFor({ state: "detached" }); }, ); }); diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 45e52d70fae1..687d88fdb397 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -143,6 +143,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { ); protected readonly transcript = new ChatTranscriptController(this); protected readonly backgroundTaskTranscript = new ChatTranscriptController(this); + protected readonly subagentSidebarTranscript = new ChatTranscriptController(this); protected readonly questionPromptState = createQuestionPromptState(() => { this.questionPrompts = listQuestionPrompts(this.questionPromptState); this.requestUpdate(); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 879e7daae90b..85b34698e6e6 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -54,6 +54,7 @@ import { } from "./chat-state-route.ts"; import { renderChat, type ChatProps } from "./chat-view.ts"; import { createBackgroundTasksProps } from "./components/chat-background-tasks.ts"; +import { renderChatDetailSlot } from "./components/chat-detail-slot.ts"; import { renderChatImageLightbox } from "./components/chat-image-lightbox.ts"; import { chatPullRequestId, createPullRequestBranch } from "./components/chat-pull-requests.ts"; import { @@ -228,6 +229,8 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH + (railSideDocked ? WORKSPACE_RAIL_MAX_WIDTH : 0), + onOpenSubagentDetail: (task) => + state.handleOpenSidebar({ kind: "subagent", taskId: task.id }), }); const tasksSideDocked = !backgroundTasks.collapsed && !backgroundTasks.narrowLayout; // Only side-docked rails narrow the conversation region. @@ -603,21 +606,15 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { chat, ...(state.sidebarContent ? { - detail: html` - openSessionWorkspaceFile(state, target)} - .onRevealInWorkspace=${(path: string) => revealSessionWorkspaceFile(state, path)} - .onOpenImage=${(item: Parameters[0]) => - state.handleOpenImage(item, state.beginImageOpen())} - .embedded=${true} - @chat-detail-panel-close=${() => state.handleCloseSidebar()} - >`, + detail: renderChatDetailSlot({ + backgroundTasks, + chat: props, + content: state.sidebarContent, + fullMessageLoader, + host: state, + layout: sidebarLayout, + transcript: this.subagentSidebarTranscript, + }), } : {}), ...(discussion diff --git a/ui/src/pages/chat/chat-pane-retained-presentation.test.ts b/ui/src/pages/chat/chat-pane-retained-presentation.test.ts index 4f710b22bf65..8971771886c2 100644 --- a/ui/src/pages/chat/chat-pane-retained-presentation.test.ts +++ b/ui/src/pages/chat/chat-pane-retained-presentation.test.ts @@ -20,6 +20,10 @@ import { } from "./chat-pane-shared.ts"; import { createTestChatPane, type TestChatPane } from "./chat-pane.test-support.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; +import { + readSubagentTranscript, + type SubagentDetailHost, +} from "./components/chat-subagent-detail-state.ts"; describe("chat pane retained presentation lifecycle", () => { it("expires abandoned eviction payload ownership", () => { @@ -192,8 +196,14 @@ describe("chat pane retained presentation lifecycle", () => { const release = vi.fn(); state.realtimeTalkSession = { stop } as unknown as ChatPageHost["realtimeTalkSession"]; state.realtimeTalkActive = true; - state.sidebarContent = { kind: "markdown", content: "transient details" }; + state.sidebarContent = { kind: "subagent", taskId: "task-live" }; state.imageLightbox = { release, src: "blob:test", title: "preview" }; + const detailHost = state as unknown as SubagentDetailHost; + readSubagentTranscript(detailHost, { + taskId: "task-live", + sessionKey: "agent:main:subagent:task-live", + }); + expect(detailHost.subagentDetailState).toBeDefined(); pane.presentationId = "p1:visible"; const announcement = document.createElement("span"); announcement.className = "chat-transcript-announcement"; @@ -204,6 +214,9 @@ describe("chat pane retained presentation lifecycle", () => { expect(stop).toHaveBeenCalledOnce(); expect(release).toHaveBeenCalledOnce(); expect(state.sidebarContent).toBeNull(); + // The wiped detail slot can no longer reset the loader itself; retirement + // must stop its timer/fetch loop so hidden panes stop reading history. + expect(detailHost.subagentDetailState).toBeUndefined(); expect(announcement.getAttribute("aria-live")).toBe("off"); }); diff --git a/ui/src/pages/chat/chat-pane-retained-presentation.ts b/ui/src/pages/chat/chat-pane-retained-presentation.ts index 4be8b39a658a..854673d351bb 100644 --- a/ui/src/pages/chat/chat-pane-retained-presentation.ts +++ b/ui/src/pages/chat/chat-pane-retained-presentation.ts @@ -12,6 +12,7 @@ import { setChatError } from "./chat-send-queue-state.ts"; import { refreshCurrentChatSessionList } from "./chat-session.ts"; import { invalidateImageLightbox } from "./chat-state-page.ts"; import { dismissConfirmedActionPopovers } from "./components/chat-message.ts"; +import { resetSubagentDetail } from "./components/chat-subagent-detail-state.ts"; import { resetTranscriptSession } from "./components/chat-thread-interactions.ts"; import { CHAT_COMPOSER_DRAFT_STORAGE_ERROR } from "./composer-persistence.ts"; @@ -63,6 +64,9 @@ export abstract class ChatPaneRetainedPresentation extends ChatPaneBoard { if (state) { stopChatRealtimeTalk(state); invalidateImageLightbox(state); + // The detail slot's render guard cannot run once the content is wiped, + // so the transcript loader's timer/fetch loop must be stopped here. + resetSubagentDetail(state); state.sidebarContent = null; state.requestUpdate?.(); } diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 534e8f89da43..7776a3b3f043 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -47,6 +47,7 @@ import { isChatRunWorking, renderChatComposer } from "./components/chat-composer import { inlineChatImageFromEvent, openInlineChatImage } from "./components/chat-image-lightbox.ts"; import type { ArtifactDownloadResolver } from "./components/chat-message-media.ts"; import { renderChatPullRequests } from "./components/chat-pull-requests.ts"; +import { renderReadOnlyTranscript } from "./components/chat-read-only-transcript.ts"; import type { SessionRailCommand, SessionRailMode } from "./components/chat-session-rail.ts"; import { renderChatSessionSuggestions } from "./components/chat-session-suggestions.ts"; import { @@ -402,46 +403,13 @@ export function renderChat(props: ChatProps) { backgroundTaskView?.kind === "transcript" && backgroundTaskView.load.status === "loaded" && backgroundTaskView.load.messages.length > 0 - ? renderChatThread( - { - paneId: `${props.paneId}:background-task-transcript`, - sessionKey: backgroundTaskView.sessionKey, - announceTranscript: false, - loading: false, - messages: backgroundTaskView.load.messages, - toolMessages: [], - streamSegments: [], - stream: null, - streamStartedAt: null, - runId: null, - queue: [], - showThinking: props.showThinking, - showToolCalls: props.showToolCalls, - persistCommentary: props.persistCommentary, - sessions: props.sessions, - sessionHost: props.sessionHost, - assistantName: props.assistantName, - assistantAvatar: props.assistantAvatar, - assistantAvatarUrl: props.assistantAvatarUrl, - userId: props.userId, - userName: props.userName, - userAvatar: props.userAvatar, - basePath: props.basePath, - fullMessageAgentId: props.fullMessageAgentId, - loadFullAssistantMessage: props.loadFullAssistantMessage, - localMediaPreviewRoots: props.localMediaPreviewRoots, - assistantAttachmentAuthToken: props.assistantAttachmentAuthToken, - resolveArtifactDownload: props.resolveArtifactDownload, - canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl, - embedSandboxMode: props.embedSandboxMode, - allowExternalEmbedUrls: props.allowExternalEmbedUrls, - autoExpandToolCalls: props.autoExpandToolCalls, - onRequestUpdate: requestUpdate, - onDraftChange: () => undefined, - onSend: () => undefined, - }, - backgroundTaskTranscript, - ) + ? renderReadOnlyTranscript({ + chat: props, + messages: backgroundTaskView.load.messages, + paneId: `${props.paneId}:background-task-transcript`, + sessionKey: backgroundTaskView.sessionKey, + transcript: backgroundTaskTranscript, + }) : nothing; const chatColumnFooter = renderChatComposer({ 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 43f1451e6bdc..e8677ef75edd 100644 --- a/ui/src/pages/chat/components/chat-background-task-row.ts +++ b/ui/src/pages/chat/components/chat-background-task-row.ts @@ -175,9 +175,6 @@ export function renderTaskDetail(task: TaskSummary, props: BackgroundTasksProps) 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`
@@ -212,30 +209,40 @@ export function renderTaskDetail(task: TaskSummary, props: BackgroundTasksProps) ${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")}
-
-
+ ${renderTaskInspector(newest, props)} + + `; +} + +export function renderTaskInspector( + task: TaskSummary, + props: BackgroundTasksProps, +): TemplateResult { + const detailedTask = props.taskDetails.get(task.id); + const newest = newestTaskSnapshot(task, detailedTask); + const output = taskDetail(newest); + const detailLoading = props.taskDetailLoadingIds.has(task.id); + const detailError = props.taskDetailErrors.get(task.id); + return html` + ${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-status.ts b/ui/src/pages/chat/components/chat-background-tasks-status.ts index 7837a3cf0a30..4f1b7f11af4c 100644 --- a/ui/src/pages/chat/components/chat-background-tasks-status.ts +++ b/ui/src/pages/chat/components/chat-background-tasks-status.ts @@ -100,7 +100,10 @@ export function renderBackgroundTasksStatusRow( if (!backgroundTasks?.connected) { return nothing; } - const subagentActivity = renderSubagentActivity(backgroundTasks.subagentActivity); + const subagentActivity = renderSubagentActivity( + backgroundTasks.subagentActivity, + backgroundTasks.onOpenSubagentDetail, + ); const remainingTasks = (backgroundTasks.tasks ?? []).filter( (task) => !backgroundTasks.subagentActivity.taskIds.has(task.id), ); diff --git a/ui/src/pages/chat/components/chat-background-tasks.ts b/ui/src/pages/chat/components/chat-background-tasks.ts index 60948aaf482a..50086a8853d4 100644 --- a/ui/src/pages/chat/components/chat-background-tasks.ts +++ b/ui/src/pages/chat/components/chat-background-tasks.ts @@ -27,6 +27,7 @@ import type { BackgroundTasksRailView, } from "./chat-background-tasks.types.ts"; import { deriveSubagentActivity } from "./chat-subagent-activity.ts"; +import { observeSubagentTaskEvent } from "./chat-subagent-detail-state.ts"; type BackgroundTaskLoadEvent = NonNullable>; @@ -381,6 +382,7 @@ export function handleBackgroundTasksEvent(host: BackgroundTasksHost, payload: u ) { return; } + observeSubagentTaskEvent(host, normalizedEvent); const event = normalizedEvent.action === "upserted" ? { @@ -680,7 +682,10 @@ function toggleBackgroundTasks(host: BackgroundTasksHost) { export function createBackgroundTasksProps( host: BackgroundTasksHost, - opts: { narrowLayout?: boolean } = {}, + opts: { + narrowLayout?: boolean; + onOpenSubagentDetail?: (task: TaskSummary) => void; + } = {}, ): BackgroundTasksProps { const state = getBackgroundTasksState(host); if (!host.connected) { @@ -733,6 +738,8 @@ export function createBackgroundTasksProps( }, onRefresh: () => loadBackgroundTasks(host, state, true), onCancel: (taskId) => void cancelBackgroundTask(host, state, taskId), + onLoadDetail: (task) => void loadBackgroundTaskDetail(host, state, task), + onOpenSubagentDetail: opts.onOpenSubagentDetail, onSelectTask: (task) => selectBackgroundTaskDetail(host, state, task), onBack: () => showPreviousBackgroundTaskView(host, state), onOpenTranscript: (task, returnTo) => openBackgroundTaskTranscript(host, state, task, returnTo), diff --git a/ui/src/pages/chat/components/chat-background-tasks.types.ts b/ui/src/pages/chat/components/chat-background-tasks.types.ts index 5852f5e848cd..8e53f329264e 100644 --- a/ui/src/pages/chat/components/chat-background-tasks.types.ts +++ b/ui/src/pages/chat/components/chat-background-tasks.types.ts @@ -36,6 +36,8 @@ export type BackgroundTasksProps = { onToggleFinished: () => void; onRefresh: () => void; onCancel: (taskId: string) => void; + onLoadDetail?: (task: TaskSummary) => void; + onOpenSubagentDetail?: (task: TaskSummary) => void; onSelectTask: (task: TaskSummary) => void; onBack: () => void; onOpenTranscript: (task: TaskSummary, returnTo: "list" | "detail") => void; diff --git a/ui/src/pages/chat/components/chat-detail-slot.ts b/ui/src/pages/chat/components/chat-detail-slot.ts new file mode 100644 index 000000000000..d8cf75d2fcd0 --- /dev/null +++ b/ui/src/pages/chat/components/chat-detail-slot.ts @@ -0,0 +1,55 @@ +import { html, type TemplateResult } from "lit"; +import type { ChatPageHost } from "../chat-state-host.ts"; +import type { ChatProps } from "../chat-view.ts"; +import type { SidebarLayout } from "../sidebar-layout.ts"; +import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; +import "./chat-sidebar.ts"; +import { openSessionWorkspaceFile, revealSessionWorkspaceFile } from "./chat-session-workspace.ts"; +import type { SidebarContent, SidebarFullMessageLoader } from "./chat-sidebar.ts"; +import { resetSubagentDetail } from "./chat-subagent-detail-state.ts"; +import { renderSubagentDetailPanel } from "./chat-subagent-detail.ts"; +import type { ChatTranscriptController } from "./chat-transcript-controller.ts"; + +export function renderChatDetailSlot(params: { + backgroundTasks: BackgroundTasksProps; + chat: ChatProps; + content: SidebarContent; + fullMessageLoader: SidebarFullMessageLoader | null; + host: ChatPageHost; + layout: SidebarLayout; + transcript: ChatTranscriptController; +}): TemplateResult { + const { content, host } = params; + if (content.kind === "subagent") { + const detailOpen = params.layout.columns.some((column) => + column.panels.some((panel) => panel.slot === "detail"), + ); + if (!detailOpen) { + resetSubagentDetail(host); + return html``; + } + return renderSubagentDetailPanel({ + backgroundTasks: params.backgroundTasks, + chat: params.chat, + host, + task: params.backgroundTasks.tasks?.find((task) => task.id === content.taskId) ?? undefined, + transcript: params.transcript, + }); + } + resetSubagentDetail(host); + return html` + openSessionWorkspaceFile(host, target)} + .onRevealInWorkspace=${(path: string) => revealSessionWorkspaceFile(host, path)} + .onOpenImage=${(item: Parameters[0]) => + host.handleOpenImage(item, host.beginImageOpen())} + .embedded=${true} + @chat-detail-panel-close=${() => host.handleCloseSidebar()} + >`; +} diff --git a/ui/src/pages/chat/components/chat-read-only-transcript.ts b/ui/src/pages/chat/components/chat-read-only-transcript.ts new file mode 100644 index 000000000000..f86670433007 --- /dev/null +++ b/ui/src/pages/chat/components/chat-read-only-transcript.ts @@ -0,0 +1,53 @@ +import type { ChatThreadProps } from "./chat-thread-interactions.ts"; +import { renderChatThread } from "./chat-thread.ts"; +import type { ChatTranscriptController } from "./chat-transcript-controller.ts"; + +export function renderReadOnlyTranscript(params: { + chat: ChatThreadProps; + messages: unknown[]; + paneId: string; + sessionKey: string; + transcript: ChatTranscriptController; +}) { + const { chat } = params; + return renderChatThread( + { + paneId: params.paneId, + sessionKey: params.sessionKey, + announceTranscript: false, + loading: false, + messages: params.messages, + toolMessages: [], + streamSegments: [], + stream: null, + streamStartedAt: null, + runId: null, + queue: [], + showThinking: chat.showThinking, + showToolCalls: chat.showToolCalls, + persistCommentary: chat.persistCommentary, + sessions: chat.sessions, + sessionHost: chat.sessionHost, + assistantName: chat.assistantName, + assistantAvatar: chat.assistantAvatar, + assistantAvatarUrl: chat.assistantAvatarUrl, + userId: chat.userId, + userName: chat.userName, + userAvatar: chat.userAvatar, + basePath: chat.basePath, + fullMessageAgentId: chat.fullMessageAgentId, + loadFullAssistantMessage: chat.loadFullAssistantMessage, + localMediaPreviewRoots: chat.localMediaPreviewRoots, + assistantAttachmentAuthToken: chat.assistantAttachmentAuthToken, + resolveArtifactDownload: chat.resolveArtifactDownload, + canvasPluginSurfaceUrl: chat.canvasPluginSurfaceUrl, + embedSandboxMode: chat.embedSandboxMode, + allowExternalEmbedUrls: chat.allowExternalEmbedUrls, + autoExpandToolCalls: chat.autoExpandToolCalls, + onRequestUpdate: chat.onRequestUpdate ?? (() => {}), + onDraftChange: () => undefined, + onSend: () => undefined, + }, + params.transcript, + ); +} diff --git a/ui/src/pages/chat/components/chat-sidebar.ts b/ui/src/pages/chat/components/chat-sidebar.ts index 75a6eec7b06f..1800a3dc33d9 100644 --- a/ui/src/pages/chat/components/chat-sidebar.ts +++ b/ui/src/pages/chat/components/chat-sidebar.ts @@ -137,15 +137,17 @@ function setRetainedFileDraft(content: FileSidebarContent, draft: RetainedFileDr retainedFileDrafts.set(key, draft); } -export type SidebarContent = +type ChatDetailContent = | MarkdownSidebarContent | CanvasSidebarContent | ImageSidebarContent | FileSidebarContent | SessionDiffSidebarContent; -function hasFullMessageRequest(content: SidebarContent): content is SidebarContent & { - fullMessageRequest: NonNullable; +export type SidebarContent = ChatDetailContent | { kind: "subagent"; taskId: string }; + +function hasFullMessageRequest(content: ChatDetailContent): content is ChatDetailContent & { + fullMessageRequest: SidebarFullMessageRequest; } { return Boolean( content.fullMessageRequest && (content.kind === "markdown" || content.kind === "canvas"), @@ -179,7 +181,9 @@ function toPlainTextCodeFence(value: string, language = ""): string { return `${fenceHeader}\n${value}\n\`\`\``; } -function buildRawSidebarContent(content: SidebarContent | null | undefined): SidebarContent | null { +function buildRawSidebarContent( + content: ChatDetailContent | null | undefined, +): ChatDetailContent | null { if (!content) { return null; } @@ -487,7 +491,7 @@ function renderFileSidebarContent( } function resolveSidebarCanvasSandbox( - content: SidebarContent, + content: ChatDetailContent, embedSandboxMode: EmbedSandboxMode, ): string { return content.kind === "canvas" @@ -508,7 +512,7 @@ function openSidebarImage( } type MarkdownSidebarProps = { - content: SidebarContent | null; + content: ChatDetailContent | null; error: string | null; fileView?: FileViewControls; onClose: () => void; @@ -696,7 +700,7 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) { } class ChatDetailPanel extends OpenClawLightDomElement { - @property({ attribute: false }) content: SidebarContent | null = null; + @property({ attribute: false }) content: ChatDetailContent | null = null; @property({ attribute: false }) loadFullMessage?: SidebarFullMessageLoader | null = null; @property() canvasPluginSurfaceUrl: string | null = null; @property() embedSandboxMode: EmbedSandboxMode = "scripts"; @@ -708,7 +712,7 @@ class ChatDetailPanel extends OpenClawLightDomElement { @property({ attribute: false }) onRevealInWorkspace?: ((path: string) => void) | null = null; @property({ attribute: false }) onOpenImage?: ((item: ImageLightboxItem) => void) | null = null; - @state() private visibleContent: SidebarContent | null = null; + @state() private visibleContent: ChatDetailContent | null = null; @state() private error: string | null = null; @state() private fileSearchOpen = false; @state() private fileSearchQuery = ""; @@ -1238,7 +1242,7 @@ class ChatDetailPanel extends OpenClawLightDomElement { }); }; - private async upgradeToFullMessage(content: SidebarContent, version: number) { + private async upgradeToFullMessage(content: ChatDetailContent, version: number) { if (!hasFullMessageRequest(content) || !this.loadFullMessage) { return; } diff --git a/ui/src/pages/chat/components/chat-subagent-activity.test.ts b/ui/src/pages/chat/components/chat-subagent-activity.test.ts index f6242b13992e..0b33873a5936 100644 --- a/ui/src/pages/chat/components/chat-subagent-activity.test.ts +++ b/ui/src/pages/chat/components/chat-subagent-activity.test.ts @@ -55,6 +55,7 @@ function makeProps(overrides: Partial): BackgroundTasksPro onToggleFinished: () => {}, onRefresh: () => {}, onCancel: () => {}, + onOpenSubagentDetail: undefined, onSelectTask: () => {}, onBack: () => {}, onOpenTranscript: () => {}, @@ -95,6 +96,47 @@ afterEach(() => { }); describe("subagent activity rows", () => { + it("opens the selected subagent from an accessible activity control", () => { + const task = makeTask({ id: "clickable-subagent" }); + const onOpenSubagentDetail = vi.fn(); + const container = renderStatusRow({ + tasks: [task], + subagentActivity: deriveSubagentActivity({ + tasks: [task], + sessionKey: "agent:main:current", + terminalObservedAtByTask: new Map(), + canonicalizeSessionKey: (sessionKey) => sessionKey ?? "", + }), + onOpenSubagentDetail, + }); + + const row = container.querySelector( + '[data-subagent-task-id="clickable-subagent"]', + ); + expect(row?.tagName).toBe("BUTTON"); + expect(row?.getAttribute("aria-label")).toBe("Open subagent details for Map codebase"); + row?.click(); + expect(onOpenSubagentDetail).toHaveBeenCalledWith(task); + }); + + it("keeps activity rows non-interactive when no open callback is provided", () => { + const task = makeTask({ id: "status-only-subagent" }); + const container = renderStatusRow({ + tasks: [task], + subagentActivity: deriveSubagentActivity({ + tasks: [task], + sessionKey: "agent:main:current", + terminalObservedAtByTask: new Map(), + canonicalizeSessionKey: (sessionKey) => sessionKey ?? "", + }), + }); + + const row = container.querySelector('[data-subagent-task-id="status-only-subagent"]'); + expect(row?.tagName).toBe("DIV"); + expect(row?.getAttribute("role")).toBe("status"); + expect(row?.hasAttribute("tabindex")).toBe(false); + }); + it("filters by requester, runtime, and retention while leaving other work in the aggregate", () => { const now = 100_000; const current = makeTask({ diff --git a/ui/src/pages/chat/components/chat-subagent-activity.ts b/ui/src/pages/chat/components/chat-subagent-activity.ts index 02461e6617fc..36006187740a 100644 --- a/ui/src/pages/chat/components/chat-subagent-activity.ts +++ b/ui/src/pages/chat/components/chat-subagent-activity.ts @@ -3,7 +3,7 @@ import { keyed } from "lit/directives/keyed.js"; import { repeat } from "lit/directives/repeat.js"; import { icons } from "../../../components/icons.ts"; import { t } from "../../../i18n/index.ts"; -import { isActiveTask, sortTasks, taskTimestampMs } from "../../../lib/tasks/data.ts"; +import { isActiveTask, sortTasks, taskTimestampMs, taskTitle } from "../../../lib/tasks/data.ts"; import type { TaskSummary } from "../../../lib/tasks/task-summary.ts"; import { renderDiffStatChips } from "./chat-diff-render.ts"; @@ -111,35 +111,53 @@ function renderSubagentActivityIndicator(task: TaskSummary): TemplateResult { >`; } -function renderSubagentActivityRow(task: TaskSummary): TemplateResult { +function renderSubagentActivityRow( + task: TaskSummary, + onOpenSubagentDetail?: (task: TaskSummary) => void, +): TemplateResult { const snippet = subagentActivitySnippet(task); const label = subagentActivityLabel(task); - return html` -
${label} + ${snippet + ? keyed( + `${task.status}:${snippet}`, + html`${snippet}`, + ) + : nothing} + ${task.diffStat ? renderDiffStatChips(task.diffStat) : nothing} + `; + if (!onOpenSubagentDetail) { + return html`
- ${renderSubagentActivityIndicator(task)} - ${label} - ${snippet - ? keyed( - `${task.status}:${snippet}`, - html`${snippet}`, - ) - : nothing} - ${task.diffStat ? renderDiffStatChips(task.diffStat) : nothing} -
- `; + ${content} +
`; + } + return html``; } export function renderSubagentActivity( presentation: SubagentActivityPresentation, + onOpenSubagentDetail?: (task: TaskSummary) => void, ): TemplateResult | typeof nothing { if (presentation.rows.length === 0) { return nothing; @@ -152,7 +170,7 @@ export function renderSubagentActivity( ${repeat( presentation.rows, (task) => task.id, - (task) => renderSubagentActivityRow(task), + (task) => renderSubagentActivityRow(task, onOpenSubagentDetail), )} ${presentation.overflowWorking > 0 ? html`
diff --git a/ui/src/pages/chat/components/chat-subagent-detail-state.test.ts b/ui/src/pages/chat/components/chat-subagent-detail-state.test.ts new file mode 100644 index 000000000000..2b4ccd6a604a --- /dev/null +++ b/ui/src/pages/chat/components/chat-subagent-detail-state.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../../api/gateway.ts"; +import type { TaskSummary } from "../../../lib/tasks/task-summary.ts"; +import { + observeSubagentTaskEvent, + readSubagentTranscript, + type SubagentDetailHost, +} from "./chat-subagent-detail-state.ts"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function history(text: string) { + return { + messages: [{ role: "assistant", content: [{ type: "text", text }] }], + sessionId: "child-session", + thinkingLevel: null, + }; +} + +function hostWith(request: ReturnType): SubagentDetailHost { + return { + client: { request } as unknown as GatewayBrowserClient, + connected: true, + connectionEpoch: 4, + requestUpdate: vi.fn(), + }; +} + +function task(status: TaskSummary["status"]): TaskSummary { + return { + id: "task-1", + taskId: "task-1", + status, + runtime: "subagent", + agentId: "main", + sessionKey: "agent:main:main", + childSessionKey: "agent:main:subagent:child", + createdAt: 1_000, + updatedAt: 2_000, + }; +} + +async function flushAsync() { + await Promise.resolve(); + await Promise.resolve(); +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("subagent detail transcript state", () => { + it("loads the selected child transcript", async () => { + const pending = deferred>(); + const request = vi.fn().mockReturnValue(pending.promise); + const host = hostWith(request); + + expect( + readSubagentTranscript(host, { + taskId: "task-1", + sessionKey: "agent:main:subagent:child", + }), + ).toEqual({ status: "loading" }); + expect(request).toHaveBeenCalledWith("chat.history", { + sessionKey: "agent:main:subagent:child", + limit: 100, + }); + + pending.resolve(history("Child transcript loaded.")); + await flushAsync(); + expect( + readSubagentTranscript(host, { + taskId: "task-1", + sessionKey: "agent:main:subagent:child", + }), + ).toMatchObject({ + status: "loaded", + messages: [{ role: "assistant" }], + }); + }); + + it("surfaces a history request failure", async () => { + const pending = deferred(); + const host = hostWith(vi.fn().mockReturnValue(pending.promise)); + readSubagentTranscript(host, { + taskId: "task-1", + sessionKey: "agent:main:subagent:child", + }); + + pending.reject(new Error("history unavailable")); + await flushAsync(); + expect( + readSubagentTranscript(host, { + taskId: "task-1", + sessionKey: "agent:main:subagent:child", + }), + ).toEqual({ status: "error" }); + }); + + it("coalesces in-flight events and performs the terminal refresh after the throttle", async () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + vi.setSystemTime(10_000); + const first = deferred>(); + const final = deferred>(); + const request = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(final.promise); + const host = hostWith(request); + readSubagentTranscript(host, { + taskId: "task-1", + sessionKey: "agent:main:subagent:child", + }); + + observeSubagentTaskEvent(host, { action: "upserted", task: task("running") }); + observeSubagentTaskEvent(host, { action: "upserted", task: task("completed") }); + expect(request).toHaveBeenCalledTimes(1); + + first.resolve(history("Still running.")); + await flushAsync(); + vi.advanceTimersByTime(1_999); + expect(request).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(1); + expect(request).toHaveBeenCalledTimes(2); + + final.resolve(history("Final child response.")); + await flushAsync(); + expect( + readSubagentTranscript(host, { + taskId: "task-1", + sessionKey: "agent:main:subagent:child", + }), + ).toMatchObject({ status: "loaded" }); + expect(request).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ui/src/pages/chat/components/chat-subagent-detail-state.ts b/ui/src/pages/chat/components/chat-subagent-detail-state.ts new file mode 100644 index 000000000000..f7000c61257a --- /dev/null +++ b/ui/src/pages/chat/components/chat-subagent-detail-state.ts @@ -0,0 +1,178 @@ +import type { GatewayBrowserClient } from "../../../api/gateway.ts"; +import type { TaskSummary } from "../../../lib/tasks/task-summary.ts"; +import { + CHAT_HISTORY_REQUEST_LIMIT, + type ChatHistoryResult, + visibleChatHistoryMessages, +} from "../chat-history.ts"; + +const SUBAGENT_TRANSCRIPT_REFRESH_MS = 2_000; + +type SubagentTranscriptLoad = + | { status: "loading" } + | { status: "loaded"; messages: unknown[] } + | { status: "error" }; + +type SubagentDetailState = { + client: GatewayBrowserClient; + connectionEpoch: number | undefined; + eventVersion: number; + inFlight: boolean; + lastRequestStartedAt: number; + load: SubagentTranscriptLoad; + refreshTimer: number | null; + requestId: number; + sessionKey: string; + taskId: string; +}; + +export type SubagentDetailHost = { + client: GatewayBrowserClient | null; + connected: boolean; + connectionEpoch?: number; + requestUpdate?: () => void; + subagentDetailState?: SubagentDetailState; +}; + +function clearRefreshTimer(state: SubagentDetailState) { + if (state.refreshTimer !== null) { + window.clearTimeout(state.refreshTimer); + state.refreshTimer = null; + } +} + +export function resetSubagentDetail(host: SubagentDetailHost) { + const current = host.subagentDetailState; + if (!current) { + return; + } + clearRefreshTimer(current); + host.subagentDetailState = undefined; +} + +function scheduleTranscriptLoad(host: SubagentDetailHost, state: SubagentDetailState) { + if (host.subagentDetailState !== state || state.inFlight) { + return; + } + const remaining = SUBAGENT_TRANSCRIPT_REFRESH_MS - (Date.now() - state.lastRequestStartedAt); + if (remaining > 0) { + if (state.refreshTimer === null) { + state.refreshTimer = window.setTimeout(() => { + state.refreshTimer = null; + scheduleTranscriptLoad(host, state); + }, remaining); + } + return; + } + clearRefreshTimer(state); + const client = host.client; + if ( + !client || + !host.connected || + client !== state.client || + host.connectionEpoch !== state.connectionEpoch + ) { + state.load = { status: "error" }; + host.requestUpdate?.(); + return; + } + const requestId = ++state.requestId; + const eventVersion = state.eventVersion; + state.inFlight = true; + state.lastRequestStartedAt = Date.now(); + if (state.load.status !== "loaded") { + state.load = { status: "loading" }; + } + host.requestUpdate?.(); + void (async () => { + let load: SubagentTranscriptLoad; + try { + const result = await client.request("chat.history", { + sessionKey: state.sessionKey, + limit: CHAT_HISTORY_REQUEST_LIMIT, + }); + load = { status: "loaded", messages: visibleChatHistoryMessages(result.messages) }; + } catch { + load = { status: "error" }; + } + const current = host.subagentDetailState; + if ( + current !== state || + current.requestId !== requestId || + host.client !== client || + host.connectionEpoch !== state.connectionEpoch + ) { + return; + } + state.inFlight = false; + state.load = load; + host.requestUpdate?.(); + // Events that arrived during this request own a later snapshot. This also + // guarantees one final history read after a terminal transition. + if (state.eventVersion > eventVersion) { + scheduleTranscriptLoad(host, state); + } + })(); +} + +export function readSubagentTranscript( + host: SubagentDetailHost, + selection: { taskId: string; sessionKey: string }, +): SubagentTranscriptLoad { + const client = host.client; + const current = host.subagentDetailState; + if ( + current && + current.taskId === selection.taskId && + current.sessionKey === selection.sessionKey && + current.client === client && + current.connectionEpoch === host.connectionEpoch + ) { + return current.load; + } + resetSubagentDetail(host); + if (!client || !host.connected) { + return { status: "error" }; + } + const next: SubagentDetailState = { + client, + connectionEpoch: host.connectionEpoch, + eventVersion: 0, + inFlight: false, + lastRequestStartedAt: Number.NEGATIVE_INFINITY, + load: { status: "loading" }, + refreshTimer: null, + requestId: 0, + sessionKey: selection.sessionKey, + taskId: selection.taskId, + }; + host.subagentDetailState = next; + scheduleTranscriptLoad(host, next); + return next.load; +} + +export function observeSubagentTaskEvent( + host: SubagentDetailHost, + event: + | { action: "upserted"; task: TaskSummary } + | { action: "deleted"; taskId: string } + | { action: "restored" }, +) { + const state = host.subagentDetailState; + if (!state) { + return; + } + if (event.action === "deleted") { + if (event.taskId === state.taskId) { + resetSubagentDetail(host); + } + return; + } + if (event.action !== "upserted" || event.task.id !== state.taskId) { + return; + } + state.eventVersion += 1; + // A terminal version remains pending through an in-flight or throttled read, + // so the next request is always the final child-session snapshot. + scheduleTranscriptLoad(host, state); +} diff --git a/ui/src/pages/chat/components/chat-subagent-detail.ts b/ui/src/pages/chat/components/chat-subagent-detail.ts new file mode 100644 index 000000000000..a14549f8e984 --- /dev/null +++ b/ui/src/pages/chat/components/chat-subagent-detail.ts @@ -0,0 +1,169 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { html, nothing, type TemplateResult } from "lit"; +import "../../../components/elapsed-time.ts"; +import { icons } from "../../../components/icons.ts"; +import { t } from "../../../i18n/index.ts"; +import { isActiveTask, taskTimestampMs, taskTitle } from "../../../lib/tasks/data.ts"; +import type { TaskSummary } from "../../../lib/tasks/task-summary.ts"; +import type { ChatProps } from "../chat-view.ts"; +import { renderTaskInspector } from "./chat-background-task-row.ts"; +import { + backgroundTaskStatusLabel, + newestTaskSnapshot, + STATUS_TONES, +} from "./chat-background-tasks-shared.ts"; +import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; +import { renderDiffStatChips } from "./chat-diff-render.ts"; +import { renderReadOnlyTranscript } from "./chat-read-only-transcript.ts"; +import { + readSubagentTranscript, + resetSubagentDetail, + type SubagentDetailHost, +} from "./chat-subagent-detail-state.ts"; +import type { ChatTranscriptController } from "./chat-transcript-controller.ts"; + +export function renderSubagentDetailPanel(params: { + backgroundTasks: BackgroundTasksProps; + chat: ChatProps; + host: SubagentDetailHost; + task: TaskSummary | undefined; + transcript: ChatTranscriptController; +}): TemplateResult { + const { backgroundTasks, task } = params; + if (!task) { + resetSubagentDetail(params.host); + return html` + + `; + } + const detailedTask = backgroundTasks.taskDetails.get(task.id); + const currentTask = newestTaskSnapshot(task, detailedTask); + const childSessionKey = normalizeOptionalString(currentTask.childSessionKey); + const content = childSessionKey + ? renderSubagentTranscript({ ...params, task: currentTask, sessionKey: childSessionKey }) + : renderSubagentFallback(currentTask, backgroundTasks, params.host); + return html` + + `; +} + +// No close button here on purpose: the sidebar region header owns the +// "Close Details" control for every detail-slot panel (the classic panel is +// embedded with its own header hidden); a second X 40px away duplicated it. +function renderSubagentHeader( + title: string, + task?: TaskSummary, + backgroundTasks?: BackgroundTasksProps, +): TemplateResult { + const active = task ? isActiveTask(task) : false; + const startedMs = task ? taskTimestampMs(task.startedAt ?? task.createdAt) : 0; + const cancelling = task ? backgroundTasks?.cancellingTaskIds.has(task.id) === true : false; + return html` + + `; +} + +function renderSubagentTranscript(params: { + chat: ChatProps; + host: SubagentDetailHost; + sessionKey: string; + task: TaskSummary; + transcript: ChatTranscriptController; +}): TemplateResult { + const load = readSubagentTranscript(params.host, { + taskId: params.task.id, + sessionKey: params.sessionKey, + }); + if (load.status === "loading") { + return html``; + } + if (load.status === "error") { + return html``; + } + if (load.messages.length === 0) { + return html``; + } + return html``; +} + +function renderSubagentFallback( + task: TaskSummary, + backgroundTasks: BackgroundTasksProps, + host: SubagentDetailHost, +): TemplateResult { + resetSubagentDetail(host); + if ( + !backgroundTasks.taskDetails.has(task.id) && + !backgroundTasks.taskDetailErrors.has(task.id) && + !backgroundTasks.taskDetailLoadingIds.has(task.id) + ) { + backgroundTasks.onLoadDetail?.(task); + } + return html``; +} diff --git a/ui/src/pages/chat/components/chat-tool-cards.ts b/ui/src/pages/chat/components/chat-tool-cards.ts index 09236c690854..a040b2849678 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.ts @@ -33,7 +33,9 @@ export { type WidgetPromptEventDetail, } from "./widget-card.ts"; -type FullMessageRequest = NonNullable; +type FullMessageRequest = NonNullable< + Extract["fullMessageRequest"] +>; export function shouldToggleSelectableDisclosure(event: MouseEvent): boolean { if (event.detail === 0) { diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 9770ce81b577..6a349c248de4 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -1359,6 +1359,77 @@ openclaw-chat-sidebar-region, padding: 16px; } +.chat-subagent-detail { + min-height: 0; +} + +.chat-subagent-detail__header { + gap: 10px; +} + +.chat-subagent-detail__heading { + display: grid; + min-width: 0; + gap: 5px; +} + +.chat-subagent-detail__heading .sidebar-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-subagent-detail__meta { + display: flex; + align-items: center; + min-width: 0; + gap: 5px; + color: var(--muted); + font-size: var(--control-ui-text-xs); +} + +.chat-subagent-detail__tool { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-subagent-detail__content { + display: flex; + padding: 0; + overflow: hidden; +} + +.chat-subagent-detail__transcript { + display: flex; + flex: 1 1 0; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.chat-subagent-detail__transcript > .chat-thread { + padding: 12px 0 6px; + border-radius: 0; +} + +.chat-subagent-detail__transcript .chat-thread-inner { + width: calc(100% - 16px); +} + +.chat-subagent-detail__state { + color: var(--muted); + font-size: var(--control-ui-text-sm); +} + +.chat-subagent-detail__state--error { + color: var(--danger); +} + +.chat-subagent-detail__fallback .chat-tasks-rail__detail-blocks { + gap: 12px; +} + /* Full-height panel kinds need a bounded wrapper so their inner content can shrink and scroll instead of expanding past the rail. */ .sidebar-panel-host--fill { diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index cb371608bd31..9f55c8f1816a 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -2034,6 +2034,31 @@ openclaw-tooltip.chat-tasks-status__preview { font-size: var(--control-ui-text-xs); } +.chat-subagent-activity__row--interactive { + width: 100%; + padding: 3px 5px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + font: inherit; + text-align: left; + cursor: var(--cursor-action); + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.chat-subagent-activity__row--interactive:hover, +.chat-subagent-activity__row--interactive:focus-visible { + background: color-mix(in srgb, var(--bg-hover) 70%, transparent); + color: var(--text); +} + +.chat-subagent-activity__row--interactive:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + .chat-subagent-activity__indicator { display: inline-flex; align-items: center;