feat: allow renaming of agent chat title (#24489)

Co-authored-by: Coder Agents <noreply@coder.com>
This commit is contained in:
Jaayden Halko
2026-04-20 14:00:46 +01:00
committed by GitHub
co-authored by Coder Agents
parent 18a30a7a10
commit 410f9a5e19
26 changed files with 1911 additions and 297 deletions
+7
View File
@@ -3171,6 +3171,13 @@ class ExperimentalApiMethods {
return response.data;
};
proposeChatTitle = async (chatId: string): Promise<{ title: string }> => {
const response = await this.axios.post<{ title: string }>(
`/api/experimental/chats/${chatId}/title/propose`,
);
return response.data;
};
createChatMessage = async (
chatId: string,
req: CreateChatMessageRequestWithClearablePlanMode,
+32
View File
@@ -826,6 +826,38 @@ export const regenerateChatTitle = (queryClient: QueryClient) => ({
},
});
type UpdateChatTitleVariables = {
chatId: string;
title: string;
};
export const updateChatTitle = (queryClient: QueryClient) => ({
mutationFn: ({ chatId, title }: UpdateChatTitleVariables) =>
API.experimental.updateChat(chatId, { title }),
onSuccess: (_data: unknown, { chatId, title }: UpdateChatTitleVariables) => {
queryClient.setQueryData<TypesGen.Chat | undefined>(
chatKey(chatId),
(chat) => (chat ? { ...chat, title } : chat),
);
updateInfiniteChatsCache(queryClient, (chats) =>
chats.map((chat) => (chat.id === chatId ? { ...chat, title } : chat)),
);
},
onSettled: async (
_data: unknown,
_error: unknown,
{ chatId }: UpdateChatTitleVariables,
) => {
await invalidateChatListQueries(queryClient);
await queryClient.invalidateQueries({
queryKey: chatKey(chatId),
exact: true,
});
},
});
export const createChat = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.CreateChatRequest) =>
API.experimental.createChat(req),
+8
View File
@@ -5700,6 +5700,14 @@ export interface PrometheusConfig {
readonly aggregate_agent_stats_by: string;
}
// From codersdk/chats.go
/**
* ProposeChatTitleResponse is returned by the propose-title endpoint.
*/
export interface ProposeChatTitleResponse {
readonly title: string;
}
// From codersdk/deployment.go
export interface ProvisionerConfig {
/**
@@ -0,0 +1,67 @@
/**
* Adapted from `DropdownMenu.tsx` to wrap Radix's ContextMenu primitive.
* Shares menu styling with DropdownMenu via `menuClasses.ts` so the
* click-triggered and right-click-triggered menus stay in visual sync
* by construction.
* @see {@link https://www.radix-ui.com/primitives/docs/components/context-menu}
*/
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { cn } from "#/utils/cn";
import {
menuContentClass,
menuItemClass,
menuSeparatorClass,
} from "../DropdownMenu/menuClasses";
export const ContextMenu = ContextMenuPrimitive.Root;
export const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
/** @public */
export const ContextMenuGroup = ContextMenuPrimitive.Group;
/** @public */
export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
export const ContextMenuContent: React.FC<
React.ComponentPropsWithRef<typeof ContextMenuPrimitive.Content>
> = ({ className, ...props }) => {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
className={cn(menuContentClass, className)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
};
type ContextMenuItemProps = React.ComponentPropsWithRef<
typeof ContextMenuPrimitive.Item
> & {
inset?: boolean;
};
export const ContextMenuItem: React.FC<ContextMenuItemProps> = ({
className,
inset,
...props
}) => {
return (
<ContextMenuPrimitive.Item
className={cn(menuItemClass, inset && "pl-8", className)}
{...props}
/>
);
};
export const ContextMenuSeparator: React.FC<
React.ComponentPropsWithRef<typeof ContextMenuPrimitive.Separator>
> = ({ className, ...props }) => {
return (
<ContextMenuPrimitive.Separator
className={cn([menuSeparatorClass], className)}
{...props}
/>
);
};
@@ -8,6 +8,11 @@
import { Check } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "#/utils/cn";
import {
menuContentClass,
menuItemClass,
menuSeparatorClass,
} from "./menuClasses";
export const DropdownMenu = DropdownMenuPrimitive.Root;
@@ -24,14 +29,7 @@ export const DropdownMenuContent: React.FC<
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
sideOffset={sideOffset}
className={cn(
"z-50 min-w-48 overflow-hidden rounded-md border border-solid bg-surface-primary p-2 text-content-secondary shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
className={cn(menuContentClass, className)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
@@ -51,19 +49,7 @@ export const DropdownMenuItem: React.FC<DropdownMenuItemProps> = ({
}) => {
return (
<DropdownMenuPrimitive.Item
className={cn(
`
relative flex cursor-default select-none items-center gap-2 rounded-sm
px-2 py-1.5 text-sm text-content-secondary font-medium outline-none
no-underline
focus:bg-surface-secondary focus:text-content-primary
data-[disabled]:pointer-events-none data-[disabled]:opacity-50
[&_svg]:size-icon-sm [&>svg]:shrink-0
[&_img]:size-icon-sm [&>img]:shrink-0
`,
inset && "pl-8",
className,
)}
className={cn(menuItemClass, inset && "pl-8", className)}
{...props}
/>
);
@@ -98,7 +84,7 @@ export const DropdownMenuSeparator: React.FC<
> = ({ className, ...props }) => {
return (
<DropdownMenuPrimitive.Separator
className={cn(["-mx-1 my-2 h-px bg-border"], className)}
className={cn([menuSeparatorClass], className)}
{...props}
/>
);
@@ -0,0 +1,19 @@
export const menuContentClass = [
"z-50 min-w-48 overflow-hidden rounded-md border border-solid bg-surface-primary p-2 text-content-secondary shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
].join(" ");
export const menuItemClass = `
relative flex cursor-default select-none items-center gap-2 rounded-sm
px-2 py-1.5 text-sm text-content-secondary font-medium outline-none
no-underline
focus:bg-surface-secondary focus:text-content-primary
data-[disabled]:pointer-events-none data-[disabled]:opacity-50
[&_svg]:size-icon-sm [&>svg]:shrink-0
[&_img]:size-icon-sm [&>img]:shrink-0
`;
export const menuSeparatorClass = "-mx-1 my-2 h-px bg-border";
+40 -11
View File
@@ -28,6 +28,7 @@ import {
reorderPinnedChat,
unarchiveChat,
unpinChat,
updateChatTitle,
updateChildInParentCache,
updateInfiniteChatsCache,
} from "#/api/queries/chats";
@@ -266,10 +267,19 @@ const AgentsPage: FC = () => {
toast.error(getErrorMessage(error, "Failed to generate new title."));
},
});
const renameTitleMutation = useMutation({
...updateChatTitle(queryClient),
onError: (error: unknown) => {
toast.error(getErrorMessage(error, "Failed to rename chat."));
},
});
const regeneratingTitleChatIdsRef = useRef<ReadonlySet<string>>(new Set());
const [regeneratingTitleChatIds, setRegeneratingTitleChatIds] = useState<
readonly string[]
>([]);
const regeneratingTitlePromisesRef = useRef(
new Map<string, Promise<string>>(),
);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const catalogModelOptions = getModelOptionsFromConfigs(
chatModelConfigsQuery.data,
@@ -425,18 +435,35 @@ const AgentsPage: FC = () => {
regeneratingTitleChatIdsRef.current = next;
setRegeneratingTitleChatIds(Array.from(next));
};
const requestRegenerateTitle = (chatId: string) => {
if (!addRegeneratingTitleChatId(chatId)) {
return;
const requestRegenerateTitle = (chatId: string): Promise<string> => {
const existing = regeneratingTitlePromisesRef.current.get(chatId);
if (existing) {
return existing;
}
void regenerateTitleMutation
.mutateAsync(chatId)
.catch(() => {
// The shared mutation onError already reports the failure.
})
.finally(() => {
removeRegeneratingTitleChatId(chatId);
});
addRegeneratingTitleChatId(chatId);
const clearRegenerateTitleTracking = () => {
regeneratingTitlePromisesRef.current.delete(chatId);
removeRegeneratingTitleChatId(chatId);
};
const promise = regenerateTitleMutation.mutateAsync(chatId).then(
(updated) => {
clearRegenerateTitleTracking();
return updated.title;
},
(error) => {
clearRegenerateTitleTracking();
throw error;
},
);
regeneratingTitlePromisesRef.current.set(chatId, promise);
return promise;
};
const requestProposeTitle = async (chatId: string): Promise<string> => {
const result = await API.experimental.proposeChatTitle(chatId);
return result.title;
};
const requestRenameTitle = async (chatId: string, title: string) => {
await renameTitleMutation.mutateAsync({ chatId, title });
};
const handleToggleSidebarCollapsed = () =>
setIsSidebarCollapsed((prev) => !prev);
@@ -745,6 +772,8 @@ const AgentsPage: FC = () => {
requestUnpinAgent={requestUnpinAgent}
requestReorderPinnedAgent={requestReorderPinnedAgent}
onRegenerateTitle={requestRegenerateTitle}
onProposeTitle={requestProposeTitle}
onRenameTitle={requestRenameTitle}
regeneratingTitleChatIds={regeneratingTitleChatIds}
onToggleSidebarCollapsed={handleToggleSidebarCollapsed}
isAgentsAdmin={isAgentsAdmin}
@@ -264,7 +264,9 @@ const defaultArgs: ComponentProps<typeof AgentsPageView> = {
requestArchiveAndDeleteWorkspace: fn(),
requestPinAgent: fn(),
requestUnpinAgent: fn(),
onRegenerateTitle: fn(),
onRegenerateTitle: fn(async () => "Generated title"),
onProposeTitle: fn(async () => "Proposed title"),
onRenameTitle: fn(async () => {}),
regeneratingTitleChatIds: [],
onToggleSidebarCollapsed: fn(),
isAgentsAdmin: false,
+11 -3
View File
@@ -24,6 +24,7 @@ export interface AgentsOutletContext {
requestUnpinAgent: (chatId: string) => void;
requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void;
onRegenerateTitle?: (chatId: string) => void;
onRenameTitle?: (chatId: string, title: string) => Promise<void>;
regeneratingTitleChatIds: readonly string[];
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
@@ -61,7 +62,9 @@ interface AgentsPageViewProps {
requestPinAgent: (chatId: string) => void;
requestUnpinAgent: (chatId: string) => void;
requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void;
onRegenerateTitle: (chatId: string) => void;
onRegenerateTitle: (chatId: string) => Promise<string>;
onProposeTitle: (chatId: string) => Promise<string>;
onRenameTitle: (chatId: string, title: string) => Promise<void>;
regeneratingTitleChatIds: readonly string[];
onToggleSidebarCollapsed: () => void;
isAgentsAdmin: boolean;
@@ -98,6 +101,8 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
requestUnpinAgent,
requestReorderPinnedAgent,
onRegenerateTitle,
onProposeTitle,
onRenameTitle,
regeneratingTitleChatIds,
onToggleSidebarCollapsed,
isAgentsAdmin,
@@ -139,7 +144,9 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
requestPinAgent,
requestUnpinAgent,
requestReorderPinnedAgent,
onRegenerateTitle,
onRegenerateTitle: (chatId: string) => {
onRegenerateTitle(chatId).catch(() => {});
},
regeneratingTitleChatIds,
isSidebarCollapsed,
onToggleSidebarCollapsed,
@@ -174,7 +181,8 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
onPinAgent={requestPinAgent}
onUnpinAgent={requestUnpinAgent}
onReorderPinnedAgent={requestReorderPinnedAgent}
onRegenerateTitle={onRegenerateTitle}
onRenameTitle={onRenameTitle}
onProposeTitle={onProposeTitle}
regeneratingTitleChatIds={regeneratingTitleChatIds}
onBeforeNewAgent={handleNewAgent}
isCreating={isCreating}
@@ -88,7 +88,7 @@ const meta: Meta<typeof AgentsSidebar> = {
onArchiveAndDeleteWorkspace: fn(),
onPinAgent: fn(),
onUnpinAgent: fn(),
onRegenerateTitle: fn(),
onRenameTitle: fn(() => Promise.resolve()),
onBeforeNewAgent: fn(),
isCreating: false,
regeneratingTitleChatIds: [],
@@ -411,7 +411,7 @@ export const MixedCacheDoesNotDuplicateChild: Story = {
// without embedding a literal date that drifts across calendar days.
const recentTimestamp = new Date(Date.now() - 60_000).toISOString();
export const RegeneratingTitleDisablesOnlyActiveChat: Story = {
export const RenameChatAvailableDuringRegeneration: Story = {
args: {
chats: [
buildChat({
@@ -426,6 +426,8 @@ export const RegeneratingTitleDisablesOnlyActiveChat: Story = {
}),
],
regeneratingTitleChatIds: ["regenerating-chat"],
onProposeTitle: fn(async () => "Proposed replacement"),
onRenameTitle: fn(async () => {}),
},
parameters: {
reactRouter: reactRouterParameters({
@@ -448,13 +450,13 @@ export const RegeneratingTitleDisablesOnlyActiveChat: Story = {
}),
);
await expect(
await body.findByRole("menuitem", { name: "Generate new title" }),
).toHaveAttribute("data-disabled");
await body.findByRole("menuitem", { name: "Rename chat" }),
).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await waitFor(() => {
expect(
body.queryByRole("menuitem", { name: "Generate new title" }),
body.queryByRole("menuitem", { name: "Rename chat" }),
).not.toBeInTheDocument();
});
@@ -464,11 +466,381 @@ export const RegeneratingTitleDisablesOnlyActiveChat: Story = {
}),
);
await expect(
await body.findByRole("menuitem", { name: "Generate new title" }),
).not.toHaveAttribute("data-disabled");
await body.findByRole("menuitem", { name: "Rename chat" }),
).toBeInTheDocument();
},
};
export const RenameChatSubmitsNewTitle: Story = {
args: {
chats: [
buildChat({
id: "rename-target",
title: "Original title",
updated_at: recentTimestamp,
}),
],
onRenameTitle: fn(() => Promise.resolve()),
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(
canvas.getByRole("button", {
name: "Open actions for Original title",
}),
);
await userEvent.click(
await body.findByRole("menuitem", { name: "Rename chat" }),
);
const input = await body.findByRole<HTMLInputElement>("textbox", {
name: "Chat title",
});
await waitFor(() => {
expect(input).toHaveValue("Original title");
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe("Original title".length);
});
await userEvent.clear(input);
await userEvent.type(input, "Renamed title", { delay: null });
await waitFor(() => {
expect(input).toHaveValue("Renamed title");
});
await userEvent.click(body.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(args.onRenameTitle).toHaveBeenCalledWith(
"rename-target",
"Renamed title",
);
});
await waitFor(() => {
expect(
body.queryByRole("heading", { name: "Rename chat" }),
).not.toBeInTheDocument();
});
},
};
export const CancellingRenameDialogKeepsTitle: Story = {
args: {
chats: [
buildChat({
id: "rename-cancel",
title: "Keep me",
updated_at: recentTimestamp,
}),
],
onRenameTitle: fn(() => Promise.resolve()),
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(
canvas.getByRole("button", {
name: "Open actions for Keep me",
}),
);
await userEvent.click(
await body.findByRole("menuitem", { name: "Rename chat" }),
);
const input = await body.findByRole<HTMLInputElement>("textbox", {
name: "Chat title",
});
await userEvent.clear(input);
await userEvent.type(input, "Discarded edit");
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
expect(args.onRenameTitle).not.toHaveBeenCalled();
expect(canvas.getByText("Keep me")).toBeInTheDocument();
},
};
export const RenameChatGenerateFillsInput: Story = {
args: {
chats: [
buildChat({
id: "rename-generate",
title: "Old title",
updated_at: recentTimestamp,
}),
],
onProposeTitle: fn(async () => "AI suggested title"),
onRenameTitle: fn(() => Promise.resolve()),
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(
canvas.getByRole("button", {
name: "Open actions for Old title",
}),
);
await userEvent.click(
await body.findByRole("menuitem", { name: "Rename chat" }),
);
const input = await body.findByRole<HTMLInputElement>("textbox", {
name: "Chat title",
});
await userEvent.click(body.getByRole("button", { name: "Generate" }));
await waitFor(() => {
expect(input).toHaveValue("AI suggested title");
});
expect(args.onProposeTitle).toHaveBeenCalledWith("rename-generate");
expect(args.onRenameTitle).not.toHaveBeenCalled();
},
};
export const RenameChatGenerateErrorSurfacesAlert: Story = {
args: {
chats: [
buildChat({
id: "rename-generate-error",
title: "Original title",
updated_at: recentTimestamp,
}),
],
onProposeTitle: fn(async () => {
throw new Error("Proposal provider is temporarily unavailable.");
}),
onRenameTitle: fn(() => Promise.resolve()),
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(
canvas.getByRole("button", {
name: "Open actions for Original title",
}),
);
await userEvent.click(
await body.findByRole("menuitem", { name: "Rename chat" }),
);
const input = await body.findByRole<HTMLInputElement>("textbox", {
name: "Chat title",
});
await userEvent.click(body.getByRole("button", { name: "Generate" }));
const alert = await body.findByRole("alert");
expect(alert).toHaveTextContent(
"Proposal provider is temporarily unavailable.",
);
await waitFor(() => {
expect(input).toHaveAttribute("aria-invalid", "true");
});
expect(input).toHaveValue("Original title");
},
};
export const RenameChatCancelAfterGenerateRestoresTitle: Story = {
args: {
chats: [
buildChat({
id: "rename-generate-cancel",
title: "Keep this one",
updated_at: recentTimestamp,
}),
],
onProposeTitle: fn(async () => "Server suggestion"),
onRenameTitle: fn(() => Promise.resolve()),
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(
canvas.getByRole("button", {
name: "Open actions for Keep this one",
}),
);
await userEvent.click(
await body.findByRole("menuitem", { name: "Rename chat" }),
);
const input = await body.findByRole<HTMLInputElement>("textbox", {
name: "Chat title",
});
await userEvent.click(body.getByRole("button", { name: "Generate" }));
await waitFor(() => {
expect(input).toHaveValue("Server suggestion");
});
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
expect(args.onRenameTitle).not.toHaveBeenCalled();
expect(canvas.getByText("Keep this one")).toBeInTheDocument();
},
};
export const RenameChatGenerateLateResponseDoesNotClobberOtherChat: Story = {
args: {
chats: [
buildChat({
id: "rename-generate-a",
title: "Chat A",
updated_at: recentTimestamp,
}),
buildChat({
id: "rename-generate-b",
title: "Chat B",
updated_at: recentTimestamp,
}),
],
onProposeTitle: fn((chatId: string) => {
if (chatId === "rename-generate-a") {
return new Promise<string>((resolve) => {
setTimeout(() => resolve("Late suggestion for A"), 150);
});
}
return Promise.resolve(`Proposal for ${chatId}`);
}),
onRenameTitle: fn(() => Promise.resolve()),
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(
canvas.getByRole("button", { name: "Open actions for Chat A" }),
);
await userEvent.click(
await body.findByRole("menuitem", { name: "Rename chat" }),
);
await body.findByRole<HTMLInputElement>("textbox", {
name: "Chat title",
});
await userEvent.click(body.getByRole("button", { name: "Generate" }));
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
await userEvent.click(
await canvas.findByRole("button", {
name: "Open actions for Chat B",
}),
);
await userEvent.click(
await body.findByRole("menuitem", { name: "Rename chat" }),
);
const inputB = await body.findByRole<HTMLInputElement>("textbox", {
name: "Chat title",
});
expect(inputB).toHaveValue("Chat B");
await userEvent.clear(inputB);
await userEvent.type(inputB, "User edit for B");
await new Promise((resolve) => setTimeout(resolve, 250));
expect(inputB).toHaveValue("User edit for B");
expect(body.queryByRole("alert")).not.toBeInTheDocument();
},
};
export const RenameChatGenerateLateResponseDoesNotClobberSameChatReopen: Story =
{
args: {
chats: [
buildChat({
id: "rename-generate-same",
title: "Chat same",
updated_at: recentTimestamp,
}),
],
onProposeTitle: fn(() => {
return new Promise<string>((resolve) => {
setTimeout(() => resolve("Late suggestion"), 150);
});
}),
onRenameTitle: fn(() => Promise.resolve()),
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(
canvas.getByRole("button", { name: "Open actions for Chat same" }),
);
await userEvent.click(
await body.findByRole("menuitem", { name: "Rename chat" }),
);
await body.findByRole<HTMLInputElement>("textbox", {
name: "Chat title",
});
await userEvent.click(body.getByRole("button", { name: "Generate" }));
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
await userEvent.click(
await canvas.findByRole("button", {
name: "Open actions for Chat same",
}),
);
await userEvent.click(
await body.findByRole("menuitem", { name: "Rename chat" }),
);
const input = await body.findByRole<HTMLInputElement>("textbox", {
name: "Chat title",
});
expect(input).toHaveValue("Chat same");
await userEvent.clear(input);
await userEvent.type(input, "User edit");
await new Promise((resolve) => setTimeout(resolve, 250));
expect(input).toHaveValue("User edit");
expect(body.queryByRole("alert")).not.toBeInTheDocument();
},
};
export const ActiveFilterShowsActiveAgents: Story = {
args: {
chats: [
@@ -110,7 +110,7 @@ const defaultProps: React.ComponentProps<typeof AgentsSidebar> = {
onArchiveAndDeleteWorkspace: vi.fn(),
onPinAgent: vi.fn(),
onUnpinAgent: vi.fn(),
onRegenerateTitle: vi.fn(),
onRenameTitle: vi.fn(async () => {}),
regeneratingTitleChatIds: [],
onBeforeNewAgent: vi.fn(),
isCreating: false,
@@ -68,6 +68,13 @@ import type {
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Avatar } from "#/components/Avatar/Avatar";
import { Button } from "#/components/Button/Button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "#/components/ContextMenu/ContextMenu";
import {
DropdownMenu,
DropdownMenuContent,
@@ -95,6 +102,7 @@ import { getTimeGroup, TIME_GROUPS } from "../../utils/timeGroups";
import type { ModelSelectorOption } from "../ChatElements";
import { asString } from "../ChatElements/runtimeTypeUtils";
import { UsageIndicator } from "../UsageIndicator";
import { RenameChatDialog } from "./RenameChatDialog";
type SidebarView =
| { panel: "chats" }
@@ -127,7 +135,8 @@ interface AgentsSidebarProps {
onPinAgent: (chatId: string) => void;
onUnpinAgent: (chatId: string) => void;
onReorderPinnedAgent?: (chatId: string, pinOrder: number) => void;
onRegenerateTitle: (chatId: string) => void;
onRenameTitle?: (chatId: string, title: string) => Promise<void>;
onProposeTitle?: (chatId: string) => Promise<string>;
onBeforeNewAgent?: () => void;
isCreating: boolean;
isArchiving?: boolean;
@@ -403,7 +412,7 @@ interface ChatTreeContextValue {
) => void;
readonly onPinAgent: (chatId: string) => void;
readonly onUnpinAgent: (chatId: string) => void;
readonly onRegenerateTitle: (chatId: string) => void;
readonly onOpenRenameDialog?: (chat: Chat) => void;
}
const ChatTreeContext = createContext<ChatTreeContextValue | null>(null);
@@ -441,7 +450,7 @@ const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
onArchiveAndDeleteWorkspace,
onPinAgent,
onUnpinAgent,
onRegenerateTitle,
onOpenRenameDialog,
} = useChatTree();
const chatID = chat.id;
const isActiveChat = activeChatId === chatID;
@@ -483,212 +492,232 @@ const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
const isRegeneratingThisChat = regeneratingTitleChatIds.includes(chat.id);
const isExpanded = normalizedSearch ? true : (expandedById[chatID] ?? false);
return (
<div className="flex min-w-0 flex-col">
<div
data-testid={`agents-tree-node-${chat.id}`}
className={cn(
"group relative flex min-w-0 items-start gap-1.5 rounded-md pl-1 pr-1.5 text-content-secondary",
"transition-none [@media(hover:hover)]:hover:bg-surface-tertiary/50 [@media(hover:hover)]:hover:text-content-primary has-[[data-state=open]]:bg-surface-tertiary",
"has-[[aria-current=page]]:bg-surface-quaternary/25 has-[[aria-current=page]]:text-content-primary [@media(hover:hover)]:has-[[aria-current=page]]:hover:bg-surface-quaternary/50",
isChildNode &&
"before:absolute before:-left-2.5 before:top-[17px] before:h-px before:w-2.5 before:bg-border-default/70",
)}
>
<div
className={cn(
"group/icon relative mt-1.5 h-5 w-5 shrink-0",
hasChildren && "cursor-pointer",
)}
const renderMenuItems = ({
Item,
Separator,
}: {
Item: typeof DropdownMenuItem | typeof ContextMenuItem;
Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator;
}) => (
<>
{!chat.archived && !isChildNode && (
<Item
onSelect={() =>
chat.pin_order > 0 ? onUnpinAgent(chat.id) : onPinAgent(chat.id)
}
>
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded-md",
hasChildren && "[@media(hover:hover)]:group-hover/icon:invisible",
)}
>
<StatusIcon
data-testid={
isDelegatedExecuting
? `agents-tree-executing-${chat.id}`
: undefined
}
className={cn("h-3.5 w-3.5 shrink-0", config.className)}
/>
</div>
{hasChildren && (
<Button
variant="subtle"
size="icon"
onClick={() => toggleExpanded(chatID)}
className={cn(
"absolute inset-0 invisible flex h-5 w-5 min-w-0 items-center justify-center rounded-md p-0 text-content-secondary/60 hover:text-content-primary [&>svg]:size-3.5",
"[@media(hover:hover)]:group-hover/icon:visible",
)}
data-testid={`agents-tree-toggle-${chat.id}`}
aria-label={isExpanded ? "Collapse" : "Expand"}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
</Button>
)}
</div>
<NavLink
to={`/agents/${chat.id}`}
className="flex min-h-0 min-w-0 flex-1 items-start gap-2 rounded-[inherit] py-1 pr-0.5 text-inherit no-underline"
>
{({ isActive }) => (
{chat.pin_order > 0 ? (
<>
<div className="min-w-0 flex-1 overflow-hidden text-left">
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
<span
aria-busy={isRegeneratingThisChat}
className={cn(
"block flex-1 truncate text-[13px] text-content-primary",
isActive && "font-medium",
// Pulse-only in sidebar (no spinner) — space-constrained card layout.
isRegeneratingThisChat && "animate-pulse",
)}
>
{chat.title}
</span>
{chat.has_unread && !isActiveChat && (
<span className="sr-only">(unread)</span>
)}
{isRegeneratingThisChat && (
<span className="sr-only" role="status">
Regenerating title…
</span>
)}
</div>
<div className="flex min-w-0 items-center gap-1.5">
{hasLinkedDiffStatus && hasLineStats && (
<span
className="inline-flex shrink-0 items-center gap-0.5 text-[13px] leading-4 tabular-nums"
title={`${filesChangedLabel}, +${additions} -${deletions}`}
>
<span className="text-git-added-bright">
+{additions}
</span>
<span className="text-git-deleted-bright">
&minus;{deletions}
</span>
</span>
)}
<div
className={cn(
"min-w-0 overflow-hidden text-[13px] leading-4",
errorReason
? "line-clamp-1 whitespace-normal text-content-destructive [overflow-wrap:anywhere]"
: "truncate text-content-secondary",
)}
title={subtitle}
>
{subtitle}
</div>
</div>
</div>
<PinOffIcon className="h-3.5 w-3.5" />
Unpin agent
</>
)}
</NavLink>
<div className="relative mt-1 flex h-6 w-7 shrink-0 items-center justify-end">
{isArchivingThisChat ? (
<Spinner className="h-3.5 w-3.5 text-content-secondary" loading />
) : (
<>
<span className="flex items-center justify-end text-xs text-content-secondary/50 tabular-nums [@media(hover:hover)]:group-hover:hidden group-has-[[data-state=open]]:hidden">
{chat.has_unread && !isActiveChat ? (
<span
className="h-2 w-2 shrink-0 rounded-full bg-content-link"
data-testid={`unread-indicator-${chat.id}`}
aria-hidden="true"
/>
) : (
shortRelativeTime(chat.updated_at)
)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Open actions for ${chat.title}`}
>
<EllipsisIcon className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="[&_[role=menuitem]]:text-[13px]"
>
{!chat.archived && !isChildNode && (
<DropdownMenuItem
onSelect={() =>
chat.pin_order > 0
? onUnpinAgent(chat.id)
: onPinAgent(chat.id)
}
>
{chat.pin_order > 0 ? (
<>
<PinOffIcon className="h-3.5 w-3.5" />
Unpin agent
</>
) : (
<>
<PinIcon className="h-3.5 w-3.5" />
Pin agent
</>
)}
</DropdownMenuItem>
)}
{chat.archived ? (
<DropdownMenuItem
disabled={isArchiving}
onSelect={() => onUnarchiveAgent(chat.id)}
>
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
Unarchive agent
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
disabled={isRegeneratingThisChat}
onSelect={() => onRegenerateTitle(chat.id)}
>
<WandSparklesIcon className="h-3.5 w-3.5" />
Generate new title
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() => onArchiveAgent(chat.id)}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive agent
</DropdownMenuItem>
{workspaceId && (
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() =>
onArchiveAndDeleteWorkspace(chat.id, workspaceId)
}
>
<Trash2Icon className="h-3.5 w-3.5" />
Archive & delete workspace
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<PinIcon className="h-3.5 w-3.5" />
Pin agent
</>
)}
</div>
</div>
</Item>
)}
{chat.archived ? (
<Item disabled={isArchiving} onSelect={() => onUnarchiveAgent(chat.id)}>
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
Unarchive agent
</Item>
) : (
<>
{onOpenRenameDialog && (
<Item onSelect={() => onOpenRenameDialog(chat)}>
<SquarePenIcon className="h-3.5 w-3.5" />
Rename chat
</Item>
)}
<Separator />
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() => onArchiveAgent(chat.id)}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive agent
</Item>
{workspaceId && (
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() => onArchiveAndDeleteWorkspace(chat.id, workspaceId)}
>
<Trash2Icon className="h-3.5 w-3.5" />
Archive & delete workspace
</Item>
)}
</>
)}
</>
);
return (
<div className="flex min-w-0 flex-col">
<ContextMenu>
<ContextMenuTrigger asChild>
<div
data-testid={`agents-tree-node-${chat.id}`}
className={cn(
"group relative flex min-w-0 items-start gap-1.5 rounded-md pl-1 pr-1.5 text-content-secondary",
"transition-none [@media(hover:hover)]:hover:bg-surface-tertiary/50 [@media(hover:hover)]:hover:text-content-primary has-[[data-state=open]]:bg-surface-tertiary",
"has-[[aria-current=page]]:bg-surface-quaternary/25 has-[[aria-current=page]]:text-content-primary [@media(hover:hover)]:has-[[aria-current=page]]:hover:bg-surface-quaternary/50",
isChildNode &&
"before:absolute before:-left-2.5 before:top-[17px] before:h-px before:w-2.5 before:bg-border-default/70",
)}
>
<div
className={cn(
"group/icon relative mt-1.5 h-5 w-5 shrink-0",
hasChildren && "cursor-pointer",
)}
>
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded-md",
hasChildren &&
"[@media(hover:hover)]:group-hover/icon:invisible",
)}
>
<StatusIcon
data-testid={
isDelegatedExecuting
? `agents-tree-executing-${chat.id}`
: undefined
}
className={cn("h-3.5 w-3.5 shrink-0", config.className)}
/>
</div>
{hasChildren && (
<Button
variant="subtle"
size="icon"
onClick={() => toggleExpanded(chatID)}
className={cn(
"absolute inset-0 invisible flex h-5 w-5 min-w-0 items-center justify-center rounded-md p-0 text-content-secondary/60 hover:text-content-primary [&>svg]:size-3.5",
"[@media(hover:hover)]:group-hover/icon:visible",
)}
data-testid={`agents-tree-toggle-${chat.id}`}
aria-label={isExpanded ? "Collapse" : "Expand"}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
</Button>
)}
</div>
<NavLink
to={`/agents/${chat.id}`}
className="flex min-h-0 min-w-0 flex-1 items-start gap-2 rounded-[inherit] py-1 pr-0.5 text-inherit no-underline"
>
{({ isActive }) => (
<>
<div className="min-w-0 flex-1 overflow-hidden text-left">
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
<span
aria-busy={isRegeneratingThisChat}
className={cn(
"block flex-1 truncate text-[13px] text-content-primary",
isActive && "font-medium",
isRegeneratingThisChat && "animate-pulse",
)}
>
{chat.title}
</span>
{chat.has_unread && !isActiveChat && (
<span className="sr-only">(unread)</span>
)}
{isRegeneratingThisChat && (
<span className="sr-only" role="status">
Regenerating title…
</span>
)}
</div>
<div className="flex min-w-0 items-center gap-1.5">
{hasLinkedDiffStatus && hasLineStats && (
<span
className="inline-flex shrink-0 items-center gap-0.5 text-[13px] leading-4 tabular-nums"
title={`${filesChangedLabel}, +${additions} -${deletions}`}
>
<span className="text-git-added-bright">
+{additions}
</span>
<span className="text-git-deleted-bright">
&minus;{deletions}
</span>
</span>
)}
<div
className={cn(
"min-w-0 overflow-hidden text-[13px] leading-4",
errorReason
? "line-clamp-1 whitespace-normal text-content-destructive [overflow-wrap:anywhere]"
: "truncate text-content-secondary",
)}
title={subtitle}
>
{subtitle}
</div>
</div>
</div>
</>
)}
</NavLink>
<div className="relative mt-1 flex h-6 w-7 shrink-0 items-center justify-end">
{isArchivingThisChat ? (
<Spinner
className="h-3.5 w-3.5 text-content-secondary"
loading
/>
) : (
<>
<span className="flex items-center justify-end text-xs text-content-secondary/50 tabular-nums [@media(hover:hover)]:group-hover:hidden group-has-[[data-state=open]]:hidden">
{chat.has_unread && !isActiveChat ? (
<span
className="h-2 w-2 shrink-0 rounded-full bg-content-link"
data-testid={`unread-indicator-${chat.id}`}
aria-hidden="true"
/>
) : (
shortRelativeTime(chat.updated_at)
)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Open actions for ${chat.title}`}
>
<EllipsisIcon className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="[&_[role=menuitem]]:text-[13px]"
>
{renderMenuItems({
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
})}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="[&_[role=menuitem]]:text-[13px]">
{renderMenuItems({
Item: ContextMenuItem,
Separator: ContextMenuSeparator,
})}
</ContextMenuContent>
</ContextMenu>
{hasChildren && isExpanded && (
<div className="relative ml-4 border-l border-border-default/60 pl-2.5">
@@ -760,7 +789,8 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
onPinAgent,
onUnpinAgent,
onReorderPinnedAgent,
onRegenerateTitle,
onRenameTitle,
onProposeTitle,
onBeforeNewAgent,
isCreating,
isArchiving = false,
@@ -796,6 +826,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
isAdmin || isApiKeysSection || Boolean(providerConfigsQuery.data?.length);
const normalizedSearch = "";
const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
const [chatPendingRename, setChatPendingRename] = useState<Chat | null>(null);
const chatTree = buildChatTree(chats);
const chatById = chatTree.chatById;
@@ -991,7 +1022,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
onArchiveAndDeleteWorkspace,
onPinAgent,
onUnpinAgent,
onRegenerateTitle,
onOpenRenameDialog: onRenameTitle ? setChatPendingRename : undefined,
};
const subNavTitle = "Settings";
@@ -1349,6 +1380,16 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
</nav>
)}
</div>
{onRenameTitle && (
<RenameChatDialog
chat={chatPendingRename}
onRename={onRenameTitle}
onPropose={onProposeTitle}
onOpenChange={(open) => {
if (!open) setChatPendingRename(null);
}}
/>
)}
</div>
);
};
@@ -0,0 +1,199 @@
import { SparklesIcon } from "lucide-react";
import { type FC, useEffect, useId, useRef, useState } from "react";
import { getErrorMessage } from "#/api/errors";
import type { Chat } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
import { Input } from "#/components/Input/Input";
import { Spinner } from "#/components/Spinner/Spinner";
type RenameChatDialogProps = {
readonly chat: Chat | null;
readonly onRename: (chatId: string, title: string) => Promise<void>;
readonly onPropose?: (chatId: string) => Promise<string>;
readonly onOpenChange: (open: boolean) => void;
};
export const RenameChatDialog: FC<RenameChatDialogProps> = ({
chat,
onRename,
onPropose,
onOpenChange,
}) => {
const [renameTitle, setRenameTitle] = useState("");
const [isRenamingChat, setIsRenamingChat] = useState(false);
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
const [generateTitleError, setGenerateTitleError] = useState<string | null>(
null,
);
const inputRef = useRef<HTMLInputElement | null>(null);
const sessionRef = useRef(0);
const inputId = useId();
const errorId = `${inputId}-error`;
const currentChatId = chat?.id ?? null;
const [prevChatId, setPrevChatId] = useState<string | null>(null);
if (currentChatId !== prevChatId) {
setPrevChatId(currentChatId);
if (chat) {
setRenameTitle(chat.title);
setGenerateTitleError(null);
setIsGeneratingTitle(false);
}
}
useEffect(() => {
if (prevChatId === null) return;
sessionRef.current += 1;
}, [prevChatId]);
const handleGenerate = async () => {
if (!chat || !onPropose) return;
const requestedSession = sessionRef.current;
setIsGeneratingTitle(true);
setGenerateTitleError(null);
try {
const newTitle = await onPropose(chat.id);
if (sessionRef.current !== requestedSession) return;
setRenameTitle(newTitle);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
setIsGeneratingTitle(false);
} catch (error) {
if (sessionRef.current !== requestedSession) return;
setGenerateTitleError(
getErrorMessage(error, "Failed to generate a new title."),
);
setIsGeneratingTitle(false);
}
};
const handleSubmit = async () => {
if (!chat) return;
const trimmedTitle = renameTitle.trim();
if (!trimmedTitle) {
onOpenChange(false);
return;
}
setIsRenamingChat(true);
await onRename(chat.id, trimmedTitle)
.then(() => {
onOpenChange(false);
})
.catch(() => {});
setIsRenamingChat(false);
};
return (
<Dialog
open={chat !== null}
onOpenChange={(open) => {
// Block closes (escape / outside click) while a rename is in
// flight; the submit handler will close on success.
if (!open && isRenamingChat) return;
onOpenChange(open);
}}
>
<DialogContent
onOpenAutoFocus={(event) => {
event.preventDefault();
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}}
className="max-w-[440px] p-6 sm:p-6"
aria-describedby={undefined}
>
<DialogHeader className="flex-row items-center justify-between space-y-0 sm:flex-row">
<DialogTitle className="text-lg">Rename chat</DialogTitle>
{onPropose && (
<Button
type="button"
variant="subtle"
size="sm"
className="h-auto min-w-0 gap-1 px-2 py-1.5 text-xs font-normal"
onClick={() => {
void handleGenerate();
}}
disabled={isRenamingChat || isGeneratingTitle}
>
{isGeneratingTitle ? (
<Spinner className="h-[18px] w-[18px]" loading />
) : (
<SparklesIcon className="h-[18px] w-[18px]" />
)}
Generate
</Button>
)}
</DialogHeader>
<form
className="flex flex-col gap-6"
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
>
<div className="space-y-1.5">
<Input
id={inputId}
ref={inputRef}
value={renameTitle}
onChange={(event) => {
setRenameTitle(event.target.value);
if (generateTitleError) {
setGenerateTitleError(null);
}
}}
disabled={isRenamingChat || isGeneratingTitle}
maxLength={200}
aria-label="Chat title"
aria-invalid={generateTitleError ? true : undefined}
aria-describedby={generateTitleError ? errorId : undefined}
/>
{generateTitleError && (
<p
id={errorId}
role="alert"
className="m-0 text-xs text-content-destructive"
>
{generateTitleError}
</p>
)}
</div>
<DialogFooter className="gap-2 sm:space-x-0">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isRenamingChat}
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={
!renameTitle.trim() ||
renameTitle.trim() === chat?.title ||
isRenamingChat ||
isGeneratingTitle
}
>
{isRenamingChat && <Spinner className="h-4 w-4" loading />}
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};