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:
Stopped workspace:
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() ? (