Compare commits

...

3 Commits

Author SHA1 Message Date
Danielle Maywood 6fe0f5f931 refactor(site): make chat owner badge link to user profile 2026-03-16 13:04:20 +00:00
Danielle Maywood a77b54d596 feat(site): show chat owner identity when viewing another user's agent
When an admin navigates to another user's agent chat via a shared link,
there was no visual indicator that the chat belongs to someone else.

Backend:
- Add owner_name and owner_avatar_url flat fields to the Chat SDK type
- Resolve owner info in all chat handlers (listChats, getChat, postChats,
  interruptChat) via GetUserByID/GetUsersByIDs with system context
- Run make gen to update TypeScript types

Frontend:
- Derive isViewingOtherChat from chat.owner_id vs current user ID
- When viewing a foreign chat, show an owner badge in the TopBar with
  the owner's avatar and '@username's chat'
- Chat remains fully interactive (not read-only)
- Storybook coverage for TopBar and AgentDetail
2026-03-16 12:50:12 +00:00
Danielle Maywood 38cdffea05 feat(site): show read-only state when viewing another user's agent chat
When an admin navigates to another user's agent chat via a shared link,
there is no visual indicator that it belongs to someone else, and the
input is fully interactive.

This change adds:
- Ownership detection by comparing chat.owner_id to the current user ID
- A read-only banner below the top bar: 'You are viewing someone else's
  agent. This chat is read-only.'
- Disabled message input and edit actions for foreign chats
- An owner badge in the TopBar showing the owner's avatar and username
- Storybook coverage for both TopBar and AgentDetail components

