mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: allow renaming of agent chat title (#24489)
Co-authored-by: Coder Agents <noreply@coder.com>
This commit is contained in:
co-authored by
Coder Agents
parent
18a30a7a10
commit
410f9a5e19
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Generated
+8
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
−{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">
|
||||
−{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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user