feat(ui): open subagent details in chat sidebar (#122941)

* feat(ui): open subagent details in chat sidebar

* chore: drop changelog edit (release generation owns it)

* refactor(ui): drop duplicate close in subagent detail panel

The sidebar region header already owns a Close Details control in both
wide and narrow layouts; the panel-local X duplicated it 40px away.

* fix(ui): stop subagent transcript loader when pane presentation retires

Pane retention wipes sidebarContent directly, so the detail slot's
render-time reset can never run again; a pending refresh timer plus
incoming task events kept refetching chat.history for a hidden panel.

* docs(ui): note close-control ownership in subagent detail header

* fix(ui): break transcript renderer import cycle

* fix(ui): use shared action cursor for subagent rows
This commit is contained in:
Peter Steinberger
2026-08-12 20:11:13 -07:00
committed by GitHub
parent 132299bcfa
commit f5ad8735d1
24 changed files with 953 additions and 117 deletions
+1 -1
View File
@@ -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.
@@ -35,6 +35,8 @@ function taskDetailCase(task: { id: string; title: string } & Record<string, unk
export function buildBackgroundTasksMock(baseTime: number) {
const now = Date.now();
const taskSessionKey = "agent:openclaw-mock:subagent:mock-task-1";
const secondTaskSessionKey = "agent:openclaw-mock:subagent:mock-task-2";
const requesterSessionKey = "agent:main:main";
const tasks = [
{
id: "task-mock-running",
@@ -49,6 +51,8 @@ export function buildBackgroundTasksMock(baseTime: number) {
toolUseCount: 7,
lastToolName: "read",
progressSummary: "Tracing task events through the background task rail",
sessionKey: requesterSessionKey,
ownerKey: requesterSessionKey,
childSessionKey: taskSessionKey,
},
{
@@ -62,6 +66,9 @@ export function buildBackgroundTasksMock(baseTime: number) {
startedAt: now - 95_000,
updatedAt: now - 1_000,
progressSummary: "Comparing agent-scoped task event paths",
sessionKey: requesterSessionKey,
ownerKey: requesterSessionKey,
childSessionKey: secondTaskSessionKey,
},
finishedTask(1, now),
finishedTask(2, now),
@@ -91,6 +98,25 @@ export function buildBackgroundTasksMock(baseTime: number) {
thinkingLevel: null,
},
},
{
match: { sessionKey: secondTaskSessionKey },
response: {
messages: [
historyMessage(
"user",
"Audit the gateway task-event scope guards.",
baseTime + 41 * 60_000,
),
historyMessage(
"assistant",
"Comparing requester, owner, and child-session event routing.",
baseTime + 41 * 60_000 + 6_000,
),
],
sessionId: "control-ui-mock-task-session-2",
thinkingLevel: null,
},
},
],
},
// One live subagent task exercises the rail, collapsed badge, and running-task status row.
+3
View File
@@ -5453,6 +5453,8 @@ export const en: TranslationMap = {
transcriptLoading: "Loading task transcript…",
transcriptEmpty: "No transcript messages yet.",
transcriptFailed: "Could not load task transcript.",
subagentDetailTitle: "Subagent details",
subagentUnavailable: "This subagent task is no longer available.",
prompt: "Prompt",
output: "Output",
promptUnavailable: "Prompt unavailable.",
@@ -5463,6 +5465,7 @@ export const en: TranslationMap = {
finished: "Subagent finished",
failed: "Subagent failed",
cancelled: "Subagent cancelled",
openDetails: "Open subagent details for {title}",
moreWorking: "+{count} more working",
},
},
+47 -1
View File
@@ -343,7 +343,29 @@ suite.define(() => {
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" });
},
);
});
+1
View File
@@ -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();
+12 -15
View File
@@ -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`<openclaw-chat-detail-panel
class="chat-sidebar"
.content=${state.sidebarContent}
.loadFullMessage=${fullMessageLoader}
.canvasPluginSurfaceUrl=${state.canvasPluginSurfaceUrl}
.embedSandboxMode=${state.embedSandboxMode}
.allowExternalEmbedUrls=${state.allowExternalEmbedUrls}
.onOpenWorkspaceFile=${(target: { path: string; line?: number | null }) =>
openSessionWorkspaceFile(state, target)}
.onRevealInWorkspace=${(path: string) => revealSessionWorkspaceFile(state, path)}
.onOpenImage=${(item: Parameters<typeof state.handleOpenImage>[0]) =>
state.handleOpenImage(item, state.beginImageOpen())}
.embedded=${true}
@chat-detail-panel-close=${() => state.handleCloseSidebar()}
></openclaw-chat-detail-panel>`,
detail: renderChatDetailSlot({
backgroundTasks,
chat: props,
content: state.sidebarContent,
fullMessageLoader,
host: state,
layout: sidebarLayout,
transcript: this.subagentSidebarTranscript,
}),
}
: {}),
...(discussion
@@ -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");
});
@@ -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?.();
}
+8 -40
View File
@@ -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({
@@ -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`
<div class="chat-tasks-rail__detail" data-task-detail=${task.id}>
@@ -212,30 +209,40 @@ export function renderTaskDetail(task: TaskSummary, props: BackgroundTasksProps)
<strong>${newest.progressSummary}</strong>
</div>`
: nothing}
${detailError
? html`<div
class="chat-tasks-rail__task-inspector-state chat-tasks-rail__task-inspector-state--error"
>
${detailError}
</div>`
: nothing}
<div class="chat-tasks-rail__detail-blocks">
<section class="chat-tasks-rail__task-inspector-block">
<div class="chat-tasks-rail__task-inspector-label">
${t("chat.backgroundTasks.prompt")}
</div>
<pre>
${detailLoading
? t("chat.backgroundTasks.detailLoading")
: (detailedTask?.prompt ?? t("chat.backgroundTasks.promptUnavailable"))}</pre>
</section>
<section class="chat-tasks-rail__task-inspector-block">
<div class="chat-tasks-rail__task-inspector-label">
${t("chat.backgroundTasks.output")}
</div>
<pre>${output ?? t("chat.backgroundTasks.outputPending")}</pre>
</section>
</div>
${renderTaskInspector(newest, props)}
</div>
`;
}
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`<div
class="chat-tasks-rail__task-inspector-state chat-tasks-rail__task-inspector-state--error"
>
${detailError}
</div>`
: nothing}
<div class="chat-tasks-rail__detail-blocks">
<section class="chat-tasks-rail__task-inspector-block">
<div class="chat-tasks-rail__task-inspector-label">${t("chat.backgroundTasks.prompt")}</div>
<pre>
${detailLoading
? t("chat.backgroundTasks.detailLoading")
: (detailedTask?.prompt ?? t("chat.backgroundTasks.promptUnavailable"))}</pre>
</section>
<section class="chat-tasks-rail__task-inspector-block">
<div class="chat-tasks-rail__task-inspector-label">${t("chat.backgroundTasks.output")}</div>
<pre>${output ?? t("chat.backgroundTasks.outputPending")}</pre>
</section>
</div>
`;
}
@@ -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),
);
@@ -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<ReturnType<typeof normalizeTaskEventPayload>>;
@@ -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),
@@ -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;
@@ -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`<openclaw-chat-detail-panel
class="chat-sidebar"
.content=${content}
.loadFullMessage=${params.fullMessageLoader}
.canvasPluginSurfaceUrl=${host.canvasPluginSurfaceUrl}
.embedSandboxMode=${host.embedSandboxMode}
.allowExternalEmbedUrls=${host.allowExternalEmbedUrls}
.onOpenWorkspaceFile=${(target: { path: string; line?: number | null }) =>
openSessionWorkspaceFile(host, target)}
.onRevealInWorkspace=${(path: string) => revealSessionWorkspaceFile(host, path)}
.onOpenImage=${(item: Parameters<typeof host.handleOpenImage>[0]) =>
host.handleOpenImage(item, host.beginImageOpen())}
.embedded=${true}
@chat-detail-panel-close=${() => host.handleCloseSidebar()}
></openclaw-chat-detail-panel>`;
}
@@ -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,
);
}
+13 -9
View File
@@ -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<SidebarContent["fullMessageRequest"]>;
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;
}
@@ -55,6 +55,7 @@ function makeProps(overrides: Partial<BackgroundTasksProps>): 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<HTMLButtonElement>(
'[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({
@@ -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`
<div
const content = html`
${renderSubagentActivityIndicator(task)}
<span class="chat-subagent-activity__label">${label}</span>
${snippet
? keyed(
`${task.status}:${snippet}`,
html`<span
class="chat-subagent-activity__snippet chat-subagent-activity__snippet--updated"
title=${snippet}
>${snippet}</span
>`,
)
: nothing}
${task.diffStat ? renderDiffStatChips(task.diffStat) : nothing}
`;
if (!onOpenSubagentDetail) {
return html`<div
class="chat-subagent-activity__row"
data-subagent-task-id=${task.id}
role="status"
aria-live="off"
>
${renderSubagentActivityIndicator(task)}
<span class="chat-subagent-activity__label">${label}</span>
${snippet
? keyed(
`${task.status}:${snippet}`,
html`<span
class="chat-subagent-activity__snippet chat-subagent-activity__snippet--updated"
title=${snippet}
>${snippet}</span
>`,
)
: nothing}
${task.diffStat ? renderDiffStatChips(task.diffStat) : nothing}
</div>
`;
${content}
</div> `;
}
return html`<button
class="chat-subagent-activity__row chat-subagent-activity__row--interactive"
data-subagent-task-id=${task.id}
type="button"
aria-label=${t("chat.backgroundTasks.subagentActivity.openDetails", {
title: taskTitle(task),
})}
@click=${() => onOpenSubagentDetail(task)}
>
${content}
</button>`;
}
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`<div class="chat-subagent-activity__overflow">
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<typeof vi.fn>): 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<ReturnType<typeof history>>();
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<never>();
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<ReturnType<typeof history>>();
const final = deferred<ReturnType<typeof history>>();
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);
});
});
@@ -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<ChatHistoryResult>("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);
}
@@ -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`
<div class="sidebar-panel chat-subagent-detail" data-subagent-detail-panel>
${renderSubagentHeader(t("chat.backgroundTasks.subagentDetailTitle"))}
<div class="sidebar-content chat-subagent-detail__state">
${t("chat.backgroundTasks.subagentUnavailable")}
</div>
</div>
`;
}
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`
<div class="sidebar-panel chat-subagent-detail" data-subagent-detail-panel>
${renderSubagentHeader(taskTitle(currentTask), currentTask, backgroundTasks)} ${content}
</div>
`;
}
// 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`
<div class="sidebar-header chat-subagent-detail__header">
<div class="chat-subagent-detail__heading">
<div class="sidebar-title" title=${title}>${title}</div>
${task
? html`<div class="chat-subagent-detail__meta">
${task.status === "running"
? html`<span class="chat-tasks-rail__task-pulse" aria-hidden="true"></span>`
: nothing}
<span
class="chat-tasks-rail__task-status chat-tasks-rail__task-status--${STATUS_TONES[
task.status
]}"
>${backgroundTaskStatusLabel(task)}</span
>
${active && startedMs > 0
? html`<span aria-hidden="true">·</span>
<openclaw-elapsed-time .startMs=${startedMs}></openclaw-elapsed-time>`
: nothing}
${task.lastToolName
? html`<span aria-hidden="true">·</span>
<span class="chat-subagent-detail__tool">${task.lastToolName}</span>`
: nothing}
${task.diffStat ? renderDiffStatChips(task.diffStat) : nothing}
</div>`
: nothing}
</div>
${task && active && backgroundTasks?.canCancel
? html`<div class="sidebar-header__actions">
<button
class="btn btn--ghost btn--sm"
type="button"
aria-label=${t("chat.backgroundTasks.stopTask", { title })}
?disabled=${cancelling || !backgroundTasks.connected}
@click=${() => backgroundTasks.onCancel(task.id)}
>
${cancelling ? icons.loader : icons.stop} ${t("chat.runControls.stop")}
</button>
</div>`
: nothing}
</div>
`;
}
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`<div class="sidebar-content chat-subagent-detail__state">
${t("chat.backgroundTasks.transcriptLoading")}
</div>`;
}
if (load.status === "error") {
return html`<div
class="sidebar-content chat-subagent-detail__state chat-subagent-detail__state--error"
>
${t("chat.backgroundTasks.transcriptFailed")}
</div>`;
}
if (load.messages.length === 0) {
return html`<div class="sidebar-content chat-subagent-detail__state">
${t("chat.backgroundTasks.transcriptEmpty")}
</div>`;
}
return html`<div class="sidebar-content chat-subagent-detail__content">
<div class="chat-subagent-detail__transcript">
${renderReadOnlyTranscript({
chat: params.chat,
messages: load.messages,
paneId: `${params.chat.paneId}:subagent-sidebar`,
sessionKey: params.sessionKey,
transcript: params.transcript,
})}
</div>
</div>`;
}
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`<div class="sidebar-content chat-subagent-detail__fallback">
${renderTaskInspector(task, backgroundTasks)}
</div>`;
}
@@ -33,7 +33,9 @@ export {
type WidgetPromptEventDetail,
} from "./widget-card.ts";
type FullMessageRequest = NonNullable<SidebarContent["fullMessageRequest"]>;
type FullMessageRequest = NonNullable<
Extract<SidebarContent, { kind: "markdown" }>["fullMessageRequest"]
>;
export function shouldToggleSelectableDisclosure(event: MouseEvent): boolean {
if (event.detail === 0) {
+71
View File
@@ -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 {
+25
View File
@@ -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;