diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 0704ec18927d..f707626eb7f8 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -8,6 +8,10 @@ overflow: hidden; } +.chat-workbench--workspace-collapsed { + grid-template-columns: minmax(0, 1fr) 52px; +} + .chat-split-container { display: flex; gap: 0; @@ -45,6 +49,12 @@ background: color-mix(in srgb, var(--panel) 84%, transparent); } +.chat-workspace-rail--collapsed { + align-items: center; + gap: 10px; + padding: 12px 8px; +} + .chat-workspace-rail__header { display: flex; align-items: center; @@ -75,14 +85,27 @@ line-height: 1.2; } -.chat-workspace-rail__refresh { +.chat-workspace-rail__actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.chat-workspace-rail__refresh, +.chat-workspace-rail__collapse-toggle { width: 32px; min-width: 32px; height: 32px; padding: 0; } +.chat-workspace-rail__collapse-toggle { + flex: 0 0 auto; + box-shadow: none; +} + .chat-workspace-rail__refresh svg, +.chat-workspace-rail__collapse-toggle svg, .chat-workspace-rail__file-icon svg { width: 15px; height: 15px; @@ -93,6 +116,25 @@ stroke-linejoin: round; } +.chat-workspace-rail__collapsed-icon { + color: var(--muted); + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; +} + +.chat-workspace-rail__collapsed-icon svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + .chat-workspace-rail__path { padding: 9px 12px; border-bottom: 1px solid color-mix(in srgb, var(--border) 42%, transparent); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 2594719d0d5b..dfca11f04e16 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -680,6 +680,7 @@ const lazyWorkboard = createLazyView(() => import("./views/workboard.ts"), notif type ChatWorkspaceFilesState = { activeName: string | null; agentId: string; + collapsed: boolean; error: string | null; list: AgentsFilesListResult | null; loading: boolean; @@ -700,6 +701,7 @@ function getChatWorkspaceFilesState(state: AppViewState, agentId: string): ChatW const next = { activeName: null, agentId, + collapsed: false, error: null, list: null, loading: false, @@ -2098,6 +2100,7 @@ export function renderApp(state: AppViewState) { }; if ( isChat && + !chatWorkspaceFiles.collapsed && state.connected && state.agentsList && !chatWorkspaceFiles.loading && @@ -2106,6 +2109,13 @@ export function renderApp(state: AppViewState) { ) { loadChatWorkspaceFiles(); } + const toggleChatWorkspaceFilesCollapsed = () => { + chatWorkspaceFiles.collapsed = !chatWorkspaceFiles.collapsed; + if (!chatWorkspaceFiles.collapsed && chatWorkspaceFiles.list?.agentId !== chatAgentId) { + loadChatWorkspaceFiles(); + } + requestHostUpdate?.(); + }; const refreshChatWorkspaceFiles = () => { loadChatWorkspaceFiles({ force: true }); }; @@ -3534,6 +3544,7 @@ export function renderApp(state: AppViewState) { sessions: state.sessionsResult, composerControls: renderGuardedChatControls(state), workspaceFiles: { + collapsed: chatWorkspaceFiles.collapsed, agentId: chatAgentId, list: chatWorkspaceFiles.list?.agentId === chatAgentId @@ -3542,6 +3553,7 @@ export function renderApp(state: AppViewState) { loading: chatWorkspaceFiles.loading, error: chatWorkspaceFiles.error, activeName: chatWorkspaceFiles.activeName, + onToggleCollapsed: toggleChatWorkspaceFilesCollapsed, onRefresh: refreshChatWorkspaceFiles, onOpenFile: openChatWorkspaceFile, }, diff --git a/ui/src/ui/e2e/chat-flow.e2e.test.ts b/ui/src/ui/e2e/chat-flow.e2e.test.ts index 5730af3e297e..49c5266c9178 100644 --- a/ui/src/ui/e2e/chat-flow.e2e.test.ts +++ b/ui/src/ui/e2e/chat-flow.e2e.test.ts @@ -221,6 +221,51 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { } }); + it("collapses the workspace files panel from its header control", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.files.list": { + agentId: "main", + files: [{ name: "AGENTS.md", path: "/workspace/AGENTS.md", size: 2048 }], + workspace: "/workspace", + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}chat`); + await page.getByRole("button", { name: "Collapse workspace files" }).waitFor({ + timeout: 10_000, + }); + await page.getByText("AGENTS.md").waitFor({ timeout: 10_000 }); + expect(await gateway.getRequests("agents.files.list")).toHaveLength(1); + + await page.getByRole("button", { name: "Collapse workspace files" }).click(); + await page.getByRole("button", { name: "Expand workspace files" }).waitFor({ + timeout: 10_000, + }); + expect(await page.locator(".chat-workspace-rail__file").count()).toBe(0); + expect(await page.locator(".chat-workspace-rail__collapsed-icon svg").count()).toBe(1); + + await page.getByRole("button", { name: "Expand workspace files" }).click(); + await page.getByRole("button", { name: "Collapse workspace files" }).waitFor({ + timeout: 10_000, + }); + await page.getByText("AGENTS.md").waitFor({ timeout: 10_000 }); + + await page.setViewportSize({ height: 900, width: 1000 }); + expect(await page.locator(".chat-workspace-rail").isHidden()).toBe(true); + } finally { + await context.close(); + } + }); + it("renders stable markdown during a streaming chat turn and finalizes the tail", async () => { const context = await browser.newContext({ locale: "en-US", diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts index fe1f60585740..c887b2267833 100644 --- a/ui/src/ui/icons.ts +++ b/ui/src/ui/icons.ts @@ -496,6 +496,13 @@ export const icons = { `, + panelRightClose: html` + + + + + + `, maximize: html` diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index d61ffad8219f..53a0674f06d0 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -875,12 +875,14 @@ describe("chat goal status", () => { }); describe("chat composer workbench", () => { - it("renders session controls in the composer and workspace files in the rail", () => { + it("renders session controls in the composer and workspace files in the expanded rail", () => { + const onToggleCollapsed = vi.fn(); const onRefresh = vi.fn(); const onOpenFile = vi.fn(); const container = renderChatView({ composerControls: html`Model`, workspaceFiles: { + collapsed: false, agentId: "main", list: { agentId: "main", @@ -897,6 +899,7 @@ describe("chat composer workbench", () => { loading: false, error: null, activeName: "AGENTS.md", + onToggleCollapsed, onRefresh, onOpenFile, }, @@ -913,8 +916,41 @@ describe("chat composer workbench", () => { expect(file?.textContent).toContain("2 KB"); file?.click(); + container + .querySelector('button[aria-label="Collapse workspace files"]') + ?.click(); expect(onOpenFile).toHaveBeenCalledWith("AGENTS.md"); + expect(onToggleCollapsed).toHaveBeenCalledTimes(1); + expect(container.querySelector('button[aria-label="Workspace files"]')).toBeNull(); + }); + + it("keeps the workspace files rail reachable from the collapsed strip", () => { + const onToggleCollapsed = vi.fn(); + const container = renderChatView({ + workspaceFiles: { + collapsed: true, + agentId: "main", + list: null, + loading: false, + error: null, + activeName: null, + onToggleCollapsed, + onRefresh: () => undefined, + onOpenFile: () => undefined, + }, + }); + + expect(container.querySelector(".chat-workspace-rail__list")).toBeNull(); + expect(container.querySelector(".chat-workspace-rail__collapsed-icon")).not.toBeNull(); + const toggle = container.querySelector( + 'button[aria-label="Expand workspace files"]', + ); + expect(toggle?.getAttribute("aria-expanded")).toBe("false"); + + toggle?.click(); + + expect(onToggleCollapsed).toHaveBeenCalledTimes(1); }); it("keeps the secondary New session and Export controls suppressed in the composer", () => { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index e880aed485db..4b0dac743d05 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -199,11 +199,13 @@ export type ChatProps = { basePath?: string; composerControls?: TemplateResult | typeof nothing | ReturnType; workspaceFiles?: { + collapsed: boolean; agentId: string; list: AgentsFilesListResult | null; loading: boolean; error: string | null; activeName: string | null; + onToggleCollapsed: () => void; onRefresh: () => void; onOpenFile: (name: string) => void; }; @@ -1028,6 +1030,28 @@ function renderWorkspaceFileRail( if (!workspaceFiles) { return nothing; } + if (workspaceFiles.collapsed) { + return html` + + `; + } const files = workspaceFiles.list?.files ?? []; return html`