fix(ui): dock the chat background-tasks rail to the bottom of narrow panes (#104107)

The tasks rail (from #104010) only rendered as a right-docked 230-280px
column and vanished with its toggles below a 1120px viewport, so narrow
split-view panes got a crushed thread and compact windows lost access
to background tasks entirely — the same bug class #104033 fixed for the
workspace rail and detail panel.

The rail now follows the pane's measured width: it presents as a
full-width bottom strip (sections side by side like the workspace
rail's bottom dock) when the pane is too narrow for a side column, and
the workspace rail keeps first claim on the side slot when only one
column fits. Side-docked rails, not bottom strips, now count against
the width available to the chat + detail split.

Proof: check:changed + 212 focused tests green on Testbox at this exact
content; codex autoreview clean; chromium+webkit visual verification at
seven window widths plus split view with zero geometry overlaps.
Landed with maintainer CI override.

Closes #104091
This commit is contained in:
Peter Steinberger
2026-07-11 13:40:08 -07:00
committed by GitHub
parent 66e4890234
commit 0ae3de7c6f
8 changed files with 147 additions and 64 deletions

View File

@@ -308,7 +308,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das
- The session diff panel shows what a session's checkout actually changed: the branch button (in the workspace rail header, the split-pane header, or the floating button in single-pane chat) opens the detail panel with a per-file diff of branch, uncommitted, and untracked work against the checkout's default-branch merge base — status dot, rename arrow, per-file +/ counts, collapsible files, and "N unmodified lines" markers between hunks. Diffs are computed server-side through the `sessions.diff` Gateway method (`operator.read` scope); binary and oversized files degrade to stats-only entries, and the button only appears when the connected Gateway advertises `sessions.diff`.
- The session workspace rail in each Chat pane lists session 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, the files toggle in the split-pane header, or the floating files button in single-pane chat (both carry a changed-file count badge). The separate file, tool, and Canvas detail panel is unaffected.
- 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 with a stop control, a collapsible finished section, and a View transcript link that opens the task's child session in the pane. Open it with the activity toggle in the split-pane header or the floating activity button in single-pane chat (both carry a running-count badge once loaded). The Tasks page remains the full cross-agent ledger.
- The workspace rail and detail panel adapt to each pane's own width rather than the window: in a narrow pane or compact window the workspace rail always presents as a bottom strip (side-dock controls hide until the pane widens), 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 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 top-right floating toggle row (beside the session diff, background tasks, and session files toggles), then split the active pane right or down for as many panes as fit. Each pane has its own session, transcript, composer, and tool stream.
- Drag a session from the sidebar into chat to open it in a pane. An animated drop preview glides between zones and labels the outcome — "Split" over the exact half a new pane will occupy, "Open here" over a whole pane — and drops also work from single-pane mode.

View File

@@ -1283,18 +1283,24 @@ class ChatPane extends OpenClawLightDomElement {
const sessionWorkspace = createSessionWorkspaceProps(state, {
narrowLayout: this.paneWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH,
});
const backgroundTasks = createBackgroundTasksProps(state, {
onOpenSession: (sessionKey) => {
this.onPaneSessionChange?.(this.paneId, sessionKey);
},
});
const railSideDocked =
!sessionWorkspace.collapsed &&
!sessionWorkspace.narrowLayout &&
sessionWorkspace.dock !== "bottom";
// Every open side rail (workspace and/or background tasks) narrows the
// room left for the chat + detail split.
const sideRailCount = (railSideDocked ? 1 : 0) + (backgroundTasks.collapsed ? 0 : 1);
// The workspace rail claims the side slot first; the tasks rail needs
// room for both columns before it may side-dock next to it.
const backgroundTasks = createBackgroundTasksProps(state, {
narrowLayout:
this.paneWidth <
WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH + (railSideDocked ? WORKSPACE_RAIL_MAX_WIDTH : 0),
onOpenSession: (sessionKey) => {
this.onPaneSessionChange?.(this.paneId, sessionKey);
},
});
const tasksSideDocked = !backgroundTasks.collapsed && !backgroundTasks.narrowLayout;
// Every side-docked rail narrows the room left for the chat + detail
// split; bottom strips do not.
const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0);
const detailSplitWidth = this.paneWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH;
const props: ChatProps = {
paneId: this.paneId,

View File

@@ -1464,17 +1464,23 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
}
});
it("releases the hidden tasks-rail grid track on narrow viewports", async () => {
const page = await openBrowserPage(1000, 700);
it("docks the tasks rail as a full-width bottom strip in a narrow pane", async () => {
const page = await openBrowserPage(1200, 700);
try {
// Below 1120px the background-tasks rail is display:none; its tasks-open
// grid templates must collapse so no empty column strip stays reserved.
// chat-pane sets --tasks-dock-bottom instead of --tasks-open when the
// pane is too narrow for a side column; the strip spans the pane and
// sits below the thread, composing with a bottom-docked workspace rail.
await page.setContent(
`<!doctype html><html><head><style>${readUiCss()}</style></head><body>
<div style="width: 980px; height: 600px; display: flex;">
<div class="chat-workbench chat-workbench--tasks-open">
<aside class="chat-workspace-rail">workspace</aside>
<aside class="chat-tasks-rail">tasks</aside>
<div style="width: 640px; height: 640px; display: flex;">
<div class="chat-workbench chat-workbench--dock-bottom chat-workbench--tasks-dock-bottom">
<aside class="chat-workspace-rail">workspace strip</aside>
<aside class="chat-tasks-rail">
<div class="chat-tasks-rail__scroll">
<section class="chat-tasks-rail__section">Running</section>
<section class="chat-tasks-rail__section">Finished</section>
</div>
</aside>
<div class="chat-workbench__main">thread</div>
</div>
</div>
@@ -1485,25 +1491,37 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
const rectFor = (selector: string) => {
const node = document.querySelector<HTMLElement>(selector);
const rect = node?.getBoundingClientRect();
return rect ? { left: rect.left, right: rect.right, width: rect.width } : null;
return rect
? {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
}
: null;
};
return {
workbench: rectFor(".chat-workbench"),
rail: rectFor(".chat-workspace-rail"),
tasksVisible:
getComputedStyle(document.querySelector<HTMLElement>(".chat-tasks-rail")!).display !==
"none",
main: rectFor(".chat-workbench__main"),
workspace: rectFor(".chat-workspace-rail"),
tasks: rectFor(".chat-tasks-rail"),
};
});
expect(layout.tasksVisible).toBe(false);
expect(layout.workbench).not.toBeNull();
expect(layout.rail).not.toBeNull();
// The workspace rail sits flush at the workbench edge — no empty strip
// reserved for the hidden tasks rail.
expect(
Math.abs((layout.rail?.right ?? 0) - (layout.workbench?.right ?? 0)),
).toBeLessThanOrEqual(1);
const workbench = layout.workbench;
const tasks = layout.tasks;
const workspace = layout.workspace;
expect(workbench).not.toBeNull();
expect(tasks).not.toBeNull();
expect(workspace).not.toBeNull();
// Full pane width, below both the thread and the workspace strip.
expect(Math.abs((tasks?.width ?? 0) - (workbench?.width ?? 0))).toBeLessThanOrEqual(1);
expect(tasks?.top ?? 0).toBeGreaterThanOrEqual((workspace?.bottom ?? 0) - 1);
expect(tasks?.top ?? 0).toBeGreaterThanOrEqual((layout.main?.bottom ?? 0) - 1);
expect(tasks?.height ?? 0).toBeGreaterThanOrEqual(160);
expect(tasks?.height ?? 0).toBeLessThanOrEqual(237);
} finally {
await closeBrowserPage(page);
}

View File

@@ -1591,6 +1591,37 @@ describe("chat composer workbench", () => {
expect(container.querySelector(".chat-workspace-rail__grip")).toBeNull();
});
it("moves the background-tasks rail to a bottom strip on narrow panes", () => {
const backgroundTasks = {
agentId: "main",
collapsed: false,
narrowLayout: false,
connected: true,
canCancel: false,
loading: false,
error: null,
tasks: [],
cancellingTaskIds: new Set<string>(),
finishedCollapsed: false,
onToggleCollapsed: () => undefined,
onToggleFinished: () => undefined,
onRefresh: () => undefined,
onCancel: () => undefined,
onOpenSession: () => undefined,
};
const wide = renderChatView({ backgroundTasks });
const wideWorkbench = wide.querySelector(".chat-workbench");
expect(wideWorkbench?.classList.contains("chat-workbench--tasks-open")).toBe(true);
expect(wideWorkbench?.classList.contains("chat-workbench--tasks-dock-bottom")).toBe(false);
const narrow = renderChatView({ backgroundTasks: { ...backgroundTasks, narrowLayout: true } });
const narrowWorkbench = narrow.querySelector(".chat-workbench");
expect(narrowWorkbench?.classList.contains("chat-workbench--tasks-open")).toBe(false);
expect(narrowWorkbench?.classList.contains("chat-workbench--tasks-dock-bottom")).toBe(true);
expect(narrow.querySelector(".chat-tasks-rail")).not.toBeNull();
});
it("keeps the secondary New session and Export controls suppressed in the composer", () => {
const container = renderChatView({
messages: [{ role: "assistant", content: "ready" }],

View File

@@ -199,6 +199,8 @@ export function renderChat(props: ChatProps) {
props.sessionWorkspace &&
(props.sessionWorkspace.dock === "bottom" || props.sessionWorkspace.narrowLayout),
);
const tasksOpen = props.backgroundTasks?.collapsed === false;
const tasksDockBottom = tasksOpen && props.backgroundTasks?.narrowLayout === true;
const canCompose = props.canSend;
let chatSection: HTMLElement | null = null;
@@ -396,10 +398,10 @@ export function renderChat(props: ChatProps) {
<div
class="chat-workbench ${props.sessionWorkspace?.collapsed
? "chat-workbench--workspace-collapsed"
: ""} ${workspaceDockBottom ? "chat-workbench--dock-bottom" : ""} ${props.backgroundTasks
?.collapsed === false
: ""} ${workspaceDockBottom ? "chat-workbench--dock-bottom" : ""} ${tasksOpen &&
!tasksDockBottom
? "chat-workbench--tasks-open"
: ""}"
: ""} ${tasksDockBottom ? "chat-workbench--tasks-dock-bottom" : ""}"
>
${renderSessionWorkspaceRail(props.sessionWorkspace)}
${renderBackgroundTasksRail(props.backgroundTasks)}

View File

@@ -205,6 +205,7 @@ describe("background tasks rail rendering", () => {
html`${renderBackgroundTasksRail({
agentId: "main",
collapsed: false,
narrowLayout: false,
connected: true,
canCancel: true,
loading: false,
@@ -247,6 +248,7 @@ describe("background tasks rail rendering", () => {
html`${renderBackgroundTasksRail({
agentId: "main",
collapsed: false,
narrowLayout: false,
connected: true,
canCancel: false,
loading: false,

View File

@@ -28,6 +28,9 @@ import { paneSessionAgentId } from "./chat-session-workspace.ts";
export type BackgroundTasksProps = {
agentId: string;
collapsed: boolean;
/** Pane too narrow for a side rail: presentation moves to a bottom strip
* (mirrors the workspace rail's narrow mode). */
narrowLayout: boolean;
connected: boolean;
canCancel: boolean;
loading: boolean;
@@ -261,7 +264,7 @@ export function toggleBackgroundTasks(host: BackgroundTasksHost) {
export function createBackgroundTasksProps(
host: BackgroundTasksHost,
opts: { onOpenSession: (sessionKey: string) => void },
opts: { narrowLayout?: boolean; onOpenSession: (sessionKey: string) => void },
): BackgroundTasksProps {
const state = getBackgroundTasksState(host);
if (!host.connected) {
@@ -281,6 +284,7 @@ export function createBackgroundTasksProps(
return {
agentId: state.agentId,
collapsed: state.collapsed,
narrowLayout: opts.narrowLayout === true,
connected: host.connected,
// tasks.cancel needs operator.write; read-only operators get no button.
canCancel: host.connected && hasOperatorWriteAccess(host.hello?.auth ?? null),
@@ -460,7 +464,9 @@ export function renderBackgroundTasksRail(
@click=${backgroundTasks.onToggleCollapsed}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true"
>${icons.panelRightClose}</span
>${backgroundTasks.narrowLayout
? icons.panelBottomClose
: icons.panelRightClose}</span
>
</button>
</openclaw-tooltip>

View File

@@ -86,16 +86,9 @@
padding: 5px;
}
/* The workspace rail adapts to the pane width (side column on wide panes,
bottom strip on narrow ones — chat-pane measures and flips the dock), so
its toggles stay available at every width. The background-tasks rail is
side-dock only, so its toggles still hide on narrow viewports until it
grows a narrow presentation. */
@media (max-width: 1120px) {
.chat-tasks-toggle {
display: none;
}
}
/* Both rails adapt to the pane width (side column on wide panes, bottom strip
on narrow ones — chat-pane measures and flips the presentation), so their
toggles stay available at every width. */
/* Positioning context for the badge on both the pane-header and floating
variants (the floating buttons are static children of the toggle row). */
@@ -1698,27 +1691,52 @@
min-height: 160px;
}
/* The background-tasks rail has no narrow presentation yet; hide it with its
toggles on narrow viewports (the workspace rail adapts instead). The hidden
rail must also release its reserved grid track, so the tasks-open templates
collapse back to the workspace-only shapes here. */
@media (max-width: 1120px) {
.chat-tasks-rail {
display: none;
}
/* Narrow pane: the tasks rail mirrors the workspace rail's bottom strip.
chat-view only sets --tasks-open for the side column, so the column
templates never fire here; these rules add the strip row instead. */
.chat-workbench--tasks-dock-bottom {
grid-template-rows: minmax(0, 1fr) minmax(160px, 236px);
}
.chat-workbench--tasks-open.chat-workbench--workspace-collapsed,
.chat-workbench--tasks-open.chat-workbench--dock-bottom {
grid-template-columns: minmax(0, 1fr);
}
/* Both strips open: workspace row 2, tasks row 3. */
.chat-workbench--tasks-dock-bottom.chat-workbench--dock-bottom {
grid-template-rows: minmax(0, 1fr) minmax(160px, 236px) minmax(160px, 236px);
}
/* Mirrors the three-column selector's specificity so the two-column shape
wins while the tasks rail is hidden. */
.chat-workbench--tasks-open:not(.chat-workbench--workspace-collapsed):not(
.chat-workbench--dock-bottom
) {
grid-template-columns: minmax(0, 1fr) minmax(230px, 280px);
}
/* A collapsed workspace rail renders nothing, so its strip row must not stay
reserved under the tasks strip. */
.chat-workbench--tasks-dock-bottom.chat-workbench--dock-bottom.chat-workbench--workspace-collapsed {
grid-template-rows: minmax(0, 1fr) minmax(160px, 236px);
}
/* -2 is the second-from-last grid line, so the strip always lands in the last
row whether or not the workspace strip sits above it. */
.chat-workbench--tasks-dock-bottom .chat-tasks-rail {
grid-column: 1 / -1;
grid-row: -2;
border-left: none;
border-top: 1px solid var(--border);
}
/* Bottom strip is short and wide: sections sit side by side as their own
scroll columns, matching the workspace rail's bottom dock. */
.chat-workbench--tasks-dock-bottom .chat-tasks-rail__scroll {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
}
.chat-workbench--tasks-dock-bottom .chat-tasks-rail__scroll > .chat-tasks-rail__section {
flex: 1 0 260px;
min-width: 0;
overflow-y: auto;
}
.chat-workbench--tasks-dock-bottom
.chat-tasks-rail__scroll
> .chat-tasks-rail__section
+ .chat-tasks-rail__section {
border-left: 1px solid color-mix(in srgb, var(--border) 42%, transparent);
}
/* Mobile: Full-screen modal */