mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): warn when viewing another user's chat (#24941)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user