Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fe0f5f931 | |||
| a77b54d596 | |||
| 38cdffea05 |
+47
-8
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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 },
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}'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",
|
||||
|
||||
Reference in New Issue
Block a user