diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 2037aa1903..f51acb8712 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -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( diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 2bf6d40c23..68a25564a4 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -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(); const queryClient = useQueryClient(); + const { user: currentUser } = useAuthenticated(); const [selectedModel, setSelectedModel] = useState(""); const scrollToBottomRef = useRef<(() => void) | null>(null); const chatInputRef = useRef(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} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index a3b7a2795d..f5500bb4fc 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -136,6 +136,9 @@ const StoryAgentChatPageView: FC = ({ 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; /** Basic conversation view with a chat title, workspace, and no archive. */ export const Default: Story = { render: () => , + 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: () => , }; +/** Shows an identity warning banner when viewing a chat owned by another user. */ +export const AdminViewingOtherUserChat: Story = { + render: () => ( + + ), + 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: () => , + 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: () => ( + + ), + 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: () => ( diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 66eea72f8f..016308c837 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -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 = ({ parentChat, persistedError, isArchived, + chatOwner, workspaceAgent, workspace, chatBuildId, @@ -403,6 +405,17 @@ export const AgentChatPageView: FC = ({ 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 = ( {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" />