fix(site): use readonly Organization[] and explicit is_default lookups (#24288)

`OrganizationAutocomplete` declared `options: Organization[]` (mutable),
but `useDashboard().organizations` returns `readonly Organization[]`.
Callers forced to spread to satisfy the type even though the component
never mutates the array.

While fixing this, found two more issues with how organization context
is resolved:

- `AgentCreateForm`, `ChatPageContent`, and `OrganizationRedirect` used
`organizations[0]` assuming the default org is first — nothing
guarantees that ordering.
- `ChatPageInput` was guessing the org from `useDashboard()` for file
uploads on existing chats, even though the chat already has
`organization_id`. In multi-org deployments, uploads could land in the
wrong org.

Changes:
- Widen `OrganizationAutocomplete` `options` to `readonly
Organization[]`
- Remove unnecessary `[...organizations]` spread in `AgentCreateForm`
- Replace all `organizations[0]` with `.find(o => o.is_default) ??
organizations[0]` for users not in the default org
- Eliminate `[...organizations].sort()` allocation in
`OrganizationRedirect` with direct `.find()` lookups (equivalent
priority: editable default → any editable → viewable default → any
viewable)
- Thread `chat.organization_id` from `AgentChatPage` →
`AgentChatPageView` → `ChatPageInput` so file uploads use the chat's
actual org instead of guessing

Closes #24285

> 🤖 PR initially created by Claude Opus 4.6
This commit is contained in:
Cian Johnston
2026-04-13 14:28:48 +00:00
committed by GitHub
parent 69917b4516
commit 11fe4972b6
7 changed files with 30 additions and 23 deletions
@@ -21,7 +21,7 @@ import {
type OrganizationAutocompleteProps = {
value: Organization | null;
onChange: (organization: Organization | null) => void;
options: Organization[];
options: readonly Organization[];
id?: string;
required?: boolean;
};
@@ -1233,6 +1233,7 @@ const AgentChatPage: FC = () => {
return (
<AgentChatPageView
agentId={agentId}
organizationId={chatQuery.data?.organization_id}
chatTitle={chatTitle}
parentChat={parentChat}
persistedError={persistedError}
@@ -113,6 +113,7 @@ type StoryProps = Omit<
const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
const props = {
agentId: AGENT_ID,
organizationId: "test-org-id",
chatTitle: "Help me refactor",
persistedError: undefined as ChatDetailError | undefined,
parentChat: undefined as TypesGen.Chat | undefined,
@@ -85,6 +85,7 @@ interface EditingState {
interface AgentChatPageViewProps {
// Chat data.
agentId: string;
organizationId: string | undefined;
chatTitle: string | undefined;
parentChat: TypesGen.Chat | undefined;
persistedError: ChatDetailError | undefined;
@@ -176,6 +177,7 @@ interface AgentChatPageViewProps {
export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
agentId,
organizationId,
chatTitle,
parentChat,
persistedError,
@@ -417,6 +419,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
</ChatScrollContainer>
<div className="shrink-0 overflow-y-auto px-4 pb-4 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<ChatPageInput
organizationId={organizationId}
store={store}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
@@ -186,6 +186,8 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
modelOptions.some((modelOption) => modelOption.id === userSelectedModel)
? userSelectedModel
: preferredModelID;
const initialOrg =
organizations.find((o) => o.is_default) ?? organizations[0];
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(
() => {
const stored = localStorage.getItem(selectedWorkspaceIdStorageKey);
@@ -207,8 +209,8 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
}
if (
showOrganizations &&
organizations[0] &&
workspace.organization_id !== organizations[0].id
initialOrg &&
workspace.organization_id !== initialOrg.id
) {
localStorage.removeItem(selectedWorkspaceIdStorageKey);
return null;
@@ -217,7 +219,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
},
);
const [selectedOrg, setSelectedOrg] = useState<TypesGen.Organization | null>(
organizations[0] ?? null,
initialOrg ?? null,
);
const [pendingOrgChange, setPendingOrgChange] =
useState<TypesGen.Organization | null>(null);
@@ -404,7 +406,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
id="organization"
required
value={selectedOrg}
options={[...organizations]}
options={organizations}
onChange={(newOrg) => {
const orgChanged = newOrg?.id !== selectedOrg?.id;
if (orgChanged && attachments.length > 0) {
@@ -2,7 +2,6 @@ import { type FC, Profiler, type ReactNode, useEffect } from "react";
import { toast } from "sonner";
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { useFileAttachments } from "../hooks/useFileAttachments";
import type { ChatDetailError } from "../utils/usageLimitMessage";
import {
@@ -124,6 +123,8 @@ export type PendingAttachment = {
};
interface ChatPageInputProps {
// Organization that owns this chat. Used to scope file uploads.
organizationId: string | undefined;
store: ChatStoreHandle;
compressionThreshold: number | undefined;
onSend: (
@@ -181,6 +182,7 @@ interface ChatPageInputProps {
}
export const ChatPageInput: FC<ChatPageInputProps> = ({
organizationId,
store,
compressionThreshold,
onSend,
@@ -257,8 +259,6 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
const latestContextUsage = rawUsage
? { ...rawUsage, compressionThreshold, lastInjectedContext }
: rawUsage;
const { organizations } = useDashboard();
const organizationId = organizations[0]?.id;
const {
attachments,
textContents,
@@ -10,25 +10,25 @@ const OrganizationRedirect: FC = () => {
organizationPermissionsByOrganizationId: organizationPermissions,
} = useOrganizationSettings();
const sortedOrganizations = [...organizations].sort(
(a, b) => (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0),
);
// Redirect /organizations => /organizations/some-organization-name
// If they can edit the default org, we should redirect to the default.
// If they cannot edit the default, we should redirect to the first org that
// they can edit.
const editableOrg = sortedOrganizations.find((org) =>
canEditOrganization(organizationPermissions[org.id]),
);
// Prefer the editable default org, then any editable org, then
// any viewable org. This replaces the previous [...organizations]
// .sort() approach with direct lookups to avoid copying the array.
const defaultOrg = organizations.find((org) => org.is_default);
const editableOrg =
(defaultOrg &&
canEditOrganization(organizationPermissions[defaultOrg.id]) &&
defaultOrg) ||
organizations.find((org) =>
canEditOrganization(organizationPermissions[org.id]),
);
if (editableOrg) {
return <Navigate to={`/organizations/${editableOrg.name}`} replace />;
}
// If they cannot edit any org, just redirect to an org they can read.
if (sortedOrganizations.length > 0) {
return (
<Navigate to={`/organizations/${sortedOrganizations[0].name}`} replace />
);
// If they cannot edit any org, just redirect to one they can read.
const viewableOrg = defaultOrg ?? organizations[0];
if (viewableOrg) {
return <Navigate to={`/organizations/${viewableOrg.name}`} replace />;
}
return <EmptyState message="No organizations found" />;
};