feat: make workspace files panel collapsible

Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
sallyom
2026-06-13 13:45:49 -04:00
committed by Shakker
parent 13a079b3f8
commit d03932af18
6 changed files with 197 additions and 13 deletions
+43 -1
View File
@@ -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);
+12
View File
@@ -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,
},
+45
View File
@@ -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",
+7
View File
@@ -496,6 +496,13 @@ export const icons = {
<path d="M10 10l-3 2 3 2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
panelRightClose: html`
<svg viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M15 3v18" stroke-linecap="round" />
<path d="M8 10l3 2-3 2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
maximize: html`
<svg viewBox="0 0 24 24">
<polyline points="15 3 21 3 21 9" />
+37 -1
View File
@@ -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`<button class="test-composer-control">Model</button>`,
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<HTMLButtonElement>('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<HTMLButtonElement>(
'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", () => {
+53 -11
View File
@@ -199,11 +199,13 @@ export type ChatProps = {
basePath?: string;
composerControls?: TemplateResult | typeof nothing | ReturnType<typeof guard>;
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`
<aside
class="chat-workspace-rail chat-workspace-rail--collapsed"
aria-label="Workspace files"
>
<button
type="button"
class="nav-collapse-toggle chat-workspace-rail__collapse-toggle"
title="Expand workspace files"
aria-label="Expand workspace files"
aria-expanded="false"
@click=${workspaceFiles.onToggleCollapsed}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.panelRightOpen}</span>
</button>
<span class="chat-workspace-rail__collapsed-icon" aria-hidden="true"
>${icons.fileText}</span
>
</aside>
`;
}
const files = workspaceFiles.list?.files ?? [];
return html`
<aside class="chat-workspace-rail" aria-label="Workspace files">
@@ -1036,16 +1060,30 @@ function renderWorkspaceFileRail(
<span class="chat-workspace-rail__eyebrow">Workspace</span>
<strong>Files</strong>
</div>
<button
class="btn btn--ghost btn--sm chat-workspace-rail__refresh"
type="button"
title="Refresh files"
aria-label="Refresh files"
?disabled=${workspaceFiles.loading}
@click=${workspaceFiles.onRefresh}
>
${icons.refresh}
</button>
<div class="chat-workspace-rail__actions">
<button
class="btn btn--ghost btn--sm chat-workspace-rail__refresh"
type="button"
title="Refresh files"
aria-label="Refresh files"
?disabled=${workspaceFiles.loading}
@click=${workspaceFiles.onRefresh}
>
${icons.refresh}
</button>
<button
type="button"
class="nav-collapse-toggle chat-workspace-rail__collapse-toggle"
title="Collapse workspace files"
aria-label="Collapse workspace files"
aria-expanded="true"
@click=${workspaceFiles.onToggleCollapsed}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true"
>${icons.panelRightClose}</span
>
</button>
</div>
</div>
${workspaceFiles.list?.workspace
? html`<div class="chat-workspace-rail__path" title=${workspaceFiles.list.workspace}>
@@ -2058,7 +2096,11 @@ export function renderChat(props: ChatProps) {
: nothing}
${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)}
<div class="chat-workbench">
<div
class="chat-workbench ${props.workspaceFiles?.collapsed
? "chat-workbench--workspace-collapsed"
: ""}"
>
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
<div
class="chat-main"