diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 43726519f593..a86fa17bb63f 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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. diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 792419a5f6f3..9199e5b6ad14 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -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, diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 6f25183896f9..3510ed4a7b72 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -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( ` -
-
- - +
+
+ +
thread
@@ -1485,25 +1491,37 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { const rectFor = (selector: string) => { const node = document.querySelector(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(".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); } diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 620bb50b47ca..e09ddec14fc7 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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(), + 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" }], diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 29d91a0d9c4f..6eae49d06ff9 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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) {
${renderSessionWorkspaceRail(props.sessionWorkspace)} ${renderBackgroundTasksRail(props.backgroundTasks)} diff --git a/ui/src/pages/chat/components/chat-background-tasks.test.ts b/ui/src/pages/chat/components/chat-background-tasks.test.ts index b224d36b6a25..c4e5dd812a37 100644 --- a/ui/src/pages/chat/components/chat-background-tasks.test.ts +++ b/ui/src/pages/chat/components/chat-background-tasks.test.ts @@ -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, diff --git a/ui/src/pages/chat/components/chat-background-tasks.ts b/ui/src/pages/chat/components/chat-background-tasks.ts index e5c0c7e791c6..ef1516f704bd 100644 --- a/ui/src/pages/chat/components/chat-background-tasks.ts +++ b/ui/src/pages/chat/components/chat-background-tasks.ts @@ -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} > ${backgroundTasks.narrowLayout + ? icons.panelBottomClose + : icons.panelRightClose} diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 7eca2a206483..31f8fcdd4726 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -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 */