From 49c2142d2dc7f2a6194f2bed52101fb514f9ac12 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:40:07 +1000 Subject: [PATCH] fix: allow unlinking chat workspaces (#25833) This allows a Coder Agents chat to detach from its linked workspace without deleting or changing the workspace, so a different workspace can be linked later. It adds detach controls wherever the linked workspace appears, including the workspace pill menu, fallback workspace badges, and the workspace picker. The workspace selection state now updates consistently across desktop and mobile. Running workspace: image Stopped workspace: image Closes CODAGT-510 --- site/src/modules/apps/apps.test.ts | 6 +- site/src/pages/AgentsPage/AgentChatPage.tsx | 5 +- .../AgentsPage/AgentChatPageView.stories.tsx | 17 ++- .../pages/AgentsPage/AgentChatPageView.tsx | 2 +- .../components/AgentChatInput.stories.tsx | 113 +++++++++++++++++- .../AgentsPage/components/AgentChatInput.tsx | 73 +++++++---- .../AgentsPage/components/ChatPageContent.tsx | 2 +- .../components/WorkspacePill.stories.tsx | 63 ++++++++-- .../AgentsPage/components/WorkspacePill.tsx | 82 ++++++++----- .../WorkspaceScheduleControls.test.tsx | 8 +- .../WorkspaceSchedulePage.test.tsx | 6 +- site/src/testHelpers/entities.ts | 2 +- 12 files changed, 291 insertions(+), 88 deletions(-) diff --git a/site/src/modules/apps/apps.test.ts b/site/src/modules/apps/apps.test.ts index 146964af78..6f8bc73f67 100644 --- a/site/src/modules/apps/apps.test.ts +++ b/site/src/modules/apps/apps.test.ts @@ -129,7 +129,7 @@ describe("getAppHref", () => { path: "/path-base", }); expect(href).toBe( - `/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`, + `/path-base/@${MockWorkspace.owner_name}/test-workspace.a-workspace-agent/apps/${app.slug}/`, ); }); @@ -145,7 +145,7 @@ describe("getAppHref", () => { path: "", }); expect(href).toBe( - `/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/terminal?app=${app.slug}`, + `/@${MockWorkspace.owner_name}/test-workspace.a-workspace-agent/terminal?app=${app.slug}`, ); }); @@ -177,7 +177,7 @@ describe("getAppHref", () => { path: "/path-base", }); expect(href).toBe( - `/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`, + `/path-base/@${MockWorkspace.owner_name}/test-workspace.a-workspace-agent/apps/${app.slug}/`, ); }); }); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index b4196b7280..358355adbe 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1160,6 +1160,7 @@ const AgentChatPage: FC = () => { isUpdateChatPlanModePending || isUpdateChatWorkspacePending; const isInputDisabled = !hasModelOptions || isArchived || isChatSettingsPending || isViewerNotOwner; + const canUpdateChatWorkspace = !isArchived && !isViewerNotOwner; const selectedWorkspaceId = chatQuery.data?.workspace_id ?? null; const isWorkspaceLoading = @@ -1633,7 +1634,9 @@ const AgentChatPage: FC = () => { isInterruptPending={isInterruptPending} workspaceOptions={workspaceOptions} selectedWorkspaceId={selectedWorkspaceId} - onWorkspaceChange={handleWorkspaceChange} + onWorkspaceChange={ + canUpdateChatWorkspace ? handleWorkspaceChange : undefined + } isWorkspaceLoading={isWorkspaceLoading} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index baed2e4d8e..1d4a94ed9d 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -673,7 +673,22 @@ export const WorkspaceAgentStartTimeout: Story = { }; export const WorkspaceNoAgent: Story = { - render: () => , + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { + name: `Remove workspace ${MockWorkspace.name}`, + }), + ).toBeVisible(); + }, }; // --------------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 596c499162..8d448acc3d 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -226,7 +226,7 @@ export const AgentChatPageView: FC = ({ isInterruptPending, workspaceOptions = [], selectedWorkspaceId = null, - onWorkspaceChange = () => {}, + onWorkspaceChange, isWorkspaceLoading = false, isSidebarCollapsed, onToggleSidebarCollapsed, diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 02d52ee3f3..375cf88430 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -910,15 +910,16 @@ export const DetailPageWorkspacePicker: Story = { statusLabel: "Workspace running", }, }, - play: async ({ canvasElement }) => { + play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); expect(canvas.getAllByText("agents-workspace")).toHaveLength(1); - expect( - canvas.queryByRole("button", { - name: "Remove workspace agents-workspace", - }), - ).not.toBeInTheDocument(); + const removeWorkspaceButton = canvas.getByRole("button", { + name: "Remove workspace agents-workspace", + }); + expect(removeWorkspaceButton).toBeVisible(); + await userEvent.click(removeWorkspaceButton); + expect(args.onWorkspaceChange).toHaveBeenCalledWith(null); const moreOptionsButton = canvas.getByRole("button", { name: "More options", @@ -940,6 +941,106 @@ export const DetailPageWorkspacePicker: Story = { }, }; +export const LinkedWorkspaceRemoveWhenInputDisabled: Story = { + args: { + isDisabled: true, + workspace: MockWorkspace, + workspaceAgent: MockWorkspaceAgent, + chatId: "chat-detail", + selectedWorkspaceId: MockWorkspace.id, + onWorkspaceChange: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const workspaceMenuButton = canvas.getByRole("button", { + name: `${MockWorkspace.name} workspace menu`, + }); + + expect( + canvas.queryByRole("button", { + name: `Remove workspace ${MockWorkspace.name}`, + }), + ).not.toBeInTheDocument(); + expect(workspaceMenuButton).toBeVisible(); + expect(workspaceMenuButton).toBeEnabled(); + await userEvent.click(workspaceMenuButton); + let detachWorkspaceItem: HTMLElement | null = null; + await waitFor(() => { + const menuId = workspaceMenuButton.getAttribute("aria-controls"); + if (!menuId) { + throw new Error("Expected workspace pill to control a menu."); + } + + const menu = canvasElement.ownerDocument.getElementById(menuId); + if (!(menu instanceof HTMLElement)) { + throw new Error("Expected workspace menu to render."); + } + + detachWorkspaceItem = within(menu).getByRole("menuitem", { + name: "Detach workspace", + }); + expect(detachWorkspaceItem).toBeVisible(); + }); + if (!detachWorkspaceItem) { + throw new Error("Expected detach workspace menu item to render."); + } + + await userEvent.click(detachWorkspaceItem); + expect(args.onWorkspaceChange).toHaveBeenCalledWith(null); + }, +}; + +export const UncheckSelectedWorkspaceFromPicker: Story = { + args: { + isDisabled: true, + workspace: MockWorkspace, + workspaceAgent: MockWorkspaceAgent, + chatId: "chat-detail", + workspaceOptions: [ + { + id: MockWorkspace.id, + name: MockWorkspace.name, + owner_name: MockWorkspace.owner_name, + organization_id: MockWorkspace.organization_id, + }, + ], + selectedWorkspaceId: MockWorkspace.id, + onWorkspaceChange: fn(), + }, + parameters: { + viewport: { defaultViewport: "mobile1" }, + chromatic: { viewports: [375] }, + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + const moreOptionsButton = canvas.getByRole("button", { + name: "More options", + }); + expect(moreOptionsButton).toBeEnabled(); + await userEvent.click(moreOptionsButton); + + const attachWorkspaceButton = ( + await body.findByText("Attach workspace") + ).closest("button"); + if (!(attachWorkspaceButton instanceof HTMLButtonElement)) { + throw new Error("Expected Attach workspace to be a button."); + } + expect(attachWorkspaceButton).toBeEnabled(); + await userEvent.click(attachWorkspaceButton); + + const workspaceMatches = await body.findAllByText(MockWorkspace.name); + const selectedWorkspaceOption = workspaceMatches.at(-1); + if (!(selectedWorkspaceOption instanceof HTMLElement)) { + throw new Error("Expected workspace option to render."); + } + await userEvent.click(selectedWorkspaceOption); + + expect(args.onWorkspaceChange).toHaveBeenCalledWith(null); + }, +}; + const confluenceMCP = makeMCPServer({ id: "mcp-confluence", display_name: "Confluence Cloud", diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index ce03c71dd1..6ddafd1fa8 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -211,10 +211,12 @@ const BadgeDismissButton: FC<{ type="button" onClick={onClick} disabled={isDisabled} - className="ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0.5 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-content-secondary" + className="group -mx-1 -my-1 inline-flex size-5 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0 text-content-secondary disabled:cursor-not-allowed disabled:opacity-50" aria-label={ariaLabel} > - + + + ); @@ -233,18 +235,28 @@ const ToolBadge: FC<{ return ( - - {badge.statusIcon} - {badge.name} - + + {badge.statusIcon} + {badge.name} + + {onRemoveWorkspace && ( + + )} + {badge.statusLabel} @@ -491,10 +503,12 @@ export const AgentChatInput: FC = ({ const selectedWorkspace = workspaceOptions?.find( (ws) => ws.id === selectedWorkspaceId, ); + const canUseWorkspacePicker = + Boolean(onWorkspaceChange) && !isWorkspaceLoading; + const linkedWorkspaceId = workspace?.id ?? attachedWorkspace?.id; const shouldShowSelectedWorkspaceBadge = selectedWorkspace - ? Boolean(onWorkspaceChange) && - selectedWorkspace.id !== attachedWorkspace?.id + ? selectedWorkspace.id !== linkedWorkspaceId : false; const enabledMcpServers = mcpServers?.filter((s) => s.enabled) ?? []; @@ -529,6 +543,9 @@ export const AgentChatInput: FC = ({ const overflowBadges = allBadges.slice(visibleCount); const handleRemoveWorkspace = () => onWorkspaceChange?.(null); + const removeWorkspaceHandler = onWorkspaceChange + ? handleRemoveWorkspace + : undefined; const handleRemoveMcp = (serverId: string) => handleMcpToggle(serverId, false); @@ -1128,7 +1145,9 @@ export const AgentChatInput: FC = ({ variant="subtle" size="icon" className="size-7 shrink-0 rounded-full [&>svg]:!size-icon-sm [&>svg]:p-0" - disabled={isDisabled && !agentSetupNotice} + disabled={ + isDisabled && !agentSetupNotice && !canUseWorkspacePicker + } aria-label="More options" > @@ -1197,7 +1216,7 @@ export const AgentChatInput: FC = ({ (isBelowMdViewport() ? ( - - - - {statusLabel} - - + > + + + {workspace.name} + + + + + + + {statusLabel} + + + = ({ View Workspace + {onRemoveWorkspace && ( + <> + + + + Detach workspace + + + )} ); diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx index 4793ee0172..3a7a35b352 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx @@ -63,7 +63,7 @@ test("add 3 hours to deadline", async () => { await user.click(addButton); await user.click(addButton); await screen.findByText( - `Shutdown time for "Test-Workspace" updated successfully.`, + `Shutdown time for "test-workspace" updated successfully.`, ); expect(await screen.findByText("Stop in 6 hours")).toBeInTheDocument(); @@ -91,7 +91,7 @@ test("remove 2 hours to deadline", async () => { await user.click(subButton); await user.click(subButton); await screen.findByText( - `Shutdown time for "Test-Workspace" updated successfully.`, + `Shutdown time for "test-workspace" updated successfully.`, ); expect(await screen.findByText("Stop in an hour")).toBeInTheDocument(); @@ -119,7 +119,7 @@ test("rollback to previous deadline on error", async () => { await user.click(addButton); await user.click(addButton); await screen.findByText( - `Failed to update shutdown time for "Test-Workspace". Please try again.`, + `Failed to update shutdown time for "test-workspace". Please try again.`, ); // In case of an error, the schedule message should remain unchanged expect(screen.getByText(initialScheduleMessage)).toBeInTheDocument(); @@ -140,7 +140,7 @@ test("request is only sent once when clicking multiple times", async () => { await user.click(addButton); await user.click(addButton); await screen.findByText( - `Shutdown time for "Test-Workspace" updated successfully.`, + `Shutdown time for "test-workspace" updated successfully.`, ); expect(updateDeadlineSpy).toHaveBeenCalledTimes(1); }); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index 0abfbcc092..9b20d3e0ed 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -287,7 +287,7 @@ describe("WorkspaceSchedulePage", () => { await user.click(submitButton); const notification = await screen.findByText( - `Schedule for workspace "Test-Workspace" updated successfully.`, + `Schedule for workspace "test-workspace" updated successfully.`, ); expect(notification).toBeInTheDocument(); @@ -320,7 +320,7 @@ describe("WorkspaceSchedulePage", () => { await user.click(submitButton); const notification = await screen.findByText( - `Schedule for workspace "Test-Workspace" updated successfully.`, + `Schedule for workspace "test-workspace" updated successfully.`, ); expect(notification).toBeInTheDocument(); @@ -345,7 +345,7 @@ describe("WorkspaceSchedulePage", () => { await user.click(submitButton); const notification = await screen.findByText( - `Schedule for workspace "Test-Workspace" updated successfully.`, + `Schedule for workspace "test-workspace" updated successfully.`, ); expect(notification).toBeInTheDocument(); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8682a63ed8..f85f3d4753 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1558,7 +1558,7 @@ export const MockBuilds = [ export const MockWorkspace: TypesGen.Workspace = { id: "test-workspace", - name: "Test-Workspace", + name: "test-workspace", created_at: "", updated_at: "", template_id: MockTemplate.id,