The archived banner takes priority when both archived and foreign-chat
states apply simultaneously.
2026-03-16 12:31:55 +00:00
14 changed files with 202 additions and 10 deletions
+47 -8
View File
@@ -193,7 +193,16 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertChats(chats, diffStatusesByChatID))
owner, err := api.Database.GetUserByID(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat owner.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertChats(chats, owner, diffStatusesByChatID))
}
func (api *API) getChatDiffStatusesByChatID(
@@ -286,7 +295,16 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusCreated, convertChat(chat, nil))
owner, err := api.Database.GetUserByID(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat owner.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusCreated, convertChat(chat, owner, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -553,7 +571,17 @@ func (api *API) chatCostUsers(rw http.ResponseWriter, r *http.Request) {
func (api *API) getChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
httpapi.Write(ctx, rw, http.StatusOK, convertChat(chat, nil))
owner, err := api.Database.GetUserByID(ctx, chat.OwnerID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat owner.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertChat(chat, owner, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -1296,7 +1324,16 @@ func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) {
chat = updatedChat
}
httpapi.Write(ctx, rw, http.StatusOK, convertChat(chat, nil))
owner, err := api.Database.GetUserByID(ctx, chat.OwnerID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat owner.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertChat(chat, owner, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -2447,10 +2484,12 @@ func truncateRunes(value string, maxLen int) string {
return string(runes[:maxLen])
}
func convertChat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.Chat {
func convertChat(c database.Chat, owner database.User, diffStatus *database.ChatDiffStatus) codersdk.Chat {
chat := codersdk.Chat{
ID: c.ID,
OwnerID: c.OwnerID,
OwnerName: owner.Username,
OwnerAvatarURL: owner.AvatarURL,
LastModelConfigID: c.LastModelConfigID,
Title: c.Title,
Status: codersdk.ChatStatus(c.Status),
@@ -2486,16 +2525,16 @@ func convertChat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.
return chat
}
func convertChats(chats []database.Chat, diffStatusesByChatID map[uuid.UUID]database.ChatDiffStatus) []codersdk.Chat {
func convertChats(chats []database.Chat, owner database.User, diffStatusesByChatID map[uuid.UUID]database.ChatDiffStatus) []codersdk.Chat {
result := make([]codersdk.Chat, len(chats))
for i, c := range chats {
diffStatus, ok := diffStatusesByChatID[c.ID]
if ok {
result[i] = convertChat(c, &diffStatus)
result[i] = convertChat(c, owner, &diffStatus)
continue
}
result[i] = convertChat(c, nil)
result[i] = convertChat(c, owner, nil)
if diffStatusesByChatID != nil {
emptyDiffStatus := convertChatDiffStatus(c.ID, nil)
result[i].DiffStatus = &emptyDiffStatus
+2
View File
@@ -35,6 +35,8 @@ const (
type Chat struct {
ID uuid.UUID `json:"id" format:"uuid"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
OwnerName string `json:"owner_name"`
OwnerAvatarURL string `json:"owner_avatar_url"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"`
ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"`
RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"`
+2
View File
@@ -74,6 +74,8 @@ const makeChat = (
): TypesGen.Chat => ({
id,
owner_id: "owner-1",
owner_name: "owner1",
owner_avatar_url: "",
last_model_config_id: "model-1",
title: `Chat ${id}`,
status: "running",
+2
View File
@@ -1058,6 +1058,8 @@ export interface ChangePasswordWithOneTimePasscodeRequest {
export interface Chat {
readonly id: string;
readonly owner_id: string;
readonly owner_name: string;
readonly owner_avatar_url: string;
readonly workspace_id?: string;
readonly parent_chat_id?: string;
readonly root_chat_id?: string;
@@ -104,7 +104,9 @@ const mockModelCatalog: TypesGen.ChatModelsResponse = {
};
const baseChatFields = {
owner_id: "owner-id",
owner_id: MockUserOwner.id,
owner_name: MockUserOwner.username,
owner_avatar_url: MockUserOwner.avatar_url ?? "",
workspace_id: mockWorkspace.id,
last_model_config_id: "model-config-1",
created_at: "2026-02-18T00:00:00.000Z",
@@ -113,6 +115,10 @@ const baseChatFields = {
last_error: null,
} as const;
const OTHER_USER_ID = "other-user-id";
const OTHER_USER_NAME = "alice";
const OTHER_USER_AVATAR = "";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -1041,3 +1047,74 @@ export const StreamedReasoningCollapsed: Story = {
).resolves.toBeInTheDocument();
},
};
/** Viewing another user's chat — owner badge is shown in the top bar. */
export const ViewingOtherUsersChat: Story = {
parameters: {
queries: [
...buildQueries(
{
id: CHAT_ID,
...baseChatFields,
owner_id: OTHER_USER_ID,
owner_name: OTHER_USER_NAME,
owner_avatar_url: OTHER_USER_AVATAR,
title: "Someone else's agent",
status: "completed",
},
{
messages: [
{
id: 1,
chat_id: CHAT_ID,
created_at: "2026-02-18T00:01:00.000Z",
role: "user",
content: [
{
type: "text",
text: "Can you help me with this task?",
},
],
},
{
id: 2,
chat_id: CHAT_ID,
created_at: "2026-02-18T00:01:30.000Z",
role: "assistant",
content: [
{
type: "text",
text: "Sure, I'll start working on that right away.",
},
],
},
],
queued_messages: [],
},
{ diffUrl: undefined },
),
],
},
};
/** Viewing another user's archived chat — both owner badge and archived banner show. */
export const ViewingOtherUsersChatArchived: Story = {
parameters: {
queries: [
...buildQueries(
{
id: CHAT_ID,
...baseChatFields,
owner_id: OTHER_USER_ID,
owner_name: OTHER_USER_NAME,
owner_avatar_url: OTHER_USER_AVATAR,
title: "Archived foreign agent",
status: "completed",
archived: true,
},
{ messages: [], queued_messages: [] },
{ diffUrl: undefined },
),
],
},
};
+14
View File
@@ -17,6 +17,7 @@ import { workspaceById, workspaceByIdKey } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { useProxy } from "contexts/ProxyContext";
import { useAuthenticated } from "hooks";
import {
getTerminalHref,
getVSCodeHref,
@@ -675,6 +676,10 @@ const AgentDetail: FC = () => {
);
const chatRecord = chatQuery.data;
const { user: currentUser } = useAuthenticated();
const isViewingOtherChat = Boolean(
chatRecord && chatRecord.owner_id !== currentUser.id,
);
const chatMessagesData = chatMessagesQuery.data;
const isArchived = chatRecord?.archived ?? false;
const chatMessagesList = chatMessagesData?.messages;
@@ -1115,6 +1120,15 @@ const AgentDetail: FC = () => {
chatErrorReasons={chatErrorReasons}
chatRecord={chatRecord}
isArchived={isArchived}
chatOwner={
isViewingOtherChat && chatRecord
? {
id: chatRecord.owner_id,
username: chatRecord.owner_name,
avatar_url: chatRecord.owner_avatar_url,
}
: undefined
}
hasWorkspace={Boolean(workspaceId)}
store={store}
editing={editing}
@@ -182,6 +182,8 @@ const createTestQueryClient = (): QueryClient =>
const makeChat = (chatID: string): TypesGen.Chat => ({
id: chatID,
owner_id: "owner-1",
owner_name: "owner1",
owner_avatar_url: "",
last_model_config_id: "model-1",
title: "test",
status: "running",
@@ -51,6 +51,8 @@ export const WithParentChat: Story = {
parentChat: {
id: "parent-chat-1",
owner_id: "owner-id",
owner_name: "owner",
owner_avatar_url: "",
last_model_config_id: "model-config-1",
title: "Set up CI/CD pipeline",
status: "completed",
@@ -102,3 +104,26 @@ export const ArchivedWithUnarchive: Story = {
).not.toBeInTheDocument();
},
};
export const ViewingOtherUsersChat: Story = {
args: {
chatOwner: {
id: "other-user-id",
username: "alice",
avatar_url: "",
name: "Alice Smith",
},
},
};
export const ViewingOtherUsersChatArchived: Story = {
args: {
isArchived: true,
chatOwner: {
id: "other-user-id",
username: "alice",
avatar_url: "",
name: "Alice Smith",
},
},
};
@@ -1,4 +1,5 @@
import type * as TypesGen from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
@@ -23,7 +24,7 @@ import {
Trash2Icon,
} from "lucide-react";
import type { FC } from "react";
import { useNavigate } from "react-router";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
interface SidebarPanelState {
@@ -42,6 +43,7 @@ interface WorkspaceActions {
type AgentDetailTopBarProps = {
chatTitle?: string;
chatOwner?: TypesGen.MinimalUser;
parentChat?: TypesGen.Chat;
onOpenParentChat: (chatId: string) => void;
panel: SidebarPanelState;
@@ -57,6 +59,7 @@ type AgentDetailTopBarProps = {
export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
chatTitle,
chatOwner,
parentChat,
onOpenParentChat,
panel,
@@ -115,6 +118,20 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
<span className="truncate text-sm text-content-primary">
{chatTitle}
</span>
{chatOwner && (
<Link
to={`/users/${chatOwner.username}`}
className="flex shrink-0 items-center gap-1 rounded bg-surface-tertiary px-1.5 py-0.5 text-xs text-content-secondary no-underline hover:bg-surface-quaternary"
>
<Avatar
src={chatOwner.avatar_url}
fallback={chatOwner.username}
size="sm"
className="size-4"
/>
@{chatOwner.username}&apos;s chat
</Link>
)}
</div>
)}
</div>
@@ -32,6 +32,8 @@ const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const buildChat = (overrides: Partial<TypesGen.Chat> = {}): TypesGen.Chat => ({
id: AGENT_ID,
owner_id: "owner-1",
owner_name: "owner1",
owner_avatar_url: "",
title: "Help me refactor",
status: "completed",
last_model_config_id: "model-config-1",
@@ -56,6 +56,7 @@ interface AgentDetailViewProps {
chatErrorReasons: Record<string, string>;
chatRecord: TypesGen.Chat | undefined;
isArchived: boolean;
chatOwner?: TypesGen.MinimalUser;
hasWorkspace: boolean;
// Store handle.
@@ -133,6 +134,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
chatErrorReasons,
chatRecord,
isArchived,
chatOwner,
hasWorkspace,
store,
editing,
@@ -207,6 +209,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
<div className="relative z-10 shrink-0 overflow-visible">
<AgentDetailTopBar
chatTitle={chatTitle}
chatOwner={chatOwner}
parentChat={parentChat}
onOpenParentChat={(chatId) => onNavigateToChat(chatId)}
panel={{
@@ -235,6 +238,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
This agent has been archived and is read-only.
</div>
)}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-full z-10 h-6 bg-surface-primary"
@@ -106,6 +106,8 @@ const todayTimestamp = new Date().toISOString();
const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
id: "chat-default",
owner_id: "owner-1",
owner_name: "owner1",
owner_avatar_url: "",
title: "Agent",
status: "completed",
last_model_config_id: defaultModelConfigs[0].id,
@@ -37,6 +37,8 @@ const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
id: "chat-default",
owner_id: "owner-1",
owner_name: "owner1",
owner_avatar_url: "",
title: "Agent",
status: "completed",
last_model_config_id: defaultModelConfigs[0].id,
@@ -56,6 +56,8 @@ const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
id: "chat-default",
owner_id: "owner-1",
owner_name: "owner1",
owner_avatar_url: "",
title: "Agent",
status: "completed",
last_model_config_id: "model-1",