From bddb41bb278e192f5e8c921e7ac3126e771166c2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:58:37 +0000 Subject: [PATCH 01/29] fix(site): prefer permitted organization for chat creation --- .../components/AgentCreateForm.stories.tsx | 146 ++++++++++-------- .../AgentsPage/components/AgentCreateForm.tsx | 78 ++++------ 2 files changed, 108 insertions(+), 116 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index ada70dee89396..eb1d3fced58e9 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { delay } from "msw"; import { expect, fn, @@ -9,7 +10,7 @@ import { within, } from "storybook/test"; import { API } from "#/api/api"; -import { permittedOrganizationsKey } from "#/api/queries/organizations"; +import { permittedOrganizations } from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog"; import { MockChatModelConfig } from "#/testHelpers/chatModels"; @@ -23,10 +24,10 @@ import { getReasoningEffortForModel, saveReasoningEffortForModel, } from "../utils/reasoningEffort"; -import { AgentCreateForm } from "./AgentCreateForm"; +import { AgentCreateForm, emptyInputStorageKey } from "./AgentCreateForm"; -const permittedOrgsKey = permittedOrganizationsKey({ - object: { resource_type: "chat", owner_id: "me" }, +const chatCreateOrganizationsQuery = permittedOrganizations({ + object: { resource_type: "chat" }, action: "create", }); @@ -131,19 +132,27 @@ type Story = StoryObj; const defaultArgs = meta.args; -const mockPermittedOrganizations = (permissions: Record) => { +const mockPermittedOrganizations = ( + permissions: Record, + delayMs = 0, +) => { spyOn(API, "getOrganizations").mockResolvedValue([ MockDefaultOrganization, MockOrganization2, ]); - spyOn(API, "checkAuthorization").mockResolvedValue(permissions); + spyOn(API, "checkAuthorization").mockImplementation(async () => { + if (delayMs > 0) { + await delay(delayMs); + } + return permissions; + }); }; export const Default: Story = {}; const submitMessage = async (canvasElement: HTMLElement, message: string) => { const canvas = within(canvasElement); - const input = canvas.getByTestId("chat-message-input"); + const input = canvas.getByRole("textbox", { name: "Chat message" }); await userEvent.click(input); await userEvent.keyboard(message); await userEvent.click(canvas.getByRole("button", { name: "Send" })); @@ -894,25 +903,76 @@ export const WithOrganizationPicker: Story = { organizations: [MockDefaultOrganization, MockOrganization2], queries: [ { - key: permittedOrgsKey, - data: [MockDefaultOrganization, MockOrganization2], + key: chatCreateOrganizationsQuery.queryKey, + data: [MockOrganization2, MockDefaultOrganization], }, ], }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Verify the org picker rendered (component didn't crash). - await waitFor(() => { - expect(canvas.getByTestId("compact-org-selector")).toBeInTheDocument(); + const organizationPicker = canvas.getByRole("button", { + name: "Organization: My Organization", }); - // Type into the chat input to trigger re-renders. If the - // permittedOrgs fallback is referentially unstable, this - // causes a render cascade that hits React's update limit. - const input = canvas.getByTestId("chat-message-input"); + await expect(organizationPicker).toBeVisible(); + + const input = canvas.getByRole("textbox", { name: "Chat message" }); await userEvent.click(input); await userEvent.keyboard("hello world"); - // The org picker should still be present after typing. - expect(canvas.getByTestId("compact-org-selector")).toBeInTheDocument(); + await expect( + canvas.getByRole("button", { + name: "Organization: My Organization", + }), + ).toBeVisible(); + }, +}; + +export const RestrictedMultiOrganizationUser: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + queries: [ + { + key: chatCreateOrganizationsQuery.queryKey, + data: [MockOrganization2], + }, + ], + }, + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + }, + play: async ({ canvasElement, args }) => { + await submitMessage(canvasElement, "test message"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: MockOrganization2.id, + }), + ); + }); + }, +}; + +export const DelayedOrganizationAuthorization: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + beforeEach: () => { + localStorage.setItem(emptyInputStorageKey, "draft message"); + mockPermittedOrganizations( + { + [MockDefaultOrganization.id]: true, + [MockOrganization2.id]: true, + }, + 1_500, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const sendButton = canvas.getByRole("button", { name: "Send" }); + await expect(sendButton).toBeDisabled(); + await waitFor(() => expect(sendButton).toBeEnabled(), { timeout: 3_000 }); }, }; @@ -922,7 +982,7 @@ export const OrgPickerTightSpacing: Story = { organizations: [MockDefaultOrganization, MockOrganization2], queries: [ { - key: permittedOrgsKey, + key: chatCreateOrganizationsQuery.queryKey, data: [MockDefaultOrganization, MockOrganization2], }, ], @@ -1008,8 +1068,6 @@ export const PermittedOrgsResolvesToEmpty: Story = { parameters: { showOrganizations: true, organizations: [MockDefaultOrganization, MockOrganization2], - // Deliberately do not pre-seed permittedOrgsKey. Let the - // mocked API calls drive the async permission resolution. }, args: { ...defaultArgs, @@ -1103,49 +1161,3 @@ export const PermittedOrgsResolvesToSubset: Story = { expect(options.organizationId).toBe(MockOrganization2.id); }, }; - -/** - * Member-scoped roles like agents-access grant chat:create only on - * chats the user owns, so the per-org check must carry owner context - * for the picker to render. - */ -export const MemberScopedPermissionsShowOrgPicker: Story = { - parameters: { - showOrganizations: true, - organizations: [MockDefaultOrganization, MockOrganization2], - }, - beforeEach: () => { - spyOn(API, "getOrganizations").mockResolvedValue([ - MockDefaultOrganization, - MockOrganization2, - ]); - spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) => - Object.fromEntries( - Object.entries(checks).map(([id, check]) => [ - id, - check.object.owner_id === "me", - ]), - ), - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const picker = await canvas.findByRole( - "button", - { name: /^Organization:/ }, - { timeout: 3000 }, - ); - await userEvent.click(picker); - await screen.findByRole("option", { - name: MockDefaultOrganization.display_name, - }); - await userEvent.click( - screen.getByRole("option", { name: MockOrganization2.display_name }), - ); - expect( - canvas.getByRole("button", { - name: `Organization: ${MockOrganization2.display_name}`, - }), - ).toBeInTheDocument(); - }, -}; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 51032010ddab0..aed962e3f7a78 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -302,11 +302,27 @@ export const AgentCreateForm: FC = ({ }, ); const [selectedOrg, setSelectedOrg] = useState( - initialOrg ?? null, + null, ); const [pendingOrgChange, setPendingOrgChange] = useState(null); - const organizationId = selectedOrg?.id ?? ""; + const permittedOrgsQuery = useQuery({ + ...permittedOrganizations({ + object: { resource_type: "chat" }, + action: "create", + }), + enabled: showOrganizations, + }); + const permittedOrgs = permittedOrgsQuery.data ?? organizations; + const effectiveOrg = + selectedOrg && permittedOrgs.some((org) => org.id === selectedOrg.id) + ? selectedOrg + : (permittedOrgs.find((org) => org.is_default) ?? + permittedOrgs[0] ?? + initialOrg ?? + null); + const organizationId = effectiveOrg?.id ?? ""; + const previousEffectiveOrgId = useRef(effectiveOrg?.id); const [planModeEnabled, setPlanModeEnabled] = useState(false); const hasModelOptions = modelOptions.length > 0; const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog); @@ -388,8 +404,8 @@ export const AgentCreateForm: FC = ({ // enough to warrant pagination, this should switch to a // server-side organization: query filter. const filteredWorkspaces = - showOrganizations && selectedOrg - ? workspaceOptions.filter((ws) => ws.organization_id === selectedOrg.id) + showOrganizations && effectiveOrg + ? workspaceOptions.filter((ws) => ws.organization_id === effectiveOrg.id) : workspaceOptions; const effectiveWorkspaceId = @@ -459,49 +475,17 @@ export const AgentCreateForm: FC = ({ } }; - const permittedOrgsQuery = useQuery({ - ...permittedOrganizations({ - object: { resource_type: "chat", owner_id: "me" }, - action: "create", - }), - enabled: showOrganizations, - }); - const permittedOrgs = permittedOrgsQuery.data ?? organizations; - - // Reconcile selectedOrg when permission filtering removes it. - // Only pure state setters run during render; side effects - // (localStorage, blob URL cleanup) run in the effect below. - const [prevPermittedOrgs, setPrevPermittedOrgs] = useState(permittedOrgs); - const [orgWasAdjusted, setOrgWasAdjusted] = useState(false); - if (permittedOrgs !== prevPermittedOrgs) { - setPrevPermittedOrgs(permittedOrgs); - if (selectedOrg && !permittedOrgs.some((o) => o.id === selectedOrg.id)) { - // Fall back through: first permitted org, then the - // dashboard default. Never null out selectedOrg. - // organizationId must always be a valid UUID for the - // create-chat request. - const nextOrg = permittedOrgs[0] ?? initialOrg ?? null; - setSelectedOrg(nextOrg); - if (nextOrg?.id !== selectedOrg.id) { - setOrgWasAdjusted(true); - } - } - } - - // Clean up workspace and attachment state after a programmatic - // org change from permission filtering. These calls have side - // effects (localStorage, blob URL revocation) that must not - // run during render. - const onOrgAdjusted = useEffectEvent(() => { + const resetOrgScopedState = useEffectEvent(() => { handleWorkspaceChange(null); resetAttachments(); }); useEffect(() => { - if (orgWasAdjusted) { - setOrgWasAdjusted(false); - onOrgAdjusted(); + if (previousEffectiveOrgId.current === effectiveOrg?.id) { + return; } - }, [orgWasAdjusted]); + previousEffectiveOrgId.current = effectiveOrg?.id; + resetOrgScopedState(); + }, [effectiveOrg?.id]); return ( <> @@ -544,17 +528,14 @@ export const AgentCreateForm: FC = ({ )} {showOrganizations && permittedOrgs.length > 1 && ( { - const orgChanged = newOrg.id !== selectedOrg?.id; + const orgChanged = newOrg.id !== effectiveOrg?.id; if (orgChanged && attachments.length > 0) { setPendingOrgChange(newOrg); return; } - if (orgChanged) { - handleWorkspaceChange(null); - } setSelectedOrg(newOrg); }} /> @@ -566,6 +547,7 @@ export const AgentCreateForm: FC = ({ isDisabled={ isCreating || isForbidden || + (showOrganizations && permittedOrgsQuery.isLoading) || isPersonalModelOverridesLoading || !hasModelOptions || Boolean(aiGatewayDisabled) @@ -622,8 +604,6 @@ export const AgentCreateForm: FC = ({ hideCancel={false} confirmText="Continue" onConfirm={() => { - resetAttachments(); - handleWorkspaceChange(null); setSelectedOrg(pendingOrgChange); setPendingOrgChange(null); }} From b6f2a63b69ff79f2c2740083575151e8cebedb4d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:14:11 +0000 Subject: [PATCH 02/29] fix(site): keep user-driven org cleanup in event handlers --- .../pages/AgentsPage/components/AgentCreateForm.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index aed962e3f7a78..49b6d216edc26 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -479,6 +479,7 @@ export const AgentCreateForm: FC = ({ handleWorkspaceChange(null); resetAttachments(); }); + // Permission updates can change effectiveOrg without invoking the selection handlers. useEffect(() => { if (previousEffectiveOrgId.current === effectiveOrg?.id) { return; @@ -536,6 +537,10 @@ export const AgentCreateForm: FC = ({ setPendingOrgChange(newOrg); return; } + if (orgChanged) { + previousEffectiveOrgId.current = newOrg.id; + handleWorkspaceChange(null); + } setSelectedOrg(newOrg); }} /> @@ -604,6 +609,12 @@ export const AgentCreateForm: FC = ({ hideCancel={false} confirmText="Continue" onConfirm={() => { + if (!pendingOrgChange) { + return; + } + previousEffectiveOrgId.current = pendingOrgChange.id; + resetAttachments(); + handleWorkspaceChange(null); setSelectedOrg(pendingOrgChange); setPendingOrgChange(null); }} From a7e078f68eb8628df91f72c4c776bcd934c4f2c4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:51:20 +0000 Subject: [PATCH 03/29] fix(site): scope chat permitted-org check to the current user --- .../components/AgentCreateForm.stories.tsx | 26 ++++++++++++++----- .../AgentsPage/components/AgentCreateForm.tsx | 5 +++- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index eb1d3fced58e9..2f010fc7b9045 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -27,7 +27,7 @@ import { import { AgentCreateForm, emptyInputStorageKey } from "./AgentCreateForm"; const chatCreateOrganizationsQuery = permittedOrganizations({ - object: { resource_type: "chat" }, + object: { resource_type: "chat", owner_id: "me" }, action: "create", }); @@ -930,12 +930,24 @@ export const RestrictedMultiOrganizationUser: Story = { parameters: { showOrganizations: true, organizations: [MockDefaultOrganization, MockOrganization2], - queries: [ - { - key: chatCreateOrganizationsQuery.queryKey, - data: [MockOrganization2], - }, - ], + }, + beforeEach: () => { + spyOn(API, "getOrganizations").mockResolvedValue([ + MockDefaultOrganization, + MockOrganization2, + ]); + // Mirrors backend RBAC for roles like agents-access: chat:create + // is granted at member (owner) scope, so a check without + // owner_id "me" is denied in every org. + spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) => + Object.fromEntries( + Object.entries(checks).map(([id, check]) => [ + id, + check.object.owner_id === "me" && + check.object.organization_id === MockOrganization2.id, + ]), + ), + ); }, args: { ...defaultArgs, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 49b6d216edc26..1483a8768348d 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -308,7 +308,10 @@ export const AgentCreateForm: FC = ({ useState(null); const permittedOrgsQuery = useQuery({ ...permittedOrganizations({ - object: { resource_type: "chat" }, + // owner_id scopes the check to the current user; roles like + // agents-access grant chat:create only at member (owner) scope, + // which an ownerless check can never match. + object: { resource_type: "chat", owner_id: "me" }, action: "create", }), enabled: showOrganizations, From bcfe72abcbc6788a4fb4122a5c75b0cdc22536b5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:07:36 +0000 Subject: [PATCH 04/29] chore(site/src/pages/AgentsPage): tighten authcheck comments --- .../pages/AgentsPage/components/AgentCreateForm.stories.tsx | 4 +--- site/src/pages/AgentsPage/components/AgentCreateForm.tsx | 5 ++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 2f010fc7b9045..3146ffa64d4d9 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -936,9 +936,7 @@ export const RestrictedMultiOrganizationUser: Story = { MockDefaultOrganization, MockOrganization2, ]); - // Mirrors backend RBAC for roles like agents-access: chat:create - // is granted at member (owner) scope, so a check without - // owner_id "me" is denied in every org. + // Model agents-access: "me" supplies the owner for member-scoped chat:create. spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) => Object.fromEntries( Object.entries(checks).map(([id, check]) => [ diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 1483a8768348d..f147f303d1b6d 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -308,9 +308,8 @@ export const AgentCreateForm: FC = ({ useState(null); const permittedOrgsQuery = useQuery({ ...permittedOrganizations({ - // owner_id scopes the check to the current user; roles like - // agents-access grant chat:create only at member (owner) scope, - // which an ownerless check can never match. + // agents-access grants chat:create only at member scope. "me" is + // replaced with the caller ID so that permission can match. object: { resource_type: "chat", owner_id: "me" }, action: "create", }), From 887c7bef39e4b0ce022706cdae5364ae83fe52d4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:26:17 +0000 Subject: [PATCH 05/29] fix(site): derive chat workspace selection instead of effect-driven resets --- .../components/AgentCreateForm.stories.tsx | 69 +++++++++++++++++++ .../AgentsPage/components/AgentCreateForm.tsx | 59 ++++------------ 2 files changed, 82 insertions(+), 46 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 3146ffa64d4d9..1b7f7cd1b0366 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -963,6 +963,75 @@ export const RestrictedMultiOrganizationUser: Story = { }, }; +export const RestrictedUserKeepsPersistedWorkspace: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + workspaceOptions: [ + { + ...MockWorkspace, + id: "ws-permitted-org", + name: "permitted-workspace", + organization_id: MockOrganization2.id, + }, + ], + workspaceCount: 1, + }, + beforeEach: () => { + localStorage.setItem("agents.selected-workspace-id", "ws-permitted-org"); + mockPermittedOrganizations({ + [MockDefaultOrganization.id]: false, + [MockOrganization2.id]: true, + }); + }, + play: async ({ canvasElement, args }) => { + await submitMessage(canvasElement, "test message"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: MockOrganization2.id, + workspaceId: "ws-permitted-org", + }), + ); + }); + }, +}; + +export const LoadingWorkspacesNeverSubmitsStoredWorkspace: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + workspaceOptions: [], + isWorkspacesLoading: true, + }, + beforeEach: () => { + localStorage.setItem("agents.selected-workspace-id", "ws-default-org"); + mockPermittedOrganizations({ + [MockDefaultOrganization.id]: false, + [MockOrganization2.id]: true, + }); + }, + play: async ({ canvasElement, args }) => { + await submitMessage(canvasElement, "test message"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: MockOrganization2.id, + workspaceId: undefined, + }), + ); + }); + }, +}; + export const DelayedOrganizationAuthorization: Story = { parameters: { showOrganizations: true, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index f147f303d1b6d..05d817c02115d 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect, useEffectEvent, useRef, useState } from "react"; +import { type FC, useEffect, useRef, useState } from "react"; import { useQuery } from "react-query"; import { toast } from "sonner"; import { isApiError } from "#/api/errors"; @@ -271,35 +271,12 @@ export const AgentCreateForm: FC = ({ : undefined; const initialOrg = organizations.find((o) => o.is_default) ?? organizations[0]; + // effectiveWorkspaceId nulls a stored selection outside the effective org's + // filtered workspace list without deleting it. Preserve the stored value + // because the permitted-organizations query may resolve after mount and + // change the effective org. const [selectedWorkspaceId, setSelectedWorkspaceId] = useState( - () => { - const stored = localStorage.getItem(selectedWorkspaceIdStorageKey); - if (!stored) return null; - - // The stored value is kept optimistically until workspaces - // load. effectiveWorkspaceId (computed after render) drops - // it if it doesn't match the current org's workspaces. - if (workspaceOptions.length === 0) return stored; - - // Validate the stored workspace still exists and belongs - // to the initial org. Without this, a workspace from a - // previously selected org persists across sessions and - // gets submitted even though it's hidden from the picker. - const workspace = workspaceOptions.find((ws) => ws.id === stored); - if (!workspace) { - localStorage.removeItem(selectedWorkspaceIdStorageKey); - return null; - } - if ( - showOrganizations && - initialOrg && - workspace.organization_id !== initialOrg.id - ) { - localStorage.removeItem(selectedWorkspaceIdStorageKey); - return null; - } - return stored; - }, + () => localStorage.getItem(selectedWorkspaceIdStorageKey), ); const [selectedOrg, setSelectedOrg] = useState( null, @@ -324,7 +301,6 @@ export const AgentCreateForm: FC = ({ initialOrg ?? null); const organizationId = effectiveOrg?.id ?? ""; - const previousEffectiveOrgId = useRef(effectiveOrg?.id); const [planModeEnabled, setPlanModeEnabled] = useState(false); const hasModelOptions = modelOptions.length > 0; const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog); @@ -416,13 +392,19 @@ export const AgentCreateForm: FC = ({ filteredWorkspaces.some((ws) => ws.id === selectedWorkspaceId)) ? selectedWorkspaceId : null; + // While the list loads, effectiveWorkspaceId is display-only: a + // stored workspace may belong to an org the user cannot chat in, + // so only a workspace confirmed in the effective org is submitted. + const submittableWorkspaceId = isWorkspacesLoading + ? null + : effectiveWorkspaceId; const handleSend = async (message: string, fileIDs?: string[]) => { submitDraft(); await onCreateChat({ message, fileIDs, - workspaceId: effectiveWorkspaceId ?? undefined, + workspaceId: submittableWorkspaceId ?? undefined, model: submittedModel, reasoningEffort: effectiveReasoningEffort, organizationId, @@ -477,19 +459,6 @@ export const AgentCreateForm: FC = ({ } }; - const resetOrgScopedState = useEffectEvent(() => { - handleWorkspaceChange(null); - resetAttachments(); - }); - // Permission updates can change effectiveOrg without invoking the selection handlers. - useEffect(() => { - if (previousEffectiveOrgId.current === effectiveOrg?.id) { - return; - } - previousEffectiveOrgId.current = effectiveOrg?.id; - resetOrgScopedState(); - }, [effectiveOrg?.id]); - return ( <>
@@ -540,7 +509,6 @@ export const AgentCreateForm: FC = ({ return; } if (orgChanged) { - previousEffectiveOrgId.current = newOrg.id; handleWorkspaceChange(null); } setSelectedOrg(newOrg); @@ -614,7 +582,6 @@ export const AgentCreateForm: FC = ({ if (!pendingOrgChange) { return; } - previousEffectiveOrgId.current = pendingOrgChange.id; resetAttachments(); handleWorkspaceChange(null); setSelectedOrg(pendingOrgChange); From d3f35d6cf81b9d4040764ffd8fb845091903e448 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:42:54 +0000 Subject: [PATCH 06/29] fix(site): defer chat attachment restore and sends until permitted orgs settle --- .../components/AgentCreateForm.stories.tsx | 57 +++++++++++++++++++ .../AgentsPage/components/AgentCreateForm.tsx | 18 ++++-- .../AgentsPage/hooks/useFileAttachments.ts | 31 +++++----- 3 files changed, 87 insertions(+), 19 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 1b7f7cd1b0366..2f9015f5aff00 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1001,6 +1001,63 @@ export const RestrictedUserKeepsPersistedWorkspace: Story = { }, }; +export const RestrictedUserKeepsPersistedAttachments: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + beforeEach: () => { + localStorage.clear(); + localStorage.setItem( + "agents.persisted-attachments", + JSON.stringify([ + { + fileId: "file-permitted-org", + fileName: "notes.txt", + fileType: "text/plain", + lastModified: 1700000000000, + organizationId: MockOrganization2.id, + }, + ]), + ); + mockPermittedOrganizations({ + [MockDefaultOrganization.id]: false, + [MockOrganization2.id]: true, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByLabelText("Remove notes.txt")).toBeInTheDocument(); + }); + const stored = localStorage.getItem("agents.persisted-attachments"); + expect(stored).toContain("file-permitted-org"); + }, +}; + +export const OrganizationAuthorizationFailure: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + beforeEach: () => { + localStorage.clear(); + localStorage.setItem(emptyInputStorageKey, "draft message"); + spyOn(API, "getOrganizations").mockResolvedValue([ + MockDefaultOrganization, + MockOrganization2, + ]); + spyOn(API, "checkAuthorization").mockRejectedValue( + new Error("authorization check failed"), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findAllByText(/authorization check failed/i); + expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); + }, +}; + export const LoadingWorkspacesNeverSubmitsStoredWorkspace: Story = { parameters: { showOrganizations: true, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 05d817c02115d..6168ee8a4378d 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -293,6 +293,11 @@ export const AgentCreateForm: FC = ({ enabled: showOrganizations, }); const permittedOrgs = permittedOrgsQuery.data ?? organizations; + // Until the permitted query produces data (still loading, or failed), + // the org selection is provisional: block sending and attachment + // restoration so nothing acts on an org the user may not have. + const orgSelectionSettled = + !showOrganizations || permittedOrgsQuery.data !== undefined; const effectiveOrg = selectedOrg && permittedOrgs.some((org) => org.id === selectedOrg.id) ? selectedOrg @@ -427,10 +432,13 @@ export const AgentCreateForm: FC = ({ handleAttach, handleRemoveAttachment, resetAttachments, - } = useFileAttachments(organizationId || undefined, { - persist: true, - provider: getProviderForModelOption(modelOptions, selectedModel), - }); + } = useFileAttachments( + orgSelectionSettled ? organizationId || undefined : undefined, + { + persist: true, + provider: getProviderForModelOption(modelOptions, selectedModel), + }, + ); const handleSendWithAttachments = async (message: string) => { const fileIds: string[] = []; @@ -522,7 +530,7 @@ export const AgentCreateForm: FC = ({ isDisabled={ isCreating || isForbidden || - (showOrganizations && permittedOrgsQuery.isLoading) || + !orgSelectionSettled || isPersonalModelOverridesLoading || !hasModelOptions || Boolean(aiGatewayDisabled) diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index eb6098c631225..2ace6a5d18442 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -53,8 +53,7 @@ function restorePersistedAttachments(currentOrgId: string): { previewUrls: Map; } { // Skip when org ID isn't loaded yet so we don't prune valid - // entries. The initializer runs once, so callers must wait for - // the org ID before mounting. + // entries; restoration is deferred until the org is known. if (!currentOrgId) { return { attachments: [], @@ -192,19 +191,23 @@ export function useFileAttachments( providerRef.current = provider; }, [provider]); - const [restored] = useState(() => - persist - ? restorePersistedAttachments(organizationId ?? "") - : { - attachments: [] as File[], - uploadStates: new Map(), - previewUrls: new Map(), - }, + const [attachments, setAttachments] = useState([]); + const [uploadStates, setUploadStates] = useState( + () => new Map(), ); - - const [attachments, setAttachments] = useState(restored.attachments); - const [uploadStates, setUploadStates] = useState(restored.uploadStates); - const [previewUrls, setPreviewUrls] = useState(restored.previewUrls); + const [previewUrls, setPreviewUrls] = useState(() => new Map()); + // Restore lazily on the first render with a known org rather than + // at mount: the caller's org can be provisional until permission + // checks resolve, and restoring with the wrong org prunes valid + // entries from storage. + const [hasRestored, setHasRestored] = useState(!persist); + if (!hasRestored && organizationId) { + setHasRestored(true); + const restored = restorePersistedAttachments(organizationId); + setAttachments(restored.attachments); + setUploadStates(restored.uploadStates); + setPreviewUrls(restored.previewUrls); + } const [textContents, setTextContents] = useState( () => new Map(), ); From f27db49a8fad19babe0d650a574340a61178ec60 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:57:59 +0000 Subject: [PATCH 07/29] fix(site): block chat send without a permitted org and rescope attachments on org change --- .../components/AgentCreateForm.stories.tsx | 29 ++------ .../AgentsPage/components/AgentCreateForm.tsx | 6 +- .../hooks/useFileAttachments.test.ts | 69 +++++++++++++++++++ .../AgentsPage/hooks/useFileAttachments.ts | 38 ++++++++-- 4 files changed, 113 insertions(+), 29 deletions(-) create mode 100644 site/src/pages/AgentsPage/hooks/useFileAttachments.test.ts diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 2f9015f5aff00..1e3e882b9abff 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1218,35 +1218,16 @@ export const PermittedOrgsResolvesToEmpty: Story = { play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - // Wait for the permitted orgs query to resolve. The org picker - // should disappear since no org is permitted. + // No permitted org anywhere: chat creation must be blocked, not + // fall back to the dashboard default org. await waitFor( () => { - expect( - canvas.queryByTestId("compact-org-selector"), - ).not.toBeInTheDocument(); + expect(canvas.getByText(/don't have permission/i)).toBeInTheDocument(); }, { timeout: 3000 }, ); - - // Type a message and submit the form. - const input = canvas.getByTestId("chat-message-input"); - await userEvent.click(input); - await userEvent.keyboard("test message"); - await userEvent.click(canvas.getByRole("button", { name: "Send" })); - - // Verify onCreateChat was called with a non-empty organizationId. - await waitFor(() => { - expect(args.onCreateChat).toHaveBeenCalled(); - }); - const options = (args.onCreateChat as ReturnType).mock - .calls[0]?.[0] as { organizationId: string } | undefined; - if (!options) { - throw new Error("Expected onCreateChat to receive options"); - } - expect(options.organizationId).not.toBe(""); - // It should fall back to the default org from the dashboard. - expect(options.organizationId).toBe(MockDefaultOrganization.id); + expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); + expect(args.onCreateChat).not.toHaveBeenCalled(); }, }; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 6168ee8a4378d..dd6e1a277b6ba 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -298,6 +298,10 @@ export const AgentCreateForm: FC = ({ // restoration so nothing acts on an org the user may not have. const orgSelectionSettled = !showOrganizations || permittedOrgsQuery.data !== undefined; + // A resolved-but-empty permitted set means chat creation is denied + // everywhere; effectiveOrg's dashboard fallback must not be sendable. + const noPermittedOrgs = + showOrganizations && permittedOrgsQuery.data?.length === 0; const effectiveOrg = selectedOrg && permittedOrgs.some((org) => org.id === selectedOrg.id) ? selectedOrg @@ -376,7 +380,7 @@ export const AgentCreateForm: FC = ({ saveReasoningEffortForModel(selectedModel, value); }; - const isForbidden = !canCreateChat; + const isForbidden = !canCreateChat || noPermittedOrgs; // Filter workspaces by the selected organization. We use // client-side filtering of the full "owner:me" fetch rather diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.ts new file mode 100644 index 0000000000000..132dd6baabdaf --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.ts @@ -0,0 +1,69 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + persistedAttachmentsStorageKey, + useFileAttachments, +} from "./useFileAttachments"; + +const persistEntry = (fileId: string, fileName: string, orgId: string) => ({ + fileId, + fileName, + fileType: "text/plain", + lastModified: 1000, + organizationId: orgId, +}); + +const uploadedFileIds = ( + result: ReturnType, +): string[] => + [...result.uploadStates.values()].flatMap((state) => + state.status === "uploaded" && state.fileId ? [state.fileId] : [], + ); + +describe("useFileAttachments org scoping", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("defers restoration until the org is known", () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-b", "b.txt", "org-b")]), + ); + const { result, rerender } = renderHook( + ({ orgId }: { orgId: string | undefined }) => + useFileAttachments(orgId, { persist: true }), + { initialProps: { orgId: undefined as string | undefined } }, + ); + + expect(result.current.attachments).toHaveLength(0); + expect(localStorage.getItem(persistedAttachmentsStorageKey)).toContain( + "file-b", + ); + + rerender({ orgId: "org-b" }); + expect(uploadedFileIds(result.current)).toStrictEqual(["file-b"]); + }); + + it("drops another org's attachments when the org changes", async () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), + ); + const { result, rerender } = renderHook( + ({ orgId }: { orgId: string | undefined }) => + useFileAttachments(orgId, { persist: true }), + { initialProps: { orgId: "org-a" as string | undefined } }, + ); + expect(uploadedFileIds(result.current)).toStrictEqual(["file-a"]); + + // A send after this switch must not carry org-a's file IDs. + rerender({ orgId: "org-b" }); + await waitFor(() => { + expect(uploadedFileIds(result.current)).toStrictEqual([]); + }); + expect( + localStorage.getItem(persistedAttachmentsStorageKey) ?? "", + ).not.toContain("file-a"); + }); +}); diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index 2ace6a5d18442..f1e3ee74f6ef3 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -199,10 +199,13 @@ export function useFileAttachments( // Restore lazily on the first render with a known org rather than // at mount: the caller's org can be provisional until permission // checks resolve, and restoring with the wrong org prunes valid - // entries from storage. - const [hasRestored, setHasRestored] = useState(!persist); - if (!hasRestored && organizationId) { - setHasRestored(true); + // entries from storage. stateOrgId records which org the current + // attachment state belongs to. + const [stateOrgId, setStateOrgId] = useState( + persist ? null : "", + ); + if (persist && stateOrgId === null && organizationId) { + setStateOrgId(organizationId); const restored = restorePersistedAttachments(organizationId); setAttachments(restored.attachments); setUploadStates(restored.uploadStates); @@ -276,6 +279,33 @@ export function useFileAttachments( // file can't be resurrected. WeakSet lets entries get GC'd. const abandonedResizesRef = useRef>(new WeakSet()); + // A permission refetch can change the caller's org without any user + // action. Attachments belong to the org they were uploaded to, so + // drop them (revoking blob previews) and restore the new org's + // persisted entries instead of sending stale file IDs cross-org. + const adoptOrganization = useEffectEvent((orgId: string) => { + for (const file of attachments) { + abandonedResizesRef.current.add(file); + } + revokePreviewUrls(); + setTextContents(new Map()); + setStateOrgId(orgId); + const restored = restorePersistedAttachments(orgId); + setAttachments(restored.attachments); + setUploadStates(restored.uploadStates); + setPreviewUrls(restored.previewUrls); + }); + useEffect(() => { + if ( + persist && + stateOrgId && + organizationId && + stateOrgId !== organizationId + ) { + adoptOrganization(organizationId); + } + }, [persist, stateOrgId, organizationId]); + type AttachItem = { file: File; needsResize: boolean }; const processResizes = async ( From fa5bf743c04751f5fe52f91e5447f868da305d88 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:06:58 +0000 Subject: [PATCH 08/29] fix(site): hide stale attachment state during org adoption and tighten comments --- .../components/AgentCreateForm.stories.tsx | 3 -- .../AgentsPage/components/AgentCreateForm.tsx | 9 +++-- ...ts.test.ts => useFileAttachments.test.tsx} | 35 ++++++++++++++++-- .../AgentsPage/hooks/useFileAttachments.ts | 36 +++++++++++-------- 4 files changed, 57 insertions(+), 26 deletions(-) rename site/src/pages/AgentsPage/hooks/{useFileAttachments.test.ts => useFileAttachments.test.tsx} (63%) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 1e3e882b9abff..d62bc942f2868 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1217,9 +1217,6 @@ export const PermittedOrgsResolvesToEmpty: Story = { }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - - // No permitted org anywhere: chat creation must be blocked, not - // fall back to the dashboard default org. await waitFor( () => { expect(canvas.getByText(/don't have permission/i)).toBeInTheDocument(); diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index dd6e1a277b6ba..1818eb15d1032 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -293,13 +293,12 @@ export const AgentCreateForm: FC = ({ enabled: showOrganizations, }); const permittedOrgs = permittedOrgsQuery.data ?? organizations; - // Until the permitted query produces data (still loading, or failed), - // the org selection is provisional: block sending and attachment - // restoration so nothing acts on an org the user may not have. + // Treat the dashboard org as provisional until permissions resolve so + // sends and persisted attachments cannot use an unpermitted org. const orgSelectionSettled = !showOrganizations || permittedOrgsQuery.data !== undefined; - // A resolved-but-empty permitted set means chat creation is denied - // everywhere; effectiveOrg's dashboard fallback must not be sendable. + // Prevent effectiveOrg's dashboard fallback from bypassing an empty + // permitted set. const noPermittedOrgs = showOrganizations && permittedOrgsQuery.data?.length === 0; const effectiveOrg = diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx similarity index 63% rename from site/src/pages/AgentsPage/hooks/useFileAttachments.test.ts rename to site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx index 132dd6baabdaf..c359de496732a 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx @@ -1,4 +1,5 @@ -import { renderHook, waitFor } from "@testing-library/react"; +import { render, renderHook, waitFor } from "@testing-library/react"; +import type { FC } from "react"; import { beforeEach, describe, expect, it } from "vitest"; import { persistedAttachmentsStorageKey, @@ -56,8 +57,6 @@ describe("useFileAttachments org scoping", () => { { initialProps: { orgId: "org-a" as string | undefined } }, ); expect(uploadedFileIds(result.current)).toStrictEqual(["file-a"]); - - // A send after this switch must not carry org-a's file IDs. rerender({ orgId: "org-b" }); await waitFor(() => { expect(uploadedFileIds(result.current)).toStrictEqual([]); @@ -66,4 +65,34 @@ describe("useFileAttachments org scoping", () => { localStorage.getItem(persistedAttachmentsStorageKey) ?? "", ).not.toContain("file-a"); }); + + it("never exposes the previous org's file IDs in any render", async () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), + ); + // Log the hook output of every render, including the + // intermediate commit between the org changing and the + // adoption effect running; that window must expose nothing. + const renderLog: { orgId: string; fileIds: string[] }[] = []; + const Probe: FC<{ orgId: string }> = ({ orgId }) => { + const result = useFileAttachments(orgId, { persist: true }); + renderLog.push({ orgId, fileIds: uploadedFileIds(result) }); + return null; + }; + const { rerender } = render(); + expect(renderLog.at(-1)).toStrictEqual({ + orgId: "org-a", + fileIds: ["file-a"], + }); + + rerender(); + await waitFor(() => { + expect(renderLog.at(-1)?.fileIds).toStrictEqual([]); + }); + const leaked = renderLog.filter( + (entry) => entry.orgId === "org-b" && entry.fileIds.includes("file-a"), + ); + expect(leaked).toStrictEqual([]); + }); }); diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index f1e3ee74f6ef3..08fb50334229e 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -52,8 +52,7 @@ function restorePersistedAttachments(currentOrgId: string): { uploadStates: Map; previewUrls: Map; } { - // Skip when org ID isn't loaded yet so we don't prune valid - // entries; restoration is deferred until the org is known. + // An unknown org must not prune entries persisted for the eventual org. if (!currentOrgId) { return { attachments: [], @@ -196,11 +195,9 @@ export function useFileAttachments( () => new Map(), ); const [previewUrls, setPreviewUrls] = useState(() => new Map()); - // Restore lazily on the first render with a known org rather than - // at mount: the caller's org can be provisional until permission - // checks resolve, and restoring with the wrong org prunes valid - // entries from storage. stateOrgId records which org the current - // attachment state belongs to. + // Delay restoration until an org is supplied. A provisional org would + // prune entries for the eventual org; stateOrgId tracks which org owns + // the in-memory attachment state. const [stateOrgId, setStateOrgId] = useState( persist ? null : "", ); @@ -279,10 +276,9 @@ export function useFileAttachments( // file can't be resurrected. WeakSet lets entries get GC'd. const abandonedResizesRef = useRef>(new WeakSet()); - // A permission refetch can change the caller's org without any user - // action. Attachments belong to the org they were uploaded to, so - // drop them (revoking blob previews) and restore the new org's - // persisted entries instead of sending stale file IDs cross-org. + // Permission refetches can change the caller's org without user action. + // Replace org-scoped attachment state so stale file IDs cannot be sent + // to another org. const adoptOrganization = useEffectEvent((orgId: string) => { for (const file of attachments) { abandonedResizesRef.current.add(file); @@ -541,11 +537,21 @@ export function useFileAttachments( } }; + // Between a permission-driven org change committing and the + // adoption effect running, state still belongs to the previous + // org. Expose it as empty so a send in that window cannot pair + // the old org's file IDs with the new org. + const orgMismatch = + persist && + stateOrgId !== null && + Boolean(organizationId) && + stateOrgId !== organizationId; + return { - attachments, - textContents, - uploadStates, - previewUrls, + attachments: orgMismatch ? [] : attachments, + textContents: orgMismatch ? new Map() : textContents, + uploadStates: orgMismatch ? new Map() : uploadStates, + previewUrls: orgMismatch ? new Map() : previewUrls, handleAttach, handleRemoveAttachment, startUpload, From d6bf2d2035ab88c69fb9a3fbd2bae455df5559ca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:15:29 +0000 Subject: [PATCH 09/29] fix(site): keep attachment state org-less when no org is permitted --- .../components/AgentCreateForm.stories.tsx | 18 ++++++++++++++++++ .../AgentsPage/components/AgentCreateForm.tsx | 7 ++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index d62bc942f2868..652121e789c0e 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1210,6 +1210,21 @@ export const PermittedOrgsResolvesToEmpty: Story = { onCreateChat: fn().mockResolvedValue(undefined), }, beforeEach: () => { + localStorage.clear(); + // Another org's persisted attachment must survive a visit while + // the user has no chat permission anywhere. + localStorage.setItem( + "agents.persisted-attachments", + JSON.stringify([ + { + fileId: "file-other-org", + fileName: "keep.txt", + fileType: "text/plain", + lastModified: 1000, + organizationId: MockOrganization2.id, + }, + ]), + ); mockPermittedOrganizations({ [MockDefaultOrganization.id]: false, [MockOrganization2.id]: false, @@ -1225,6 +1240,9 @@ export const PermittedOrgsResolvesToEmpty: Story = { ); expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); expect(args.onCreateChat).not.toHaveBeenCalled(); + expect( + localStorage.getItem("agents.persisted-attachments") ?? "", + ).toContain("file-other-org"); }, }; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 1818eb15d1032..e9b3aa95c1c82 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -436,7 +436,12 @@ export const AgentCreateForm: FC = ({ handleRemoveAttachment, resetAttachments, } = useFileAttachments( - orgSelectionSettled ? organizationId || undefined : undefined, + // With no permitted org, effectiveOrg falls back to an org the + // user cannot chat in; restoring against it would prune other + // orgs' persisted attachments. + orgSelectionSettled && !noPermittedOrgs + ? organizationId || undefined + : undefined, { persist: true, provider: getProviderForModelOption(modelOptions, selectedModel), From 0181799886d9be0b62375652c681ace38bb42546 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:23:01 +0000 Subject: [PATCH 10/29] refactor(site/src/pages/AgentsPage): drop type assertions in attachment hook test --- .../hooks/useFileAttachments.test.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx index c359de496732a..e6e3b0492fe42 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx @@ -21,6 +21,13 @@ const uploadedFileIds = ( state.status === "uploaded" && state.fileId ? [state.fileId] : [], ); +const renderAttachments = (initialProps: { orgId: string | undefined }) => + renderHook( + ({ orgId }: { orgId: string | undefined }) => + useFileAttachments(orgId, { persist: true }), + { initialProps }, + ); + describe("useFileAttachments org scoping", () => { beforeEach(() => { localStorage.clear(); @@ -31,11 +38,7 @@ describe("useFileAttachments org scoping", () => { persistedAttachmentsStorageKey, JSON.stringify([persistEntry("file-b", "b.txt", "org-b")]), ); - const { result, rerender } = renderHook( - ({ orgId }: { orgId: string | undefined }) => - useFileAttachments(orgId, { persist: true }), - { initialProps: { orgId: undefined as string | undefined } }, - ); + const { result, rerender } = renderAttachments({ orgId: undefined }); expect(result.current.attachments).toHaveLength(0); expect(localStorage.getItem(persistedAttachmentsStorageKey)).toContain( @@ -51,11 +54,7 @@ describe("useFileAttachments org scoping", () => { persistedAttachmentsStorageKey, JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), ); - const { result, rerender } = renderHook( - ({ orgId }: { orgId: string | undefined }) => - useFileAttachments(orgId, { persist: true }), - { initialProps: { orgId: "org-a" as string | undefined } }, - ); + const { result, rerender } = renderAttachments({ orgId: "org-a" }); expect(uploadedFileIds(result.current)).toStrictEqual(["file-a"]); rerender({ orgId: "org-b" }); await waitFor(() => { From 21756f9763c7123af4d4fba9b087cbda2df3d695 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:43:28 +0000 Subject: [PATCH 11/29] fix(site/src/pages/AgentsPage): ignore file drops until org authorization settles --- .../components/AgentCreateForm.stories.tsx | 24 +++++++++++++++++++ .../AgentsPage/components/AgentCreateForm.tsx | 7 +++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 652121e789c0e..4e4be0728a20c 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1108,7 +1108,31 @@ export const DelayedOrganizationAuthorization: Story = { const canvas = within(canvasElement); const sendButton = canvas.getByRole("button", { name: "Send" }); await expect(sendButton).toBeDisabled(); + // dispatchEvent returns false when a handler accepted the drop + // via preventDefault, giving a race-free accepted/ignored signal. + const dropFile = (name: string): boolean => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(new File(["hello"], name, { type: "text/plain" })); + return canvas.getByTestId("chat-composer").dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + }; + // While the check is pending the composer must ignore drops: with + // no org context the file cannot upload, and post-settlement + // restoration would silently discard it. + expect(dropFile("drop.txt")).toBe(true); + expect(canvas.queryByLabelText("Remove drop.txt")).not.toBeInTheDocument(); await waitFor(() => expect(sendButton).toBeEnabled(), { timeout: 3_000 }); + // Positive control: once settled the same drop is accepted and + // attaches, so the pending-state assertions exercised a real path. + expect(dropFile("after.txt")).toBe(false); + await waitFor(() => + expect(canvas.getByLabelText("Remove after.txt")).toBeInTheDocument(), + ); }, }; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index e9b3aa95c1c82..81a2aa6921540 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -558,7 +558,12 @@ export const AgentCreateForm: FC = ({ planModeEnabled={planModeEnabled} onPlanModeToggle={setPlanModeEnabled} attachments={attachments} - onAttach={handleAttach} + // Until the attachment hook has a real org, a dropped or + // pasted file cannot upload and would be discarded by + // restoration once the org settles. + onAttach={ + orgSelectionSettled && !noPermittedOrgs ? handleAttach : undefined + } onRemoveAttachment={handleRemoveAttachment} uploadStates={uploadStates} previewUrls={previewUrls} From 2a8ab8c03802fa7375212e04aaa68d68dfb7aca6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:53:09 +0000 Subject: [PATCH 12/29] fix(site/src/pages/AgentsPage): move attachment restoration out of render --- .../hooks/useFileAttachments.test.tsx | 23 +++++++++++++++++- .../AgentsPage/hooks/useFileAttachments.ts | 24 ++++++------------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx index e6e3b0492fe42..1aa319a2db437 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx @@ -1,5 +1,5 @@ import { render, renderHook, waitFor } from "@testing-library/react"; -import type { FC } from "react"; +import { type FC, Suspense } from "react"; import { beforeEach, describe, expect, it } from "vitest"; import { persistedAttachmentsStorageKey, @@ -94,4 +94,25 @@ describe("useFileAttachments org scoping", () => { ); expect(leaked).toStrictEqual([]); }); + + it("does not prune storage during a render that never commits", () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), + ); + // Suspending after the hook call abandons the render before + // commit, the same discard concurrent rendering can perform. + const Suspender: FC<{ orgId: string }> = ({ orgId }) => { + useFileAttachments(orgId, { persist: true }); + throw new Promise(() => {}); + }; + render( + + + , + ); + expect(localStorage.getItem(persistedAttachmentsStorageKey)).toContain( + "file-a", + ); + }); }); diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index 08fb50334229e..fe9aedb33e4af 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -195,19 +195,12 @@ export function useFileAttachments( () => new Map(), ); const [previewUrls, setPreviewUrls] = useState(() => new Map()); - // Delay restoration until an org is supplied. A provisional org would - // prune entries for the eventual org; stateOrgId tracks which org owns - // the in-memory attachment state. + // stateOrgId tracks which org owns the in-memory attachment state. + // It stays null until an org is supplied because restoring against + // a provisional org would prune entries for the eventual org. const [stateOrgId, setStateOrgId] = useState( persist ? null : "", ); - if (persist && stateOrgId === null && organizationId) { - setStateOrgId(organizationId); - const restored = restorePersistedAttachments(organizationId); - setAttachments(restored.attachments); - setUploadStates(restored.uploadStates); - setPreviewUrls(restored.previewUrls); - } const [textContents, setTextContents] = useState( () => new Map(), ); @@ -278,7 +271,9 @@ export function useFileAttachments( // Permission refetches can change the caller's org without user action. // Replace org-scoped attachment state so stale file IDs cannot be sent - // to another org. + // to another org. Runs post-commit (never during render) because + // restorePersistedAttachments prunes other orgs' localStorage entries, + // which must not happen from a render React may abandon. const adoptOrganization = useEffectEvent((orgId: string) => { for (const file of attachments) { abandonedResizesRef.current.add(file); @@ -292,12 +287,7 @@ export function useFileAttachments( setPreviewUrls(restored.previewUrls); }); useEffect(() => { - if ( - persist && - stateOrgId && - organizationId && - stateOrgId !== organizationId - ) { + if (persist && organizationId && stateOrgId !== organizationId) { adoptOrganization(organizationId); } }, [persist, stateOrgId, organizationId]); From 958ff0daa612dcab074528b9ac6b2a09707ca56d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:03:43 +0000 Subject: [PATCH 13/29] fix(site/src/pages/AgentsPage): gate composer on attachment org adoption --- .../AgentsPage/components/AgentCreateForm.tsx | 15 +++++++------ .../hooks/useFileAttachments.test.tsx | 21 +++++++++++++++++++ .../AgentsPage/hooks/useFileAttachments.ts | 9 ++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 81a2aa6921540..2715df491fb57 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -428,6 +428,7 @@ export const AgentCreateForm: FC = ({ }; const { + organizationAdopted, attachments, textContents, uploadStates, @@ -539,6 +540,9 @@ export const AgentCreateForm: FC = ({ isCreating || isForbidden || !orgSelectionSettled || + // Until adoption, a send would omit persisted files + // the hook has not yet restored. + !organizationAdopted || isPersonalModelOverridesLoading || !hasModelOptions || Boolean(aiGatewayDisabled) @@ -558,12 +562,11 @@ export const AgentCreateForm: FC = ({ planModeEnabled={planModeEnabled} onPlanModeToggle={setPlanModeEnabled} attachments={attachments} - // Until the attachment hook has a real org, a dropped or - // pasted file cannot upload and would be discarded by - // restoration once the org settles. - onAttach={ - orgSelectionSettled && !noPermittedOrgs ? handleAttach : undefined - } + // Until the attachment hook has adopted a real org, a + // dropped or pasted file cannot upload and would be + // discarded by restoration when adoption completes. + // Adoption implies the org settled and is permitted. + onAttach={organizationAdopted ? handleAttach : undefined} onRemoveAttachment={handleRemoveAttachment} uploadStates={uploadStates} previewUrls={previewUrls} diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx index 1aa319a2db437..0d18f4f710b82 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx @@ -95,6 +95,27 @@ describe("useFileAttachments org scoping", () => { expect(leaked).toStrictEqual([]); }); + it("reports adoption only after the post-commit effect", () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), + ); + const log: { adopted: boolean; fileIds: string[] }[] = []; + const Probe: FC<{ orgId: string }> = ({ orgId }) => { + const result = useFileAttachments(orgId, { persist: true }); + log.push({ + adopted: result.organizationAdopted, + fileIds: uploadedFileIds(result), + }); + return null; + }; + render(); + // The commit that supplies the org must not report adoption; the + // persisted file is only restored (and sendable) afterwards. + expect(log[0]).toStrictEqual({ adopted: false, fileIds: [] }); + expect(log.at(-1)).toStrictEqual({ adopted: true, fileIds: ["file-a"] }); + }); + it("does not prune storage during a render that never commits", () => { localStorage.setItem( persistedAttachmentsStorageKey, diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index fe9aedb33e4af..326ca80a5f6ac 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -162,6 +162,13 @@ function clearPersistedAttachments() { } interface UseFileAttachmentsReturn { + /** + * True once the in-memory attachment state belongs to the supplied + * organization. Adoption happens in a post-commit effect, so during + * the commit that first supplies (or changes) the org this is false; + * callers should keep attach and send controls disabled until then. + */ + organizationAdopted: boolean; attachments: File[]; textContents: Map; uploadStates: Map; @@ -538,6 +545,8 @@ export function useFileAttachments( stateOrgId !== organizationId; return { + organizationAdopted: + !persist || (Boolean(organizationId) && stateOrgId === organizationId), attachments: orgMismatch ? [] : attachments, textContents: orgMismatch ? new Map() : textContents, uploadStates: orgMismatch ? new Map() : uploadStates, From 531da5ca3da3478885342aee18ddd91bebb1c56a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:13:46 +0000 Subject: [PATCH 14/29] fix(site/src/pages/AgentsPage): discard upload completions from an abandoned org --- .../hooks/useFileAttachments.test.tsx | 31 +++++++++++- .../AgentsPage/hooks/useFileAttachments.ts | 48 +++++++++++-------- 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx index 0d18f4f710b82..fb237154a705a 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx @@ -1,6 +1,7 @@ -import { render, renderHook, waitFor } from "@testing-library/react"; +import { act, render, renderHook, waitFor } from "@testing-library/react"; import { type FC, Suspense } from "react"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { API } from "#/api/api"; import { persistedAttachmentsStorageKey, useFileAttachments, @@ -116,6 +117,32 @@ describe("useFileAttachments org scoping", () => { expect(log.at(-1)).toStrictEqual({ adopted: true, fileIds: ["file-a"] }); }); + it("discards an upload that completes after another org was adopted", async () => { + let resolveUpload!: (value: { id: string }) => void; + vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( + new Promise<{ id: string }>((resolve) => { + resolveUpload = resolve; + }), + ); + const { result, rerender } = renderAttachments({ orgId: "org-a" }); + act(() => { + result.current.startUpload(new File(["x"], "x.txt")); + }); + + rerender({ orgId: "org-b" }); + await waitFor(() => { + expect(result.current.organizationAdopted).toBe(true); + }); + await act(async () => { + resolveUpload({ id: "file-x" }); + }); + + expect(uploadedFileIds(result.current)).toStrictEqual([]); + expect( + localStorage.getItem(persistedAttachmentsStorageKey) ?? "", + ).not.toContain("file-x"); + }); + it("does not prune storage during a render that never commits", () => { localStorage.setItem( persistedAttachmentsStorageKey, diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index 326ca80a5f6ac..c772347d80add 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -223,6 +223,23 @@ export function useFileAttachments( return () => revokePreviewUrls(); }, []); + // Upload completions capture the org they started under. By the time + // they resolve, adoption may have replaced state with another org's; + // applying then would leak an entry into the new org's map and + // persist the file ID under the abandoned org, where a later + // adoption flip could resurrect it. + const commitUploadOutcome = useEffectEvent( + (file: File, uploadOrgId: string, state: UploadState) => { + if (persist && stateOrgId !== uploadOrgId) { + return; + } + setUploadStates((prev) => new Map(prev).set(file, state)); + if (persist && state.status === "uploaded" && state.fileId) { + addPersistedAttachment(file, state.fileId, uploadOrgId); + } + }, + ); + const startUpload = (file: File) => { if (!organizationId) { setUploadStates((prev) => @@ -234,25 +251,17 @@ export function useFileAttachments( return; } - const shouldPersist = persist && Boolean(organizationId); + const uploadOrgId = organizationId; const isImage = file.type.startsWith("image/"); setUploadStates((prev) => new Map(prev).set(file, { status: "uploading" })); void (async () => { try { - const result = await API.experimental.uploadChatFile( - file, - organizationId, - ); - setUploadStates((prev) => - new Map(prev).set(file, { - status: "uploaded", - fileId: result.id, - }), - ); - if (shouldPersist) { - addPersistedAttachment(file, result.id, organizationId!); - } + const result = await API.experimental.uploadChatFile(file, uploadOrgId); + commitUploadOutcome(file, uploadOrgId, { + status: "uploaded", + fileId: result.id, + }); if (isImage) { // Pre-warm the HTTP cache so the timeline can // render the image instantly after send. Text @@ -260,13 +269,10 @@ export function useFileAttachments( void fetch(getChatFileURL(result.id)); } } catch (err: unknown) { - const errorMessage = formatAgentAttachmentUploadError(err); - setUploadStates((prev) => - new Map(prev).set(file, { - status: "error", - error: errorMessage, - }), - ); + commitUploadOutcome(file, uploadOrgId, { + status: "error", + error: formatAgentAttachmentUploadError(err), + }); } })(); }; From f4fa619ad3c96e7a8a4ab9e39ecbf62acaf4ee9d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:21:28 +0000 Subject: [PATCH 15/29] fix(site/src/pages/AgentsPage): invalidate uploads across org round trips --- .../hooks/useFileAttachments.test.tsx | 32 +++++++++++++++++++ .../AgentsPage/hooks/useFileAttachments.ts | 26 +++++++++------ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx index fb237154a705a..ad44419d0e094 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx @@ -143,6 +143,38 @@ describe("useFileAttachments org scoping", () => { ).not.toContain("file-x"); }); + it("discards an upload that spans an org round trip", async () => { + let resolveUpload!: (value: { id: string }) => void; + vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( + new Promise<{ id: string }>((resolve) => { + resolveUpload = resolve; + }), + ); + const { result, rerender } = renderAttachments({ orgId: "org-a" }); + act(() => { + result.current.startUpload(new File(["x"], "x.txt")); + }); + + // A -> B -> A: stateOrgId matches the upload's org again, so only + // the adoption epoch can tell the completion is stale. + rerender({ orgId: "org-b" }); + await waitFor(() => { + expect(result.current.organizationAdopted).toBe(true); + }); + rerender({ orgId: "org-a" }); + await waitFor(() => { + expect(result.current.organizationAdopted).toBe(true); + }); + await act(async () => { + resolveUpload({ id: "file-x" }); + }); + + expect(uploadedFileIds(result.current)).toStrictEqual([]); + expect( + localStorage.getItem(persistedAttachmentsStorageKey) ?? "", + ).not.toContain("file-x"); + }); + it("does not prune storage during a render that never commits", () => { localStorage.setItem( persistedAttachmentsStorageKey, diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index c772347d80add..11738e288b5e6 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -223,14 +223,20 @@ export function useFileAttachments( return () => revokePreviewUrls(); }, []); - // Upload completions capture the org they started under. By the time - // they resolve, adoption may have replaced state with another org's; - // applying then would leak an entry into the new org's map and - // persist the file ID under the abandoned org, where a later - // adoption flip could resurrect it. + // Bumped on every adoption. Upload completions capture the epoch they + // started under and are discarded after any adoption since, even an + // A-to-B-to-A round trip that restores the original stateOrgId; + // applying a stale completion would persist the file ID and let a + // later restoration resurrect an abandoned upload. + const adoptionEpochRef = useRef(0); const commitUploadOutcome = useEffectEvent( - (file: File, uploadOrgId: string, state: UploadState) => { - if (persist && stateOrgId !== uploadOrgId) { + ( + file: File, + uploadOrgId: string, + uploadEpoch: number, + state: UploadState, + ) => { + if (persist && adoptionEpochRef.current !== uploadEpoch) { return; } setUploadStates((prev) => new Map(prev).set(file, state)); @@ -252,13 +258,14 @@ export function useFileAttachments( } const uploadOrgId = organizationId; + const uploadEpoch = adoptionEpochRef.current; const isImage = file.type.startsWith("image/"); setUploadStates((prev) => new Map(prev).set(file, { status: "uploading" })); void (async () => { try { const result = await API.experimental.uploadChatFile(file, uploadOrgId); - commitUploadOutcome(file, uploadOrgId, { + commitUploadOutcome(file, uploadOrgId, uploadEpoch, { status: "uploaded", fileId: result.id, }); @@ -269,7 +276,7 @@ export function useFileAttachments( void fetch(getChatFileURL(result.id)); } } catch (err: unknown) { - commitUploadOutcome(file, uploadOrgId, { + commitUploadOutcome(file, uploadOrgId, uploadEpoch, { status: "error", error: formatAgentAttachmentUploadError(err), }); @@ -288,6 +295,7 @@ export function useFileAttachments( // restorePersistedAttachments prunes other orgs' localStorage entries, // which must not happen from a render React may abandon. const adoptOrganization = useEffectEvent((orgId: string) => { + adoptionEpochRef.current += 1; for (const file of attachments) { abandonedResizesRef.current.add(file); } From 25bebdb16731a34e5f24f40807a6c1fded18b33c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:34:26 +0000 Subject: [PATCH 16/29] fix(site/src/pages/AgentsPage): clear org selection revoked by permission refetch --- .../components/AgentCreateForm.stories.tsx | 84 +++++++++++++++++++ .../AgentsPage/components/AgentCreateForm.tsx | 14 ++++ .../hooks/useFileAttachments.test.tsx | 27 +++--- 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 4e4be0728a20c..d15e32116fcbf 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { delay } from "msw"; +import { useState } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; import { expect, fn, @@ -1136,6 +1138,88 @@ export const DelayedOrganizationAuthorization: Story = { }, }; +// Mutable so the play function can change results between refetches; +// the story-level QueryClient lets it drive those refetches, which the +// preview decorator's client (staleTime: Infinity, inaccessible from +// play) cannot. +const revocablePermissions: Record = {}; +let revocableQueryClient: QueryClient | undefined; + +export const RevokedSelectionDoesNotResurrect: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + decorators: [ + (Story) => { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: Number.POSITIVE_INFINITY, + retry: false, + }, + }, + }), + ); + revocableQueryClient = queryClient; + return ( + + + + ); + }, + ], + beforeEach: () => { + revocablePermissions[MockDefaultOrganization.id] = true; + revocablePermissions[MockOrganization2.id] = true; + spyOn(API, "getOrganizations").mockResolvedValue([ + MockDefaultOrganization, + MockOrganization2, + ]); + spyOn(API, "checkAuthorization").mockImplementation(async () => ({ + ...revocablePermissions, + })); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = await canvas.findByTestId("compact-org-selector"); + await waitFor(() => + expect(trigger).toHaveAccessibleName("Organization: My Organization"), + ); + await userEvent.click(trigger); + await userEvent.click( + await screen.findByRole("option", { name: /My Organization 2/ }), + ); + await waitFor(() => + expect(canvas.getByTestId("compact-org-selector")).toHaveAccessibleName( + "Organization: My Organization 2", + ), + ); + + // A refetch revokes the selected org; with one permitted org + // left, the picker unmounts and the default becomes effective. + revocablePermissions[MockOrganization2.id] = false; + await revocableQueryClient?.invalidateQueries(); + await waitFor(() => + expect( + canvas.queryByTestId("compact-org-selector"), + ).not.toBeInTheDocument(), + ); + + // A later refetch re-permits it. The revoked selection must not + // resurrect and switch orgs without user action. + revocablePermissions[MockOrganization2.id] = true; + await revocableQueryClient?.invalidateQueries(); + await waitFor(() => + expect(canvas.getByTestId("compact-org-selector")).toHaveAccessibleName( + "Organization: My Organization", + ), + ); + }, +}; + export const OrgPickerTightSpacing: Story = { parameters: { showOrganizations: true, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 2715df491fb57..623c29f094261 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -301,6 +301,20 @@ export const AgentCreateForm: FC = ({ // permitted set. const noPermittedOrgs = showOrganizations && permittedOrgsQuery.data?.length === 0; + // Drop an explicit selection the moment a permission refetch + // invalidates it. Left latent, a later refetch that re-permits the + // org would silently switch back without user action, discarding + // the effective org's attachment state. Render-time state + // adjustment, not an effect, per the React "adjusting state when + // props change" pattern; effectiveOrg already ignores the invalid + // selection in this same render. + if ( + selectedOrg && + orgSelectionSettled && + !permittedOrgs.some((org) => org.id === selectedOrg.id) + ) { + setSelectedOrg(null); + } const effectiveOrg = selectedOrg && permittedOrgs.some((org) => org.id === selectedOrg.id) ? selectedOrg diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx index ad44419d0e094..f26c3be1d818e 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx @@ -29,6 +29,19 @@ const renderAttachments = (initialProps: { orgId: string | undefined }) => { initialProps }, ); +const mockDeferredUpload = (): ((value: { id: string }) => void) => { + let resolve: ((value: { id: string }) => void) | undefined; + vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( + new Promise<{ id: string }>((res) => { + resolve = res; + }), + ); + if (!resolve) { + throw new Error("Promise executor did not run synchronously"); + } + return resolve; +}; + describe("useFileAttachments org scoping", () => { beforeEach(() => { localStorage.clear(); @@ -118,12 +131,7 @@ describe("useFileAttachments org scoping", () => { }); it("discards an upload that completes after another org was adopted", async () => { - let resolveUpload!: (value: { id: string }) => void; - vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( - new Promise<{ id: string }>((resolve) => { - resolveUpload = resolve; - }), - ); + const resolveUpload = mockDeferredUpload(); const { result, rerender } = renderAttachments({ orgId: "org-a" }); act(() => { result.current.startUpload(new File(["x"], "x.txt")); @@ -144,12 +152,7 @@ describe("useFileAttachments org scoping", () => { }); it("discards an upload that spans an org round trip", async () => { - let resolveUpload!: (value: { id: string }) => void; - vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( - new Promise<{ id: string }>((resolve) => { - resolveUpload = resolve; - }), - ); + const resolveUpload = mockDeferredUpload(); const { result, rerender } = renderAttachments({ orgId: "org-a" }); act(() => { result.current.startUpload(new File(["x"], "x.txt")); From b09e60d8b5a601cc62778a51e95e325da3981180 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:48:18 +0000 Subject: [PATCH 17/29] fix(site/src/pages/AgentsPage): revalidate org-scoped state on permission refetches --- .../components/AgentCreateForm.stories.tsx | 193 +++++++++++++++--- .../AgentsPage/components/AgentCreateForm.tsx | 74 +++++-- 2 files changed, 216 insertions(+), 51 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index d15e32116fcbf..0c74e258068ef 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import { delay } from "msw"; import { useState } from "react"; import { QueryClient, QueryClientProvider } from "react-query"; @@ -1128,7 +1128,13 @@ export const DelayedOrganizationAuthorization: Story = { // restoration would silently discard it. expect(dropFile("drop.txt")).toBe(true); expect(canvas.queryByLabelText("Remove drop.txt")).not.toBeInTheDocument(); + // The picker must also stay hidden: its pending-state option list + // is the unfiltered dashboard fallback. + expect( + canvas.queryByTestId("compact-org-selector"), + ).not.toBeInTheDocument(); await waitFor(() => expect(sendButton).toBeEnabled(), { timeout: 3_000 }); + await canvas.findByTestId("compact-org-selector"); // Positive control: once settled the same drop is accepted and // attaches, so the pending-state assertions exercised a real path. expect(dropFile("after.txt")).toBe(false); @@ -1138,49 +1144,58 @@ export const DelayedOrganizationAuthorization: Story = { }, }; -// Mutable so the play function can change results between refetches; -// the story-level QueryClient lets it drive those refetches, which the +// Mutable so play functions can change results between refetches; the +// story-level QueryClient lets them drive those refetches, which the // preview decorator's client (staleTime: Infinity, inaccessible from // play) cannot. const revocablePermissions: Record = {}; let revocableQueryClient: QueryClient | undefined; +const withRevocableQueryClient: Decorator = (Story) => { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: Number.POSITIVE_INFINITY, + retry: false, + }, + }, + }), + ); + revocableQueryClient = queryClient; + return ( + + + + ); +}; + +const mockRevocablePermissions = (permissions: Record) => { + for (const key of Object.keys(revocablePermissions)) { + delete revocablePermissions[key]; + } + Object.assign(revocablePermissions, permissions); + spyOn(API, "getOrganizations").mockResolvedValue([ + MockDefaultOrganization, + MockOrganization2, + ]); + spyOn(API, "checkAuthorization").mockImplementation(async () => ({ + ...revocablePermissions, + })); +}; + export const RevokedSelectionDoesNotResurrect: Story = { parameters: { showOrganizations: true, organizations: [MockDefaultOrganization, MockOrganization2], }, - decorators: [ - (Story) => { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: Number.POSITIVE_INFINITY, - retry: false, - }, - }, - }), - ); - revocableQueryClient = queryClient; - return ( - - - - ); - }, - ], + decorators: [withRevocableQueryClient], beforeEach: () => { - revocablePermissions[MockDefaultOrganization.id] = true; - revocablePermissions[MockOrganization2.id] = true; - spyOn(API, "getOrganizations").mockResolvedValue([ - MockDefaultOrganization, - MockOrganization2, - ]); - spyOn(API, "checkAuthorization").mockImplementation(async () => ({ - ...revocablePermissions, - })); + mockRevocablePermissions({ + [MockDefaultOrganization.id]: true, + [MockOrganization2.id]: true, + }); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -1220,6 +1235,118 @@ export const RevokedSelectionDoesNotResurrect: Story = { }, }; +export const RevokedOrgChangeClearsStoredWorkspace: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + decorators: [withRevocableQueryClient], + args: { + ...defaultArgs, + workspaceOptions: [ + { + ...MockWorkspace, + id: "ws-default-org", + name: "default-workspace", + organization_id: MockDefaultOrganization.id, + }, + ], + workspaceCount: 1, + }, + beforeEach: () => { + localStorage.setItem("agents.selected-workspace-id", "ws-default-org"); + mockRevocablePermissions({ + [MockDefaultOrganization.id]: true, + [MockOrganization2.id]: true, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => + expect( + canvas.getByLabelText("Remove workspace default-workspace"), + ).toBeInTheDocument(), + ); + + // A refetch revokes the workspace's org, changing the effective + // org; the stored workspace must be dropped, not just masked. + revocablePermissions[MockDefaultOrganization.id] = false; + await revocableQueryClient?.invalidateQueries(); + await waitFor(() => + expect( + canvas.queryByLabelText("Remove workspace default-workspace"), + ).not.toBeInTheDocument(), + ); + + // Re-permitting the org must not resurrect the workspace. + revocablePermissions[MockDefaultOrganization.id] = true; + await revocableQueryClient?.invalidateQueries(); + await waitFor(() => + expect(canvas.getByTestId("compact-org-selector")).toHaveAccessibleName( + "Organization: My Organization", + ), + ); + expect( + canvas.queryByLabelText("Remove workspace default-workspace"), + ).not.toBeInTheDocument(); + expect(localStorage.getItem("agents.selected-workspace-id")).toBeNull(); + }, +}; + +export const RevokedPendingOrgClosesConfirmDialog: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + decorators: [withRevocableQueryClient], + beforeEach: () => { + localStorage.clear(); + localStorage.setItem( + "agents.persisted-attachments", + JSON.stringify([ + { + fileId: "file-default-org", + fileName: "notes.txt", + fileType: "text/plain", + lastModified: 1700000000000, + organizationId: MockDefaultOrganization.id, + }, + ]), + ); + mockRevocablePermissions({ + [MockDefaultOrganization.id]: true, + [MockOrganization2.id]: true, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await waitFor(() => + expect(canvas.getByLabelText("Remove notes.txt")).toBeInTheDocument(), + ); + await userEvent.click(await canvas.findByTestId("compact-org-selector")); + await userEvent.click( + await screen.findByRole("option", { name: /My Organization 2/ }), + ); + await body.findByText( + "Changing organization will remove your current attachments.", + ); + + // Revoking the pending org while its confirmation dialog is open + // must close the dialog before Continue can destroy attachments. + revocablePermissions[MockOrganization2.id] = false; + await revocableQueryClient?.invalidateQueries(); + await waitFor(() => + expect( + body.queryByText( + "Changing organization will remove your current attachments.", + ), + ).not.toBeInTheDocument(), + ); + expect(canvas.getByLabelText("Remove notes.txt")).toBeInTheDocument(); + }, +}; + export const OrgPickerTightSpacing: Story = { parameters: { showOrganizations: true, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 623c29f094261..f829608420f2f 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -315,6 +315,15 @@ export const AgentCreateForm: FC = ({ ) { setSelectedOrg(null); } + // Same rule for a pending change awaiting confirmation: closing the + // dialog prevents confirming into an org that was just revoked. + if ( + pendingOrgChange && + orgSelectionSettled && + !permittedOrgs.some((org) => org.id === pendingOrgChange.id) + ) { + setPendingOrgChange(null); + } const effectiveOrg = selectedOrg && permittedOrgs.some((org) => org.id === selectedOrg.id) ? selectedOrg @@ -323,6 +332,24 @@ export const AgentCreateForm: FC = ({ initialOrg ?? null); const organizationId = effectiveOrg?.id ?? ""; + // A settled permission refetch that changes the effective org must + // also drop the stored workspace, mirroring user-driven org changes; + // left latent, it would resurrect and submit if the old org became + // effective again. Initial settlement records the org without + // clearing so a persisted workspace survives first load. Render-time + // state adjustment; the storage entry is removed post-commit below. + const [lastSettledOrgId, setLastSettledOrgId] = useState(null); + if (orgSelectionSettled && organizationId !== lastSettledOrgId) { + setLastSettledOrgId(organizationId); + if (lastSettledOrgId !== null) { + setSelectedWorkspaceId(null); + } + } + useEffect(() => { + if (selectedWorkspaceId === null) { + localStorage.removeItem(selectedWorkspaceIdStorageKey); + } + }, [selectedWorkspaceId]); const [planModeEnabled, setPlanModeEnabled] = useState(false); const hasModelOptions = modelOptions.length > 0; const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog); @@ -529,23 +556,28 @@ export const AgentCreateForm: FC = ({ {permittedOrgsQuery.error != null && ( )} - {showOrganizations && permittedOrgs.length > 1 && ( - { - const orgChanged = newOrg.id !== effectiveOrg?.id; - if (orgChanged && attachments.length > 0) { - setPendingOrgChange(newOrg); - return; - } - if (orgChanged) { - handleWorkspaceChange(null); - } - setSelectedOrg(newOrg); - }} - /> - )} + {/* Hidden until settled: before then the list is the + unfiltered dashboard fallback, and selecting a + never-permitted org would destroy workspace state. */} + {showOrganizations && + orgSelectionSettled && + permittedOrgs.length > 1 && ( + { + const orgChanged = newOrg.id !== effectiveOrg?.id; + if (orgChanged && attachments.length > 0) { + setPendingOrgChange(newOrg); + return; + } + if (orgChanged) { + handleWorkspaceChange(null); + } + setSelectedOrg(newOrg); + }} + /> + )} = ({ if (!pendingOrgChange) { return; } + setPendingOrgChange(null); + // A revoking refetch can land after this render's + // closure was created; re-check before destroying + // attachment and workspace state. + if (!permittedOrgs.some((org) => org.id === pendingOrgChange.id)) { + return; + } resetAttachments(); handleWorkspaceChange(null); setSelectedOrg(pendingOrgChange); - setPendingOrgChange(null); }} onClose={() => setPendingOrgChange(null)} /> From f1185d833d34885560133c8d17a12d542f818f0c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:15:36 +0000 Subject: [PATCH 18/29] chore(site/src/pages/AgentsPage): cleanup gate round 6 --- .../components/AgentCreateForm.stories.tsx | 69 +++++++------------ .../AgentsPage/components/AgentCreateForm.tsx | 54 ++++++--------- .../hooks/useFileAttachments.test.tsx | 20 ++---- .../AgentsPage/hooks/useFileAttachments.ts | 31 +++------ 4 files changed, 62 insertions(+), 112 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 0c74e258068ef..68c41ffd4eee6 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -22,6 +22,7 @@ import { MockWorkspace, } from "#/testHelpers/entities"; import { withDashboardProvider } from "#/testHelpers/storybook"; +import { persistedAttachmentsStorageKey } from "../hooks/useFileAttachments"; import { getReasoningEffortForModel, saveReasoningEffortForModel, @@ -1123,13 +1124,11 @@ export const DelayedOrganizationAuthorization: Story = { }), ); }; - // While the check is pending the composer must ignore drops: with - // no org context the file cannot upload, and post-settlement - // restoration would silently discard it. + // Pending authorization leaves attachments without a valid org, so drops + // must be ignored. expect(dropFile("drop.txt")).toBe(true); expect(canvas.queryByLabelText("Remove drop.txt")).not.toBeInTheDocument(); - // The picker must also stay hidden: its pending-state option list - // is the unfiltered dashboard fallback. + // The pending option list is unfiltered, so the picker must stay hidden. expect( canvas.queryByTestId("compact-org-selector"), ).not.toBeInTheDocument(); @@ -1144,10 +1143,9 @@ export const DelayedOrganizationAuthorization: Story = { }, }; -// Mutable so play functions can change results between refetches; the -// story-level QueryClient lets them drive those refetches, which the -// preview decorator's client (staleTime: Infinity, inaccessible from -// play) cannot. +// Mutable permissions let play functions change authorization across refetches. +// The story-local QueryClient exposes those refetches; the preview client's +// instance is inaccessible and uses infinite stale time. const revocablePermissions: Record = {}; let revocableQueryClient: QueryClient | undefined; @@ -1185,17 +1183,23 @@ const mockRevocablePermissions = (permissions: Record) => { })); }; -export const RevokedSelectionDoesNotResurrect: Story = { +const revocableStoryContext = { parameters: { showOrganizations: true, organizations: [MockDefaultOrganization, MockOrganization2], }, decorators: [withRevocableQueryClient], +}; + +const allOrganizationsPermitted = { + [MockDefaultOrganization.id]: true, + [MockOrganization2.id]: true, +}; + +export const RevokedSelectionDoesNotResurrect: Story = { + ...revocableStoryContext, beforeEach: () => { - mockRevocablePermissions({ - [MockDefaultOrganization.id]: true, - [MockOrganization2.id]: true, - }); + mockRevocablePermissions(allOrganizationsPermitted); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -1213,8 +1217,6 @@ export const RevokedSelectionDoesNotResurrect: Story = { ), ); - // A refetch revokes the selected org; with one permitted org - // left, the picker unmounts and the default becomes effective. revocablePermissions[MockOrganization2.id] = false; await revocableQueryClient?.invalidateQueries(); await waitFor(() => @@ -1223,8 +1225,6 @@ export const RevokedSelectionDoesNotResurrect: Story = { ).not.toBeInTheDocument(), ); - // A later refetch re-permits it. The revoked selection must not - // resurrect and switch orgs without user action. revocablePermissions[MockOrganization2.id] = true; await revocableQueryClient?.invalidateQueries(); await waitFor(() => @@ -1236,11 +1236,7 @@ export const RevokedSelectionDoesNotResurrect: Story = { }; export const RevokedOrgChangeClearsStoredWorkspace: Story = { - parameters: { - showOrganizations: true, - organizations: [MockDefaultOrganization, MockOrganization2], - }, - decorators: [withRevocableQueryClient], + ...revocableStoryContext, args: { ...defaultArgs, workspaceOptions: [ @@ -1255,10 +1251,7 @@ export const RevokedOrgChangeClearsStoredWorkspace: Story = { }, beforeEach: () => { localStorage.setItem("agents.selected-workspace-id", "ws-default-org"); - mockRevocablePermissions({ - [MockDefaultOrganization.id]: true, - [MockOrganization2.id]: true, - }); + mockRevocablePermissions(allOrganizationsPermitted); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -1268,8 +1261,6 @@ export const RevokedOrgChangeClearsStoredWorkspace: Story = { ).toBeInTheDocument(), ); - // A refetch revokes the workspace's org, changing the effective - // org; the stored workspace must be dropped, not just masked. revocablePermissions[MockDefaultOrganization.id] = false; await revocableQueryClient?.invalidateQueries(); await waitFor(() => @@ -1278,7 +1269,6 @@ export const RevokedOrgChangeClearsStoredWorkspace: Story = { ).not.toBeInTheDocument(), ); - // Re-permitting the org must not resurrect the workspace. revocablePermissions[MockDefaultOrganization.id] = true; await revocableQueryClient?.invalidateQueries(); await waitFor(() => @@ -1294,15 +1284,11 @@ export const RevokedOrgChangeClearsStoredWorkspace: Story = { }; export const RevokedPendingOrgClosesConfirmDialog: Story = { - parameters: { - showOrganizations: true, - organizations: [MockDefaultOrganization, MockOrganization2], - }, - decorators: [withRevocableQueryClient], + ...revocableStoryContext, beforeEach: () => { localStorage.clear(); localStorage.setItem( - "agents.persisted-attachments", + persistedAttachmentsStorageKey, JSON.stringify([ { fileId: "file-default-org", @@ -1313,10 +1299,7 @@ export const RevokedPendingOrgClosesConfirmDialog: Story = { }, ]), ); - mockRevocablePermissions({ - [MockDefaultOrganization.id]: true, - [MockOrganization2.id]: true, - }); + mockRevocablePermissions(allOrganizationsPermitted); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -1332,8 +1315,6 @@ export const RevokedPendingOrgClosesConfirmDialog: Story = { "Changing organization will remove your current attachments.", ); - // Revoking the pending org while its confirmation dialog is open - // must close the dialog before Continue can destroy attachments. revocablePermissions[MockOrganization2.id] = false; await revocableQueryClient?.invalidateQueries(); await waitFor(() => @@ -1449,7 +1430,7 @@ export const PermittedOrgsResolvesToEmpty: Story = { // Another org's persisted attachment must survive a visit while // the user has no chat permission anywhere. localStorage.setItem( - "agents.persisted-attachments", + persistedAttachmentsStorageKey, JSON.stringify([ { fileId: "file-other-org", @@ -1476,7 +1457,7 @@ export const PermittedOrgsResolvesToEmpty: Story = { expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); expect(args.onCreateChat).not.toHaveBeenCalled(); expect( - localStorage.getItem("agents.persisted-attachments") ?? "", + localStorage.getItem(persistedAttachmentsStorageKey) ?? "", ).toContain("file-other-org"); }, }; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index f829608420f2f..8496cdecc1f6a 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -301,18 +301,13 @@ export const AgentCreateForm: FC = ({ // permitted set. const noPermittedOrgs = showOrganizations && permittedOrgsQuery.data?.length === 0; - // Drop an explicit selection the moment a permission refetch - // invalidates it. Left latent, a later refetch that re-permits the - // org would silently switch back without user action, discarding - // the effective org's attachment state. Render-time state - // adjustment, not an effect, per the React "adjusting state when - // props change" pattern; effectiveOrg already ignores the invalid - // selection in this same render. - if ( - selectedOrg && - orgSelectionSettled && - !permittedOrgs.some((org) => org.id === selectedOrg.id) - ) { + const selectedOrgIsPermitted = + selectedOrg !== null && + permittedOrgs.some((org) => org.id === selectedOrg.id); + // Clear invalid selections during render so re-permission cannot silently + // restore them and switch attachment state. effectiveOrg already ignores + // the invalid selection in this render. + if (selectedOrg && orgSelectionSettled && !selectedOrgIsPermitted) { setSelectedOrg(null); } // Same rule for a pending change awaiting confirmation: closing the @@ -325,19 +320,16 @@ export const AgentCreateForm: FC = ({ setPendingOrgChange(null); } const effectiveOrg = - selectedOrg && permittedOrgs.some((org) => org.id === selectedOrg.id) + selectedOrg && selectedOrgIsPermitted ? selectedOrg : (permittedOrgs.find((org) => org.is_default) ?? permittedOrgs[0] ?? initialOrg ?? null); const organizationId = effectiveOrg?.id ?? ""; - // A settled permission refetch that changes the effective org must - // also drop the stored workspace, mirroring user-driven org changes; - // left latent, it would resurrect and submit if the old org became - // effective again. Initial settlement records the org without - // clearing so a persisted workspace survives first load. Render-time - // state adjustment; the storage entry is removed post-commit below. + // Clear a workspace when a settled permission refetch changes org, but + // preserve it on initial settlement. Render-time adjustment prevents stale + // selection from resurfacing before localStorage is cleared post-commit. const [lastSettledOrgId, setLastSettledOrgId] = useState(null); if (orgSelectionSettled && organizationId !== lastSettledOrgId) { setLastSettledOrgId(organizationId); @@ -478,9 +470,8 @@ export const AgentCreateForm: FC = ({ handleRemoveAttachment, resetAttachments, } = useFileAttachments( - // With no permitted org, effectiveOrg falls back to an org the - // user cannot chat in; restoring against it would prune other - // orgs' persisted attachments. + // Avoid restoring against effectiveOrg's fallback when no org is permitted; + // that would prune attachments persisted for other orgs. orgSelectionSettled && !noPermittedOrgs ? organizationId || undefined : undefined, @@ -556,9 +547,8 @@ export const AgentCreateForm: FC = ({ {permittedOrgsQuery.error != null && ( )} - {/* Hidden until settled: before then the list is the - unfiltered dashboard fallback, and selecting a - never-permitted org would destroy workspace state. */} + {/* The pre-settlement list is the unfiltered dashboard fallback; + selecting from it could destroy existing workspace state. */} {showOrganizations && orgSelectionSettled && permittedOrgs.length > 1 && ( @@ -586,8 +576,7 @@ export const AgentCreateForm: FC = ({ isCreating || isForbidden || !orgSelectionSettled || - // Until adoption, a send would omit persisted files - // the hook has not yet restored. + // Sending before adoption would omit persisted files not yet restored. !organizationAdopted || isPersonalModelOverridesLoading || !hasModelOptions || @@ -608,10 +597,8 @@ export const AgentCreateForm: FC = ({ planModeEnabled={planModeEnabled} onPlanModeToggle={setPlanModeEnabled} attachments={attachments} - // Until the attachment hook has adopted a real org, a - // dropped or pasted file cannot upload and would be - // discarded by restoration when adoption completes. - // Adoption implies the org settled and is permitted. + // Files attached before org adoption cannot upload and would be discarded + // when restoration completes. onAttach={organizationAdopted ? handleAttach : undefined} onRemoveAttachment={handleRemoveAttachment} uploadStates={uploadStates} @@ -653,9 +640,8 @@ export const AgentCreateForm: FC = ({ return; } setPendingOrgChange(null); - // A revoking refetch can land after this render's - // closure was created; re-check before destroying - // attachment and workspace state. + // Recheck authorization because a refetch may revoke the pending org + // after this render created the closure. if (!permittedOrgs.some((org) => org.id === pendingOrgChange.id)) { return; } diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx index f26c3be1d818e..374980e5a2829 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx @@ -2,6 +2,7 @@ import { act, render, renderHook, waitFor } from "@testing-library/react"; import { type FC, Suspense } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { API } from "#/api/api"; +import { createDeferred } from "#/testHelpers/deferred"; import { persistedAttachmentsStorageKey, useFileAttachments, @@ -30,16 +31,11 @@ const renderAttachments = (initialProps: { orgId: string | undefined }) => ); const mockDeferredUpload = (): ((value: { id: string }) => void) => { - let resolve: ((value: { id: string }) => void) | undefined; + const deferred = createDeferred<{ id: string }>(); vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( - new Promise<{ id: string }>((res) => { - resolve = res; - }), + deferred.promise, ); - if (!resolve) { - throw new Error("Promise executor did not run synchronously"); - } - return resolve; + return deferred.resolve; }; describe("useFileAttachments org scoping", () => { @@ -124,8 +120,6 @@ describe("useFileAttachments org scoping", () => { return null; }; render(); - // The commit that supplies the org must not report adoption; the - // persisted file is only restored (and sendable) afterwards. expect(log[0]).toStrictEqual({ adopted: false, fileIds: [] }); expect(log.at(-1)).toStrictEqual({ adopted: true, fileIds: ["file-a"] }); }); @@ -158,8 +152,7 @@ describe("useFileAttachments org scoping", () => { result.current.startUpload(new File(["x"], "x.txt")); }); - // A -> B -> A: stateOrgId matches the upload's org again, so only - // the adoption epoch can tell the completion is stale. + // After A -> B -> A, only the adoption epoch distinguishes the stale upload. rerender({ orgId: "org-b" }); await waitFor(() => { expect(result.current.organizationAdopted).toBe(true); @@ -183,8 +176,7 @@ describe("useFileAttachments org scoping", () => { persistedAttachmentsStorageKey, JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), ); - // Suspending after the hook call abandons the render before - // commit, the same discard concurrent rendering can perform. + // Suspending after the hook runs simulates a render React abandons before commit. const Suspender: FC<{ orgId: string }> = ({ orgId }) => { useFileAttachments(orgId, { persist: true }); throw new Promise(() => {}); diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index 11738e288b5e6..6fbb6662764f8 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -163,10 +163,8 @@ function clearPersistedAttachments() { interface UseFileAttachmentsReturn { /** - * True once the in-memory attachment state belongs to the supplied - * organization. Adoption happens in a post-commit effect, so during - * the commit that first supplies (or changes) the org this is false; - * callers should keep attach and send controls disabled until then. + * True after the post-commit effect assigns in-memory attachment state to + * the supplied organization. Keep attach and send controls disabled until then. */ organizationAdopted: boolean; attachments: File[]; @@ -202,12 +200,9 @@ export function useFileAttachments( () => new Map(), ); const [previewUrls, setPreviewUrls] = useState(() => new Map()); - // stateOrgId tracks which org owns the in-memory attachment state. - // It stays null until an org is supplied because restoring against - // a provisional org would prune entries for the eventual org. - const [stateOrgId, setStateOrgId] = useState( - persist ? null : "", - ); + // Persisted state remains unowned until post-commit org adoption; restoring + // against a provisional org would prune entries for the eventual org. + const [stateOrgId, setStateOrgId] = useState(null); const [textContents, setTextContents] = useState( () => new Map(), ); @@ -223,11 +218,9 @@ export function useFileAttachments( return () => revokePreviewUrls(); }, []); - // Bumped on every adoption. Upload completions capture the epoch they - // started under and are discarded after any adoption since, even an - // A-to-B-to-A round trip that restores the original stateOrgId; - // applying a stale completion would persist the file ID and let a - // later restoration resurrect an abandoned upload. + // Every adoption invalidates older upload completions, including A-to-B-to-A + // round trips where stateOrgId matches again. Otherwise a stale completion + // could persist and later restore an abandoned upload. const adoptionEpochRef = useRef(0); const commitUploadOutcome = useEffectEvent( ( @@ -289,11 +282,9 @@ export function useFileAttachments( // file can't be resurrected. WeakSet lets entries get GC'd. const abandonedResizesRef = useRef>(new WeakSet()); - // Permission refetches can change the caller's org without user action. - // Replace org-scoped attachment state so stale file IDs cannot be sent - // to another org. Runs post-commit (never during render) because - // restorePersistedAttachments prunes other orgs' localStorage entries, - // which must not happen from a render React may abandon. + // Permission refetches can change the org without user action. Replace state + // after commit so stale file IDs cannot cross orgs and an abandoned render + // cannot prune localStorage through restorePersistedAttachments. const adoptOrganization = useEffectEvent((orgId: string) => { adoptionEpochRef.current += 1; for (const file of attachments) { From 8b4fceef160feafc2d637be0b41ebee4b1beb7fe Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:27:19 +0000 Subject: [PATCH 19/29] fix(site/src/pages/AgentsPage): block send until stored workspace validates --- .../components/AgentCreateForm.stories.tsx | 22 +++++++++---------- .../AgentsPage/components/AgentCreateForm.tsx | 14 ++++++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 68c41ffd4eee6..2ea4f691cb280 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1061,7 +1061,7 @@ export const OrganizationAuthorizationFailure: Story = { }, }; -export const LoadingWorkspacesNeverSubmitsStoredWorkspace: Story = { +export const LoadingWorkspacesBlocksSendUntilValidated: Story = { parameters: { showOrganizations: true, organizations: [MockDefaultOrganization, MockOrganization2], @@ -1073,22 +1073,22 @@ export const LoadingWorkspacesNeverSubmitsStoredWorkspace: Story = { isWorkspacesLoading: true, }, beforeEach: () => { + localStorage.setItem(emptyInputStorageKey, "draft message"); localStorage.setItem("agents.selected-workspace-id", "ws-default-org"); mockPermittedOrganizations({ - [MockDefaultOrganization.id]: false, + [MockDefaultOrganization.id]: true, [MockOrganization2.id]: true, }); }, play: async ({ canvasElement, args }) => { - await submitMessage(canvasElement, "test message"); - await waitFor(() => { - expect(args.onCreateChat).toHaveBeenCalledWith( - expect.objectContaining({ - organizationId: MockOrganization2.id, - workspaceId: undefined, - }), - ); - }); + const canvas = within(canvasElement); + // The picker renders only after the permission check settles, so + // once it appears every other Send gate has been decided. + await canvas.findByTestId("compact-org-selector"); + // The stored workspace cannot be validated yet; sending now would + // silently drop the association, so Send stays disabled. + await expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); + expect(args.onCreateChat).not.toHaveBeenCalled(); }, }; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 8496cdecc1f6a..52e34e2e30a36 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -433,19 +433,18 @@ export const AgentCreateForm: FC = ({ filteredWorkspaces.some((ws) => ws.id === selectedWorkspaceId)) ? selectedWorkspaceId : null; - // While the list loads, effectiveWorkspaceId is display-only: a - // stored workspace may belong to an org the user cannot chat in, - // so only a workspace confirmed in the effective org is submitted. - const submittableWorkspaceId = isWorkspacesLoading - ? null - : effectiveWorkspaceId; + // A stored workspace cannot be validated against the effective org + // until the list loads; sending then would silently drop the + // association, so Send stays disabled instead. + const workspaceValidationPending = + selectedWorkspaceId !== null && isWorkspacesLoading; const handleSend = async (message: string, fileIDs?: string[]) => { submitDraft(); await onCreateChat({ message, fileIDs, - workspaceId: submittableWorkspaceId ?? undefined, + workspaceId: effectiveWorkspaceId ?? undefined, model: submittedModel, reasoningEffort: effectiveReasoningEffort, organizationId, @@ -578,6 +577,7 @@ export const AgentCreateForm: FC = ({ !orgSelectionSettled || // Sending before adoption would omit persisted files not yet restored. !organizationAdopted || + workspaceValidationPending || isPersonalModelOverridesLoading || !hasModelOptions || Boolean(aiGatewayDisabled) From a6582b35af52a3737cc2379bb1ca12bd31a5a296 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:36:17 +0000 Subject: [PATCH 20/29] fix(site/src/pages/AgentsPage): keep fallback org across permission refetches --- .../components/AgentCreateForm.stories.tsx | 5 ++++- .../pages/AgentsPage/components/AgentCreateForm.tsx | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 2ea4f691cb280..319c4d2a5af17 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1269,11 +1269,14 @@ export const RevokedOrgChangeClearsStoredWorkspace: Story = { ).not.toBeInTheDocument(), ); + // Re-permitting the default must not switch the form away from + // the fallback org the user is now composing under, nor + // resurrect the cleared workspace. revocablePermissions[MockDefaultOrganization.id] = true; await revocableQueryClient?.invalidateQueries(); await waitFor(() => expect(canvas.getByTestId("compact-org-selector")).toHaveAccessibleName( - "Organization: My Organization", + "Organization: My Organization 2", ), ); expect( diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 52e34e2e30a36..bebdcd331e683 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -327,6 +327,19 @@ export const AgentCreateForm: FC = ({ initialOrg ?? null); const organizationId = effectiveOrg?.id ?? ""; + // Record a settled fallback-derived org as the selection; left null, a + // later refetch that re-permits a revoked default would silently switch + // the form away from the org the user is composing under. Only permitted + // orgs are recorded, otherwise the invalidation above would clear the + // record on the next render and loop. + if ( + orgSelectionSettled && + !selectedOrg && + effectiveOrg && + permittedOrgs.some((org) => org.id === effectiveOrg.id) + ) { + setSelectedOrg(effectiveOrg); + } // Clear a workspace when a settled permission refetch changes org, but // preserve it on initial settlement. Render-time adjustment prevents stale // selection from resurfacing before localStorage is cleared post-commit. From d09c67c097ec81cb0de9e5c217fd881609fb56a3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:44:09 +0000 Subject: [PATCH 21/29] fix(site/src/pages/AgentsPage): preserve stored workspace while no org is permitted --- .../components/AgentCreateForm.stories.tsx | 50 +++++++++++++++++++ .../AgentsPage/components/AgentCreateForm.tsx | 9 +++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 319c4d2a5af17..e576760f1dc01 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1286,6 +1286,56 @@ export const RevokedOrgChangeClearsStoredWorkspace: Story = { }, }; +export const EmptyPermittedSetPreservesStoredWorkspace: Story = { + ...revocableStoryContext, + args: { + ...defaultArgs, + workspaceOptions: [ + { + ...MockWorkspace, + id: "ws-org-2", + name: "org2-workspace", + organization_id: MockOrganization2.id, + }, + ], + workspaceCount: 1, + }, + beforeEach: () => { + localStorage.setItem("agents.selected-workspace-id", "ws-org-2"); + mockRevocablePermissions({ + [MockDefaultOrganization.id]: false, + [MockOrganization2.id]: true, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => + expect( + canvas.getByLabelText("Remove workspace org2-workspace"), + ).toBeInTheDocument(), + ); + + // A refetch that empties the permitted set blocks sending but is + // not an org change; the stored workspace must survive it. + revocablePermissions[MockOrganization2.id] = false; + await revocableQueryClient?.invalidateQueries(); + await waitFor(() => + expect(canvas.getByText(/don't have permission/i)).toBeInTheDocument(), + ); + + revocablePermissions[MockOrganization2.id] = true; + await revocableQueryClient?.invalidateQueries(); + await waitFor(() => + expect( + canvas.getByLabelText("Remove workspace org2-workspace"), + ).toBeInTheDocument(), + ); + expect(localStorage.getItem("agents.selected-workspace-id")).toBe( + "ws-org-2", + ); + }, +}; + export const RevokedPendingOrgClosesConfirmDialog: Story = { ...revocableStoryContext, beforeEach: () => { diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index bebdcd331e683..4f3b0e117e199 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -343,8 +343,15 @@ export const AgentCreateForm: FC = ({ // Clear a workspace when a settled permission refetch changes org, but // preserve it on initial settlement. Render-time adjustment prevents stale // selection from resurfacing before localStorage is cleared post-commit. + // An empty permitted set is skipped: its dashboard fallback is not a real + // org change (sends are blocked), and clearing would permanently delete a + // workspace that should return when the org is re-permitted. const [lastSettledOrgId, setLastSettledOrgId] = useState(null); - if (orgSelectionSettled && organizationId !== lastSettledOrgId) { + if ( + orgSelectionSettled && + !noPermittedOrgs && + organizationId !== lastSettledOrgId + ) { setLastSettledOrgId(organizationId); if (lastSettledOrgId !== null) { setSelectedWorkspaceId(null); From 64a9f7c9cfaecb7fae5d4a0bafe7fb9a0a8ef113 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:04:13 +0000 Subject: [PATCH 22/29] chore(site/src/pages/AgentsPage): cleanup gate round 7 --- .../components/AgentCreateForm.stories.tsx | 35 +++++-------------- .../AgentsPage/components/AgentCreateForm.tsx | 16 +++------ 2 files changed, 13 insertions(+), 38 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index e576760f1dc01..3a7630d5b637e 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1068,7 +1068,6 @@ export const LoadingWorkspacesBlocksSendUntilValidated: Story = { }, args: { ...defaultArgs, - onCreateChat: fn().mockResolvedValue(undefined), workspaceOptions: [], isWorkspacesLoading: true, }, @@ -1080,15 +1079,13 @@ export const LoadingWorkspacesBlocksSendUntilValidated: Story = { [MockOrganization2.id]: true, }); }, - play: async ({ canvasElement, args }) => { + play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // The picker renders only after the permission check settles, so - // once it appears every other Send gate has been decided. - await canvas.findByTestId("compact-org-selector"); - // The stored workspace cannot be validated yet; sending now would - // silently drop the association, so Send stays disabled. + // Wait for permissions to settle before checking workspace validation. + await canvas.findByRole("button", { + name: "Organization: My Organization", + }); await expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); - expect(args.onCreateChat).not.toHaveBeenCalled(); }, }; @@ -1269,9 +1266,6 @@ export const RevokedOrgChangeClearsStoredWorkspace: Story = { ).not.toBeInTheDocument(), ); - // Re-permitting the default must not switch the form away from - // the fallback org the user is now composing under, nor - // resurrect the cleared workspace. revocablePermissions[MockDefaultOrganization.id] = true; await revocableQueryClient?.invalidateQueries(); await waitFor(() => @@ -1298,7 +1292,6 @@ export const EmptyPermittedSetPreservesStoredWorkspace: Story = { organization_id: MockOrganization2.id, }, ], - workspaceCount: 1, }, beforeEach: () => { localStorage.setItem("agents.selected-workspace-id", "ws-org-2"); @@ -1309,27 +1302,15 @@ export const EmptyPermittedSetPreservesStoredWorkspace: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await waitFor(() => - expect( - canvas.getByLabelText("Remove workspace org2-workspace"), - ).toBeInTheDocument(), - ); + await canvas.findByLabelText("Remove workspace org2-workspace"); - // A refetch that empties the permitted set blocks sending but is - // not an org change; the stored workspace must survive it. revocablePermissions[MockOrganization2.id] = false; await revocableQueryClient?.invalidateQueries(); - await waitFor(() => - expect(canvas.getByText(/don't have permission/i)).toBeInTheDocument(), - ); + await canvas.findByText(/don't have permission/i); revocablePermissions[MockOrganization2.id] = true; await revocableQueryClient?.invalidateQueries(); - await waitFor(() => - expect( - canvas.getByLabelText("Remove workspace org2-workspace"), - ).toBeInTheDocument(), - ); + await canvas.findByLabelText("Remove workspace org2-workspace"); expect(localStorage.getItem("agents.selected-workspace-id")).toBe( "ws-org-2", ); diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 4f3b0e117e199..922fbe12ad270 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -327,11 +327,8 @@ export const AgentCreateForm: FC = ({ initialOrg ?? null); const organizationId = effectiveOrg?.id ?? ""; - // Record a settled fallback-derived org as the selection; left null, a - // later refetch that re-permits a revoked default would silently switch - // the form away from the org the user is composing under. Only permitted - // orgs are recorded, otherwise the invalidation above would clear the - // record on the next render and loop. + // Adopt a permitted fallback so later refetches cannot switch the form to a + // re-permitted default. The permission guard also avoids a render loop. if ( orgSelectionSettled && !selectedOrg && @@ -340,12 +337,9 @@ export const AgentCreateForm: FC = ({ ) { setSelectedOrg(effectiveOrg); } - // Clear a workspace when a settled permission refetch changes org, but - // preserve it on initial settlement. Render-time adjustment prevents stale - // selection from resurfacing before localStorage is cleared post-commit. - // An empty permitted set is skipped: its dashboard fallback is not a real - // org change (sends are blocked), and clearing would permanently delete a - // workspace that should return when the org is re-permitted. + // Clear a workspace after a settled org change, before its localStorage value + // is cleared post-commit. An empty permission set has no selectable org, so + // preserve the workspace until its org is re-permitted. const [lastSettledOrgId, setLastSettledOrgId] = useState(null); if ( orgSelectionSettled && From 353b78a017ab2e4bb12a8434ba24757ce91f72ec Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:16:07 +0000 Subject: [PATCH 23/29] fix(site/src/pages/AgentsPage): ignore stale permitted cache when orgs hide --- .../components/AgentCreateForm.stories.tsx | 30 +++++++++++++++++++ .../AgentsPage/components/AgentCreateForm.tsx | 8 ++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 3a7630d5b637e..b5a9d725c5e1f 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1317,6 +1317,36 @@ export const EmptyPermittedSetPreservesStoredWorkspace: Story = { }, }; +export const SingleOrgIgnoresStalePermittedCache: Story = { + parameters: { + // showOrganizations is false (org count dropped to one), but the + // disabled query still holds a cached permitted list naming an + // org the dashboard no longer knows. It must be ignored. + showOrganizations: false, + organizations: [MockDefaultOrganization], + queries: [ + { + key: chatCreateOrganizationsQuery.queryKey, + data: [MockOrganization2], + }, + ], + }, + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + }, + play: async ({ canvasElement, args }) => { + await submitMessage(canvasElement, "test message"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: MockDefaultOrganization.id, + }), + ); + }); + }, +}; + export const RevokedPendingOrgClosesConfirmDialog: Story = { ...revocableStoryContext, beforeEach: () => { diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 922fbe12ad270..588c5c63cdb64 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -292,7 +292,13 @@ export const AgentCreateForm: FC = ({ }), enabled: showOrganizations, }); - const permittedOrgs = permittedOrgsQuery.data ?? organizations; + // A disabled query retains cached data, so once showOrganizations + // turns false (org count dropped to one) the dashboard list is + // authoritative; the stale permitted list could keep a removed org + // selected and submitting. + const permittedOrgs = showOrganizations + ? (permittedOrgsQuery.data ?? organizations) + : organizations; // Treat the dashboard org as provisional until permissions resolve so // sends and persisted attachments cannot use an unpermitted org. const orgSelectionSettled = From cae3925fa7cb8a365a2ed2b85f0d773bf6a47f65 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:29:05 +0000 Subject: [PATCH 24/29] chore(site/src/pages/AgentsPage): cleanup gate round 8 --- .../AgentsPage/components/AgentCreateForm.stories.tsx | 3 --- site/src/pages/AgentsPage/components/AgentCreateForm.tsx | 7 +++---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index b5a9d725c5e1f..ed90e42e2b010 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1319,9 +1319,6 @@ export const EmptyPermittedSetPreservesStoredWorkspace: Story = { export const SingleOrgIgnoresStalePermittedCache: Story = { parameters: { - // showOrganizations is false (org count dropped to one), but the - // disabled query still holds a cached permitted list naming an - // org the dashboard no longer knows. It must be ignored. showOrganizations: false, organizations: [MockDefaultOrganization], queries: [ diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 588c5c63cdb64..9cb83dbf9091c 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -292,10 +292,9 @@ export const AgentCreateForm: FC = ({ }), enabled: showOrganizations, }); - // A disabled query retains cached data, so once showOrganizations - // turns false (org count dropped to one) the dashboard list is - // authoritative; the stale permitted list could keep a removed org - // selected and submitting. + // Disabled queries retain cached data. When the dashboard hides organization + // selection, its organization list is authoritative so a removed org cannot + // remain selected for submission. const permittedOrgs = showOrganizations ? (permittedOrgsQuery.data ?? organizations) : organizations; From fa80a1fb9bb614c6dadeffc3dcfe281b2d0427ab Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:43:11 +0000 Subject: [PATCH 25/29] refactor(site/src/pages/AgentsPage): query org selector by role in new stories --- .../components/AgentCreateForm.stories.tsx | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index ed90e42e2b010..f6053f0ce8c52 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1127,10 +1127,10 @@ export const DelayedOrganizationAuthorization: Story = { expect(canvas.queryByLabelText("Remove drop.txt")).not.toBeInTheDocument(); // The pending option list is unfiltered, so the picker must stay hidden. expect( - canvas.queryByTestId("compact-org-selector"), + canvas.queryByRole("button", { name: /organization/i }), ).not.toBeInTheDocument(); await waitFor(() => expect(sendButton).toBeEnabled(), { timeout: 3_000 }); - await canvas.findByTestId("compact-org-selector"); + await canvas.findByRole("button", { name: /organization/i }); // Positive control: once settled the same drop is accepted and // attaches, so the pending-state assertions exercised a real path. expect(dropFile("after.txt")).toBe(false); @@ -1200,35 +1200,30 @@ export const RevokedSelectionDoesNotResurrect: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const trigger = await canvas.findByTestId("compact-org-selector"); - await waitFor(() => - expect(trigger).toHaveAccessibleName("Organization: My Organization"), - ); + const trigger = await canvas.findByRole("button", { + name: "Organization: My Organization", + }); await userEvent.click(trigger); await userEvent.click( await screen.findByRole("option", { name: /My Organization 2/ }), ); - await waitFor(() => - expect(canvas.getByTestId("compact-org-selector")).toHaveAccessibleName( - "Organization: My Organization 2", - ), - ); + await canvas.findByRole("button", { + name: "Organization: My Organization 2", + }); revocablePermissions[MockOrganization2.id] = false; await revocableQueryClient?.invalidateQueries(); await waitFor(() => expect( - canvas.queryByTestId("compact-org-selector"), + canvas.queryByRole("button", { name: /organization/i }), ).not.toBeInTheDocument(), ); revocablePermissions[MockOrganization2.id] = true; await revocableQueryClient?.invalidateQueries(); - await waitFor(() => - expect(canvas.getByTestId("compact-org-selector")).toHaveAccessibleName( - "Organization: My Organization", - ), - ); + await canvas.findByRole("button", { + name: "Organization: My Organization", + }); }, }; @@ -1268,11 +1263,9 @@ export const RevokedOrgChangeClearsStoredWorkspace: Story = { revocablePermissions[MockDefaultOrganization.id] = true; await revocableQueryClient?.invalidateQueries(); - await waitFor(() => - expect(canvas.getByTestId("compact-org-selector")).toHaveAccessibleName( - "Organization: My Organization 2", - ), - ); + await canvas.findByRole("button", { + name: "Organization: My Organization 2", + }); expect( canvas.queryByLabelText("Remove workspace default-workspace"), ).not.toBeInTheDocument(); @@ -1368,7 +1361,11 @@ export const RevokedPendingOrgClosesConfirmDialog: Story = { await waitFor(() => expect(canvas.getByLabelText("Remove notes.txt")).toBeInTheDocument(), ); - await userEvent.click(await canvas.findByTestId("compact-org-selector")); + await userEvent.click( + await canvas.findByRole("button", { + name: "Organization: My Organization", + }), + ); await userEvent.click( await screen.findByRole("option", { name: /My Organization 2/ }), ); From 3ca42bd5c117ed1bf3ddbc479a6ac71cca1b6003 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:52:04 +0000 Subject: [PATCH 26/29] fix(site/src/pages/AgentsPage): hide attachments when no org is authorized --- .../hooks/useFileAttachments.test.tsx | 26 +++++++++++++++++++ .../AgentsPage/hooks/useFileAttachments.ts | 14 +++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx index 374980e5a2829..9a1d2f963e129 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx @@ -171,6 +171,32 @@ describe("useFileAttachments org scoping", () => { ).not.toContain("file-x"); }); + it("hides attachments when authorization leaves no org", async () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), + ); + const { result, rerender } = renderAttachments({ orgId: "org-a" }); + await waitFor(() => { + expect(uploadedFileIds(result.current)).toStrictEqual(["file-a"]); + }); + + // Losing the org entirely (empty permitted set) must hide the + // previous org's attachments so they cannot be removed, while + // keeping them persisted for when the org returns. + rerender({ orgId: undefined }); + expect(result.current.attachments).toStrictEqual([]); + expect(uploadedFileIds(result.current)).toStrictEqual([]); + expect(localStorage.getItem(persistedAttachmentsStorageKey)).toContain( + "file-a", + ); + + rerender({ orgId: "org-a" }); + await waitFor(() => { + expect(uploadedFileIds(result.current)).toStrictEqual(["file-a"]); + }); + }); + it("does not prune storage during a render that never commits", () => { localStorage.setItem( persistedAttachmentsStorageKey, diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index 6fbb6662764f8..471f1e006176d 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -539,15 +539,13 @@ export function useFileAttachments( } }; - // Between a permission-driven org change committing and the - // adoption effect running, state still belongs to the previous - // org. Expose it as empty so a send in that window cannot pair - // the old org's file IDs with the new org. + // Whenever adopted state does not belong to the current org, expose it + // as empty: between an org change committing and the adoption effect + // running, a send could pair the old org's file IDs with the new org, + // and when authorization leaves no org at all, remove buttons could + // still delete the previous org's persisted attachments. const orgMismatch = - persist && - stateOrgId !== null && - Boolean(organizationId) && - stateOrgId !== organizationId; + persist && stateOrgId !== null && stateOrgId !== organizationId; return { organizationAdopted: From e1a620416cfc4bea33bbc10f2d7a138cffc5f433 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:05:14 +0000 Subject: [PATCH 27/29] fix(site/src/pages/AgentsPage): gate workspace picker until org authorization settles --- .../components/AgentCreateForm.stories.tsx | 17 +++++++++++++++++ .../AgentsPage/components/AgentCreateForm.tsx | 9 ++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index f6053f0ce8c52..38037e0a3a2cc 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1104,10 +1104,27 @@ export const DelayedOrganizationAuthorization: Story = { 1_500, ); }, + args: { + ...defaultArgs, + workspaceOptions: [ + { + ...MockWorkspace, + id: "ws-provisional", + name: "provisional-workspace", + organization_id: MockDefaultOrganization.id, + }, + ], + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const sendButton = canvas.getByRole("button", { name: "Send" }); await expect(sendButton).toBeDisabled(); + // The workspace picker must be unreachable while the check is + // pending: its options come from the provisional fallback org. + // With the workspace callback gated, the whole menu disables. + await expect( + canvas.getByRole("button", { name: "More options" }), + ).toBeDisabled(); // dispatchEvent returns false when a handler accepted the drop // via preventDefault, giving a race-free accepted/ignored signal. const dropFile = (name: string): boolean => { diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 9cb83dbf9091c..269a0446dceda 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -632,7 +632,14 @@ export const AgentCreateForm: FC = ({ onMCPAuthComplete={onMCPAuthComplete} workspaceOptions={filteredWorkspaces} selectedWorkspaceId={effectiveWorkspaceId} - onWorkspaceChange={handleWorkspaceChange} + // Before settlement the options come from the provisional + // fallback org; a pick would overwrite the stored + // workspace with a foreign-org value. + onWorkspaceChange={ + orgSelectionSettled && !noPermittedOrgs + ? handleWorkspaceChange + : undefined + } isWorkspaceLoading={isWorkspacesLoading} canConfigureAgentSetup={canConfigureAgentSetup} providerCount={providerCount} From 6d46cc5eba3050a330d50fc4b098cd3706363da2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:11:53 +0000 Subject: [PATCH 28/29] refactor(site/src/pages/AgentsPage): trim redundant comments --- .../AgentsPage/components/AgentCreateForm.stories.tsx | 3 --- site/src/pages/AgentsPage/components/AgentCreateForm.tsx | 4 +--- .../src/pages/AgentsPage/hooks/useFileAttachments.test.tsx | 3 --- site/src/pages/AgentsPage/hooks/useFileAttachments.ts | 7 ++----- 4 files changed, 3 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 38037e0a3a2cc..e040eb88f0ed0 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1119,9 +1119,6 @@ export const DelayedOrganizationAuthorization: Story = { const canvas = within(canvasElement); const sendButton = canvas.getByRole("button", { name: "Send" }); await expect(sendButton).toBeDisabled(); - // The workspace picker must be unreachable while the check is - // pending: its options come from the provisional fallback org. - // With the workspace callback gated, the whole menu disables. await expect( canvas.getByRole("button", { name: "More options" }), ).toBeDisabled(); diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 269a0446dceda..5b3f382214524 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -632,9 +632,7 @@ export const AgentCreateForm: FC = ({ onMCPAuthComplete={onMCPAuthComplete} workspaceOptions={filteredWorkspaces} selectedWorkspaceId={effectiveWorkspaceId} - // Before settlement the options come from the provisional - // fallback org; a pick would overwrite the stored - // workspace with a foreign-org value. + // Do not persist a workspace until its organization is authorized. onWorkspaceChange={ orgSelectionSettled && !noPermittedOrgs ? handleWorkspaceChange diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx index 9a1d2f963e129..38d82886b3320 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx @@ -181,9 +181,6 @@ describe("useFileAttachments org scoping", () => { expect(uploadedFileIds(result.current)).toStrictEqual(["file-a"]); }); - // Losing the org entirely (empty permitted set) must hide the - // previous org's attachments so they cannot be removed, while - // keeping them persisted for when the org returns. rerender({ orgId: undefined }); expect(result.current.attachments).toStrictEqual([]); expect(uploadedFileIds(result.current)).toStrictEqual([]); diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index 471f1e006176d..b4b9193fb8808 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -539,11 +539,8 @@ export function useFileAttachments( } }; - // Whenever adopted state does not belong to the current org, expose it - // as empty: between an org change committing and the adoption effect - // running, a send could pair the old org's file IDs with the new org, - // and when authorization leaves no org at all, remove buttons could - // still delete the previous org's persisted attachments. + // Hide state that belongs to another organization. Exposing it could send + // stale file IDs or remove persisted attachments from the previous org. const orgMismatch = persist && stateOrgId !== null && stateOrgId !== organizationId; From b4e09aef387d0c47c1eaee54aad423b30423017d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:06:49 +0000 Subject: [PATCH 29/29] test(site/src/pages/AgentsPage): align stories with permittedOrganizationsKey helper from main --- .../components/AgentCreateForm.stories.tsx | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index e040eb88f0ed0..59ed37449314b 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -12,7 +12,7 @@ import { within, } from "storybook/test"; import { API } from "#/api/api"; -import { permittedOrganizations } from "#/api/queries/organizations"; +import { permittedOrganizationsKey } from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog"; import { MockChatModelConfig } from "#/testHelpers/chatModels"; @@ -29,7 +29,7 @@ import { } from "../utils/reasoningEffort"; import { AgentCreateForm, emptyInputStorageKey } from "./AgentCreateForm"; -const chatCreateOrganizationsQuery = permittedOrganizations({ +const permittedOrgsKey = permittedOrganizationsKey({ object: { resource_type: "chat", owner_id: "me" }, action: "create", }); @@ -906,7 +906,7 @@ export const WithOrganizationPicker: Story = { organizations: [MockDefaultOrganization, MockOrganization2], queries: [ { - key: chatCreateOrganizationsQuery.queryKey, + key: permittedOrgsKey, data: [MockOrganization2, MockDefaultOrganization], }, ], @@ -1330,7 +1330,7 @@ export const SingleOrgIgnoresStalePermittedCache: Story = { organizations: [MockDefaultOrganization], queries: [ { - key: chatCreateOrganizationsQuery.queryKey, + key: permittedOrgsKey, data: [MockOrganization2], }, ], @@ -1406,7 +1406,7 @@ export const OrgPickerTightSpacing: Story = { organizations: [MockDefaultOrganization, MockOrganization2], queries: [ { - key: chatCreateOrganizationsQuery.queryKey, + key: permittedOrgsKey, data: [MockDefaultOrganization, MockOrganization2], }, ], @@ -1581,3 +1581,49 @@ export const PermittedOrgsResolvesToSubset: Story = { expect(options.organizationId).toBe(MockOrganization2.id); }, }; + +/** + * Member-scoped roles like agents-access grant chat:create only on + * chats the user owns, so the per-org check must carry owner context + * for the picker to render. + */ +export const MemberScopedPermissionsShowOrgPicker: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + beforeEach: () => { + spyOn(API, "getOrganizations").mockResolvedValue([ + MockDefaultOrganization, + MockOrganization2, + ]); + spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) => + Object.fromEntries( + Object.entries(checks).map(([id, check]) => [ + id, + check.object.owner_id === "me", + ]), + ), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const picker = await canvas.findByRole( + "button", + { name: /^Organization:/ }, + { timeout: 3000 }, + ); + await userEvent.click(picker); + await screen.findByRole("option", { + name: MockDefaultOrganization.display_name, + }); + await userEvent.click( + screen.getByRole("option", { name: MockOrganization2.display_name }), + ); + expect( + canvas.getByRole("button", { + name: `Organization: ${MockOrganization2.display_name}`, + }), + ).toBeInTheDocument(); + }, +};