feat(site): warn when viewing another user's chat (#24941)

This commit is contained in:
Michael Suchacz
2026-05-05 00:47:24 +02:00
committed by GitHub
parent fad69df710
commit 43aa0498d6
4 changed files with 180 additions and 4 deletions
@@ -19,7 +19,11 @@ import {
} from "#/api/queries/chats";
import { workspaceByIdKey } from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import { MockUserOwner, MockWorkspace } from "#/testHelpers/entities";
import {
MockUserMember,
MockUserOwner,
MockWorkspace,
} from "#/testHelpers/entities";
import {
withAuthProvider,
withDashboardProvider,
@@ -122,7 +126,7 @@ const mockModelConfigs: TypesGen.ChatModelConfig[] = [
const baseChatFields = {
organization_id: "test-org-id",
owner_id: "owner-id",
owner_id: MockUserOwner.id,
workspace_id: mockWorkspace.id,
last_model_config_id: MODEL_CONFIG_ID,
mcp_server_ids: [],
@@ -1093,6 +1097,17 @@ export const WithMessageHistory: Story = {
{ diffUrl: undefined },
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("Markdown rendering showcase"),
).toBeVisible();
await waitFor(() =>
expect(
canvas.queryByText(/^This is not your chat/),
).not.toBeInTheDocument(),
);
},
};
/** Skeleton placeholder when no query data is available yet. */
@@ -1113,6 +1128,66 @@ export const Loading: Story = {
},
};
export const AdminViewingOtherUserChat: Story = {
parameters: {
queries: [
...buildQueries(
{
id: CHAT_ID,
...baseChatFields,
owner_id: "other-user-id",
title: "Other user's chat",
status: "completed",
},
{ messages: [], queued_messages: [], has_more: false },
{ diffUrl: undefined },
),
{
key: ["user", "other-user-id"],
data: {
...MockUserMember,
id: "other-user-id",
username: "OtherUser",
},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const banner = await canvas.findByText(
"This is not your chat. Prompting here will use @OtherUser's identity.",
);
expect(banner).toBeVisible();
expect(banner).toHaveAttribute("role", "status");
},
};
export const ArchivedOtherUserChat: Story = {
parameters: {
queries: buildQueries(
{
id: CHAT_ID,
...baseChatFields,
archived: true,
owner_id: "other-user-id",
title: "Archived other user's chat",
status: "completed",
},
{ messages: [], queued_messages: [], has_more: false },
{ diffUrl: undefined },
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText("This agent has been archived and is read-only."),
).toBeVisible();
expect(
canvas.queryByText(/^This is not your chat/),
).not.toBeInTheDocument();
},
};
export const PlanModeFromChatState: Story = {
parameters: {
queries: buildQueries(
+20 -1
View File
@@ -36,6 +36,7 @@ import {
userCompactionThresholds,
} from "#/api/queries/chats";
import { deploymentSSHConfig } from "#/api/queries/deployment";
import { user as userQuery } from "#/api/queries/users";
import {
workspaceById,
workspaceByIdKey,
@@ -44,6 +45,7 @@ import {
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatMessagePart } from "#/api/typesGenerated";
import { useProxy } from "#/contexts/ProxyContext";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { isMobileViewport } from "#/utils/mobile";
import { pageTitle } from "#/utils/page";
import { rewriteLocalhostURL } from "#/utils/portForward";
@@ -645,6 +647,7 @@ const AgentChatPage: FC = () => {
scrollContainerRef,
} = useOutletContext<AgentsOutletContext>();
const queryClient = useQueryClient();
const { user: currentUser } = useAuthenticated();
const [selectedModel, setSelectedModel] = useState("");
const scrollToBottomRef = useRef<(() => void) | null>(null);
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
@@ -796,6 +799,22 @@ const AgentChatPage: FC = () => {
const { proxy } = useProxy();
const chatRecord = chatQuery.data;
const isArchived = chatRecord?.archived ?? false;
const isViewerNotOwner =
chatRecord !== undefined && currentUser.id !== chatRecord.owner_id;
const chatOwnerQuery = useQuery({
...userQuery(chatRecord?.owner_id ?? ""),
enabled: isViewerNotOwner && !isArchived,
});
const chatOwner =
isViewerNotOwner && chatRecord !== undefined
? {
id: chatRecord.owner_id,
...(chatOwnerQuery.data?.username
? { username: chatOwnerQuery.data.username }
: {}),
}
: undefined;
const planModeEnabled = chatRecord?.plan_mode === "plan";
// Initialize MCP selection from chat record or defaults.
@@ -849,7 +868,6 @@ const AgentChatPage: FC = () => {
has_more: chatMessagesQuery.data?.pages.at(-1)?.has_more ?? false,
}
: undefined;
const isArchived = chatRecord?.archived ?? false;
const isRegenerateTitleDisabled = isArchived || isRegeneratingThisChat;
const chatLastModelConfigID = chatRecord?.last_model_config_id;
@@ -1456,6 +1474,7 @@ const AgentChatPage: FC = () => {
parentChat={parentChat}
persistedError={persistedError}
isArchived={isArchived}
chatOwner={chatOwner}
workspace={workspace}
workspaceAgent={workspaceAgent}
chatBuildId={chatQuery.data?.build_id}
@@ -136,6 +136,9 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
persistedError: undefined as ChatDetailError | undefined,
parentChat: undefined as TypesGen.Chat | undefined,
isArchived: false,
chatOwner: undefined as ComponentProps<
typeof AgentChatPageView
>["chatOwner"],
effectiveSelectedModel: defaultModelConfigID,
setSelectedModel: fn(),
modelOptions: defaultModelOptions,
@@ -214,6 +217,12 @@ type Story = StoryObj<typeof AgentChatPageView>;
/** Basic conversation view with a chat title, workspace, and no archive. */
export const Default: Story = {
render: () => <StoryAgentChatPageView />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.queryByText(/^This is not your chat/),
).not.toBeInTheDocument();
},
};
/** Archived agent displays the read-only banner below the top bar. */
@@ -221,6 +230,56 @@ export const Archived: Story = {
render: () => <StoryAgentChatPageView isArchived isInputDisabled />,
};
/** Shows an identity warning banner when viewing a chat owned by another user. */
export const AdminViewingOtherUserChat: Story = {
render: () => (
<StoryAgentChatPageView
chatOwner={{ id: "other-user-id", username: "OtherUser" }}
/>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const banner = canvas.getByText(
"This is not your chat. Prompting here will use @OtherUser's identity.",
);
expect(banner).toBeVisible();
expect(banner).toHaveAttribute("role", "status");
},
};
/** Shows the owner ID fallback while the owner profile is unavailable. */
export const OtherUserChatOwnerFallback: Story = {
render: () => <StoryAgentChatPageView chatOwner={{ id: "other-user-id" }} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const banner = canvas.getByText(
"This is not your chat. Prompting here will use owner other-user-id's identity.",
);
expect(banner).toBeVisible();
expect(banner).toHaveAttribute("role", "status");
},
};
/** Archived chats stay read-only without the identity warning banner. */
export const ArchivedOtherUserChat: Story = {
render: () => (
<StoryAgentChatPageView
isArchived
isInputDisabled
chatOwner={{ id: "other-user-id", username: "OtherUser" }}
/>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.queryByText(/^This is not your chat/),
).not.toBeInTheDocument();
expect(
canvas.getByText("This agent has been archived and is read-only."),
).toBeVisible();
},
};
/** Shows the parent chat link in the top bar when a parent exists. */
export const WithParentChat: Story = {
render: () => (
@@ -1,4 +1,4 @@
import { ArchiveIcon } from "lucide-react";
import { ArchiveIcon, TriangleAlertIcon } from "lucide-react";
import {
type FC,
@@ -87,6 +87,7 @@ interface AgentChatPageViewProps {
parentChat: TypesGen.Chat | undefined;
persistedError: ChatDetailError | undefined;
isArchived: boolean;
chatOwner: { id: string; username?: string } | undefined;
workspaceAgent?: TypesGen.WorkspaceAgent;
workspace?: TypesGen.Workspace;
chatBuildId?: string;
@@ -188,6 +189,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
parentChat,
persistedError,
isArchived,
chatOwner,
workspaceAgent,
workspace,
chatBuildId,
@@ -403,6 +405,17 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
editing.editingMessageId !== null ||
editing.editingQueuedMessageID !== null;
const chatOwnerLabel =
chatOwner === undefined
? undefined
: chatOwner.username
? `@${chatOwner.username}`
: `owner ${chatOwner.id}`;
const chatOwnerWarning =
chatOwnerLabel === undefined
? undefined
: `This is not your chat. Prompting here will use ${chatOwnerLabel}'s identity.`;
const titleElement = (
<title>
{chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")}
@@ -453,6 +466,16 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
{chatOwnerWarning && !isArchived && (
<div
role="status"
aria-live="polite"
className="flex shrink-0 items-center gap-2 border-b border-border-warning bg-surface-orange px-4 py-2 text-xs text-content-primary"
>
<TriangleAlertIcon className="h-4 w-4 shrink-0 text-content-warning" />
{chatOwnerWarning}
</div>
)}
{isArchived && (
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
<ArchiveIcon className="h-4 w-4 shrink-0" />