refactor(ui): make task detail a detail-slot document (#125748)

* refactor(ui): make task detail a slot document

* test(ui): cover task detail slot teardown
This commit is contained in:
Peter Steinberger
2026-08-18 02:28:26 -07:00
committed by GitHub
parent 5e3e52431e
commit 2601bcbc52
4 changed files with 166 additions and 50 deletions
+2 -5
View File
@@ -45,7 +45,7 @@ import {
} from "./chat-state-route.ts";
import type { ChatProps } from "./chat-view.ts";
import { createBackgroundTasksProps } from "./components/chat-background-tasks.ts";
import { detailSlotOpen } from "./components/chat-detail-slot.ts";
import { openTaskDetailId } from "./components/chat-detail-slot.ts";
import { chatPullRequestId, createPullRequestBranch } from "./components/chat-pull-requests.ts";
import {
createSessionWorkspaceProps,
@@ -241,10 +241,7 @@ export class ChatPane extends ChatPaneLayoutRender {
};
const backgroundTasksBase = createBackgroundTasksProps(state, {
narrowLayout: false,
openTaskId:
state.sidebarContent?.kind === "task" && detailSlotOpen(sidebarLayout)
? state.sidebarContent.taskId
: undefined,
openTaskId: openTaskDetailId(state.sidebarContent, sidebarLayout),
onOpenTaskDetail: (task) => state.handleOpenSidebar({ kind: "task", taskId: task.id }),
});
const backgroundTasks = {
@@ -6,7 +6,7 @@ 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 { resetTaskDetail } from "./chat-task-detail-state.ts";
import { resetTaskDetail, type TaskDetailHost } from "./chat-task-detail-state.ts";
import { renderTaskDetailPanel } from "./chat-task-detail.ts";
import type { ChatTranscriptController } from "./chat-transcript-controller.ts";
@@ -17,6 +17,13 @@ export function detailSlotOpen(layout: SidebarLayout): boolean {
return layout.columns.some((column) => column.panels.some((panel) => panel.slot === "detail"));
}
export function openTaskDetailId(
content: SidebarContent | null | undefined,
layout: SidebarLayout,
): string | undefined {
return content?.kind === "task" && detailSlotOpen(layout) ? content.taskId : undefined;
}
export function renderChatDetailSlot(params: {
backgroundTasks: BackgroundTasksProps;
chat: ChatProps;
@@ -27,38 +34,44 @@ export function renderChatDetailSlot(params: {
transcript: ChatTranscriptController;
}): TemplateResult {
const { content, host } = params;
if (content.kind === "task") {
if (!detailSlotOpen(params.layout)) {
resetTaskDetail(host);
return html``;
}
return renderTaskDetailPanel({
backgroundTasks: params.backgroundTasks,
chat: params.chat,
host,
task: params.backgroundTasks.tasks?.find((task) => task.id === content.taskId) ?? undefined,
transcript: params.transcript,
});
const taskDetailHost: TaskDetailHost = host;
const taskId = openTaskDetailId(content, params.layout);
if (taskId === undefined && taskDetailHost.taskDetailState !== undefined) {
resetTaskDetail(taskDetailHost);
}
resetTaskDetail(host);
return html`<openclaw-chat-detail-panel
class="chat-sidebar"
.content=${content}
.basePath=${params.chat.basePath ?? ""}
.loadFullMessage=${params.fullMessageLoader}
.canvasPluginSurfaceUrl=${host.canvasPluginSurfaceUrl}
.embedSandboxMode=${host.embedSandboxMode}
.allowExternalEmbedUrls=${host.allowExternalEmbedUrls}
.onOpenWorkspaceFile=${(target: { path: string; line?: number | null }) =>
openSessionWorkspaceFile(host, target)}
.onOpenSessionLink=${params.chat.onOpenSessionLink}
.onRevealInWorkspace=${(path: string) => {
revealSessionWorkspaceFile(host, path);
host.updateSidebarLayout(openSlot(host.sidebarLayout, "workspace"));
}}
.onOpenImage=${(item: Parameters<typeof host.handleOpenImage>[0]) =>
host.handleOpenImage(item, host.beginImageOpen())}
.embedded=${true}
@chat-detail-panel-close=${() => host.handleCloseSidebar()}
></openclaw-chat-detail-panel>`;
const documents: Partial<Record<SidebarContent["kind"], TemplateResult>> = {
task:
taskId === undefined
? html``
: renderTaskDetailPanel({
backgroundTasks: params.backgroundTasks,
chat: params.chat,
host,
task: params.backgroundTasks.tasks?.find((task) => task.id === taskId) ?? undefined,
transcript: params.transcript,
}),
};
return (
documents[content.kind] ??
html`<openclaw-chat-detail-panel
class="chat-sidebar"
.content=${content}
.basePath=${params.chat.basePath ?? ""}
.loadFullMessage=${params.fullMessageLoader}
.canvasPluginSurfaceUrl=${host.canvasPluginSurfaceUrl}
.embedSandboxMode=${host.embedSandboxMode}
.allowExternalEmbedUrls=${host.allowExternalEmbedUrls}
.onOpenWorkspaceFile=${(target: { path: string; line?: number | null }) =>
openSessionWorkspaceFile(host, target)}
.onOpenSessionLink=${params.chat.onOpenSessionLink}
.onRevealInWorkspace=${(path: string) => {
revealSessionWorkspaceFile(host, path);
host.updateSidebarLayout(openSlot(host.sidebarLayout, "workspace"));
}}
.onOpenImage=${(item: Parameters<typeof host.handleOpenImage>[0]) =>
host.handleOpenImage(item, host.beginImageOpen())}
.embedded=${true}
@chat-detail-panel-close=${() => host.handleCloseSidebar()}
></openclaw-chat-detail-panel>`
);
}
+14 -11
View File
@@ -151,16 +151,19 @@ function setRetainedFileDraft(content: FileSidebarContent, draft: RetainedFileDr
retainedFileDrafts.set(key, draft);
}
type ChatDetailContent =
export type SidebarContent =
| MarkdownSidebarContent
| CanvasSidebarContent
| ImageSidebarContent
| FileSidebarContent
| SessionDiffSidebarContent;
| SessionDiffSidebarContent
| { kind: "task"; taskId: string };
export type SidebarContent = ChatDetailContent | { kind: "task"; taskId: string };
type ChatDetailPanelContent = Exclude<SidebarContent, { kind: "task" }>;
function hasFullMessageRequest(content: ChatDetailContent): content is ChatDetailContent & {
function hasFullMessageRequest(
content: ChatDetailPanelContent,
): content is ChatDetailPanelContent & {
fullMessageRequest: SidebarFullMessageRequest;
} {
return Boolean(
@@ -196,8 +199,8 @@ function toPlainTextCodeFence(value: string, language = ""): string {
}
function buildRawSidebarContent(
content: ChatDetailContent | null | undefined,
): ChatDetailContent | null {
content: ChatDetailPanelContent | null | undefined,
): ChatDetailPanelContent | null {
if (!content) {
return null;
}
@@ -505,7 +508,7 @@ function renderFileSidebarContent(
}
function resolveSidebarCanvasSandbox(
content: ChatDetailContent,
content: ChatDetailPanelContent,
embedSandboxMode: EmbedSandboxMode,
): string {
return content.kind === "canvas"
@@ -514,7 +517,7 @@ function resolveSidebarCanvasSandbox(
}
type MarkdownSidebarProps = {
content: ChatDetailContent | null;
content: ChatDetailPanelContent | null;
error: string | null;
fileView?: FileViewControls;
onClose: () => void;
@@ -704,7 +707,7 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
}
class ChatDetailPanel extends OpenClawLightDomElement {
@property({ attribute: false }) content: ChatDetailContent | null = null;
@property({ attribute: false }) content: ChatDetailPanelContent | null = null;
@property({ attribute: false }) loadFullMessage?: SidebarFullMessageLoader | null = null;
@property() basePath = "";
@property() canvasPluginSurfaceUrl: string | null = null;
@@ -719,7 +722,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: ChatDetailContent | null = null;
@state() private visibleContent: ChatDetailPanelContent | null = null;
@state() private error: string | null = null;
@state() private fileSearchOpen = false;
@state() private fileSearchQuery = "";
@@ -1249,7 +1252,7 @@ class ChatDetailPanel extends OpenClawLightDomElement {
});
};
private async upgradeToFullMessage(content: ChatDetailContent, version: number) {
private async upgradeToFullMessage(content: ChatDetailPanelContent, version: number) {
if (!hasFullMessageRequest(content) || !this.loadFullMessage) {
return;
}
@@ -1,11 +1,19 @@
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 type { ChatPageHost } from "../chat-state-host.ts";
import type { ChatProps } from "../chat-view.ts";
import { closeSlot, openSlot, type SidebarLayout } from "../sidebar-layout.ts";
import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts";
import { renderChatDetailSlot } from "./chat-detail-slot.ts";
import type { SidebarContent } from "./chat-sidebar.ts";
import * as taskDetailState from "./chat-task-detail-state.ts";
import {
observeTaskDetailEvent,
readTaskTranscript,
type TaskDetailHost,
} from "./chat-task-detail-state.ts";
import type { ChatTranscriptController } from "./chat-transcript-controller.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
@@ -49,6 +57,55 @@ function task(status: TaskSummary["status"]): TaskSummary {
};
}
function backgroundTasks(selectedTask: TaskSummary): BackgroundTasksProps {
return {
sessionKey: "agent:main:main",
statusRowId: "chat-tasks-status-test",
collapsed: false,
narrowLayout: false,
connected: true,
canCancel: false,
loading: false,
error: null,
tasks: [selectedTask],
subagentActivity: {
rows: [],
overflowWorking: 0,
taskIds: new Set(),
nextExpiryAt: null,
},
taskDetails: new Map(),
taskDetailErrors: new Map(),
taskDetailLoadingIds: new Set(),
cancellingTaskIds: new Set(),
finishedCollapsed: false,
onToggleCollapsed: () => undefined,
onToggleFinished: () => undefined,
onRefresh: () => undefined,
onCancel: () => undefined,
};
}
const taskContent = { kind: "task", taskId: "task-1" } satisfies SidebarContent;
const fileContent = {
kind: "file",
path: "notes.txt",
name: "notes.txt",
content: "Non-task detail",
} satisfies SidebarContent;
function renderDetail(host: TaskDetailHost, content: SidebarContent, layout: SidebarLayout) {
renderChatDetailSlot({
backgroundTasks: backgroundTasks(task("running")),
chat: { paneId: "pane-1" } as ChatProps,
content,
fullMessageLoader: null,
host: host as ChatPageHost,
layout,
transcript: {} as ChatTranscriptController,
});
}
async function flushAsync() {
await Promise.resolve();
await Promise.resolve();
@@ -60,6 +117,52 @@ afterEach(() => {
});
describe("task detail transcript state", () => {
it.each([
{
label: "the detail slot closes",
nextContent: taskContent,
nextLayout: closeSlot(openSlot({ columns: [] }, "detail"), "detail"),
},
{
label: "the detail slot switches to a file",
nextContent: fileContent,
nextLayout: openSlot({ columns: [] }, "detail"),
},
])("clears transcript state when $label", ({ nextContent, nextLayout }) => {
const pending = deferred<never>();
const host = hostWith(vi.fn().mockReturnValue(pending.promise));
const openDetailLayout = openSlot({ columns: [] }, "detail");
renderDetail(host, taskContent, openDetailLayout);
expect(host.taskDetailState).toBeDefined();
renderDetail(host, nextContent, nextLayout);
expect(host.taskDetailState).toBeUndefined();
});
it("does not reset transcript state during stable task or non-task renders", () => {
const pending = deferred<never>();
const request = vi.fn().mockReturnValue(pending.promise);
const host = hostWith(request);
const openDetailLayout = openSlot({ columns: [] }, "detail");
const reset = vi.spyOn(taskDetailState, "resetTaskDetail");
renderDetail(host, taskContent, openDetailLayout);
const openTaskState = host.taskDetailState;
renderDetail(host, taskContent, openDetailLayout);
expect(host.taskDetailState).toBe(openTaskState);
expect(request).toHaveBeenCalledOnce();
expect(reset).not.toHaveBeenCalled();
renderDetail(host, fileContent, openDetailLayout);
expect(host.taskDetailState).toBeUndefined();
expect(reset).toHaveBeenCalledOnce();
renderDetail(host, fileContent, openDetailLayout);
expect(reset).toHaveBeenCalledOnce();
});
it("loads the selected child transcript", async () => {
const pending = deferred<ReturnType<typeof history>>();
const request = vi.fn().mockReturnValue(pending.promise);