Compare commits

...

8 Commits

Author SHA1 Message Date
TJ 8a58f8b8f6 Merge branch 'main' into feat/editable-chat-title 2026-04-09 17:55:57 -07:00
Tracy Johnson b0d34998ef fix(site): remove Generate new title label, keep bare wand icon 2026-04-08 21:25:57 +00:00
Tracy Johnson c6557a45a1 fix(site): show 'Generate new title' label next to wand icon on hover 2026-04-08 21:25:04 +00:00
Tracy Johnson b435b006ae fix(site): nudge Saved indicator down 1px to match title baseline 2026-04-08 21:22:35 +00:00
Tracy Johnson eb0bfcad5c fix(site): align Saved indicator baseline with chat title 2026-04-08 21:19:43 +00:00
Tracy Johnson 9cb7db9678 fix(site): use bare wand icon instead of styled button for title regen 2026-04-08 21:04:37 +00:00
Tracy Johnson 7dd9f63226 refactor(site): replace pencil icon with AI wand for title regeneration
Swap the hover pencil icon next to the chat title with the existing
WandSparklesIcon. Clicking it triggers onRegenerateTitle instead of
being decorative.
2026-04-08 21:02:26 +00:00
Tracy Johnson fae0a46cd6 feat(site): add inline-editable chat titles in top bar
Click the chat title in the top bar to edit it inline. Press Enter to
save or Escape to cancel. A subtle pencil icon appears on hover, and a
brief 'Saved' indicator with a save icon confirms the update.

Backend: wire req.Title handling in patchChat so PATCH
/api/experimental/chats/{id} persists title changes.

Frontend: add updateChatTitle mutation with optimistic updates, thread
onUpdateTitle through AgentChatPage -> AgentChatPageView -> ChatTopBar.
2026-04-08 20:54:41 +00:00
5 changed files with 235 additions and 11 deletions
+26
View File
@@ -1755,6 +1755,32 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
chat = updatedChat
}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Title cannot be empty.",
})
return
}
updatedChat, err := api.Database.UpdateChatByID(ctx, database.UpdateChatByIDParams{
ID: chat.ID,
Title: title,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat title.",
Detail: err.Error(),
})
return
}
chat = updatedChat
}
if req.Archived != nil {
archived := *req.Archived
if archived == chat.Archived {
+61
View File
@@ -577,6 +577,67 @@ export const regenerateChatTitle = (queryClient: QueryClient) => ({
},
});
export const updateChatTitle = (queryClient: QueryClient) => ({
mutationFn: ({ chatId, title }: { chatId: string; title: string }) =>
API.experimental.updateChat(chatId, { title }),
onMutate: async ({ chatId, title }: { chatId: string; title: string }) => {
await queryClient.cancelQueries({
queryKey: chatsKey,
predicate: isChatListQuery,
});
await queryClient.cancelQueries({
queryKey: chatKey(chatId),
exact: true,
});
const previousChat = queryClient.getQueryData<TypesGen.Chat>(
chatKey(chatId),
);
const previousTitle = previousChat?.title;
queryClient.setQueryData<TypesGen.Chat>(chatKey(chatId), (old) =>
old ? { ...old, title } : old,
);
updateInfiniteChatsCache(queryClient, (chats) =>
chats.map((chat) => (chat.id === chatId ? { ...chat, title } : chat)),
);
return { previousTitle };
},
onError: (
_error: unknown,
{ chatId }: { chatId: string; title: string },
context: { previousTitle?: string } | undefined,
) => {
if (context?.previousTitle !== undefined) {
queryClient.setQueryData<TypesGen.Chat>(chatKey(chatId), (old) =>
old ? { ...old, title: context.previousTitle! } : old,
);
updateInfiniteChatsCache(queryClient, (chats) =>
chats.map((chat) =>
chat.id === chatId
? { ...chat, title: context.previousTitle! }
: chat,
),
);
}
},
onSettled: async (
_data: unknown,
_error: unknown,
{ chatId }: { chatId: string; title: string },
) => {
await invalidateChatListQueries(queryClient);
await queryClient.invalidateQueries({
queryKey: chatKey(chatId),
exact: true,
});
},
});
export const createChat = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.CreateChatRequest) =>
API.experimental.createChat(req),
+20 -1
View File
@@ -1,4 +1,11 @@
import { type FC, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
type FC,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
useInfiniteQuery,
@@ -23,6 +30,7 @@ import {
interruptChat,
mcpServerConfigs,
promoteChatQueuedMessage,
updateChatTitle,
userCompactionThresholds,
} from "#/api/queries/chats";
import { deploymentSSHConfig } from "#/api/queries/deployment";
@@ -1094,6 +1102,16 @@ const AgentChatPage: FC = () => {
onRegenerateTitle(agentId);
};
const updateTitleMutation = useMutation(updateChatTitle(queryClient));
const handleUpdateTitle = useCallback(
(title: string) => {
if (!agentId) return;
updateTitleMutation.mutate({ chatId: agentId, title });
},
[agentId, updateTitleMutation],
);
if (chatQuery.isLoading || chatMessagesQuery.isLoading) {
return (
<AgentChatPageLoadingView
@@ -1168,6 +1186,7 @@ const AgentChatPage: FC = () => {
handleArchiveAndDeleteWorkspaceAction
}
handleRegenerateTitle={handleRegenerateTitle}
onUpdateTitle={handleUpdateTitle}
isRegeneratingTitle={isRegeneratingThisChat}
isRegenerateTitleDisabled={isRegenerateTitleDisabled}
urlTransform={urlTransform}
@@ -143,6 +143,7 @@ interface AgentChatPageViewProps {
handleUnarchiveAgentAction: () => void;
handleArchiveAndDeleteWorkspaceAction: () => void;
handleRegenerateTitle?: () => void;
onUpdateTitle?: (title: string) => void;
isRegeneratingTitle?: boolean;
isRegenerateTitleDisabled?: boolean;
@@ -212,6 +213,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
handleUnarchiveAgentAction,
handleArchiveAndDeleteWorkspaceAction,
handleRegenerateTitle,
onUpdateTitle,
isRegeneratingTitle,
isRegenerateTitleDisabled,
scrollContainerRef,
@@ -347,6 +349,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
{...(handleRegenerateTitle
? { onRegenerateTitle: handleRegenerateTitle }
: {})}
onUpdateTitle={onUpdateTitle}
isRegeneratingTitle={isRegeneratingTitle}
isRegenerateTitleDisabled={isRegenerateTitleDisabled}
hasWorkspace={Boolean(workspace)}
@@ -10,11 +10,12 @@ import {
PanelLeftIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
SaveIcon,
TerminalIcon,
Trash2Icon,
WandSparklesIcon,
} from "lucide-react";
import type { FC } from "react";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import type * as TypesGen from "#/api/typesGenerated";
@@ -55,6 +56,7 @@ type ChatTopBarProps = {
onArchiveAgent: () => void;
onUnarchiveAgent: () => void;
onArchiveAndDeleteWorkspace: () => void;
onUpdateTitle?: (title: string) => void;
onRegenerateTitle?: () => void;
isRegeneratingTitle?: boolean;
isRegenerateTitleDisabled?: boolean;
@@ -73,6 +75,7 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
onArchiveAgent,
onUnarchiveAgent,
onArchiveAndDeleteWorkspace,
onUpdateTitle,
onRegenerateTitle,
isRegeneratingTitle,
isRegenerateTitleDisabled,
@@ -84,6 +87,52 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
}) => {
const { isEmbedded } = useEmbedContext();
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [showSaved, setShowSaved] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const savedTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const startEditing = useCallback(() => {
if (!onUpdateTitle || isRegeneratingTitle) return;
setDraftTitle(chatTitle ?? "");
setIsEditingTitle(true);
}, [onUpdateTitle, isRegeneratingTitle, chatTitle]);
const commitEdit = useCallback(() => {
const trimmed = draftTitle.trim();
if (trimmed && trimmed !== chatTitle) {
onUpdateTitle?.(trimmed);
if (savedTimeoutRef.current) {
clearTimeout(savedTimeoutRef.current);
}
setShowSaved(true);
savedTimeoutRef.current = setTimeout(() => {
setShowSaved(false);
}, 2000);
}
setIsEditingTitle(false);
}, [draftTitle, chatTitle, onUpdateTitle]);
const cancelEdit = useCallback(() => {
setIsEditingTitle(false);
}, []);
useEffect(() => {
if (isEditingTitle && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditingTitle]);
useEffect(() => {
return () => {
if (savedTimeoutRef.current) {
clearTimeout(savedTimeoutRef.current);
}
};
}, []);
const prUrl = diffStatusData?.url;
const prState = diffStatusData?.pull_request_state;
const prDraft = diffStatusData?.pull_request_draft;
@@ -127,8 +176,9 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
role="status"
aria-live="polite"
aria-busy={isRegeneratingTitle}
className="flex min-w-0 items-center gap-1.5"
className="group/title flex min-w-0 flex-1 items-center gap-1.5"
>
{" "}
{parentChat && (
<>
<Button
@@ -144,14 +194,79 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
<ChevronRightIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary/70 -ml-0.5" />
</>
)}
<span
className={cn(
"truncate text-sm text-content-primary",
isRegeneratingTitle && "animate-pulse",
)}
>
{chatTitle}
</span>
{onUpdateTitle && !isRegeneratingTitle ? (
isEditingTitle ? (
<input
ref={inputRef}
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
commitEdit();
} else if (e.key === "Escape") {
cancelEdit();
}
}}
onBlur={commitEdit}
className="min-w-0 flex-1 text-sm text-content-primary bg-transparent border-none outline-none p-0 m-0 font-inherit"
/>
) : (
<>
<span
role="textbox"
tabIndex={0}
onClick={startEditing}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
startEditing();
}
}}
className="truncate text-sm text-content-primary cursor-text"
>
{chatTitle}
</span>
{!showSaved && onRegenerateTitle && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
if (!isRegenerateTitleDisabled) onRegenerateTitle();
}}
onKeyDown={(e) => {
if (
(e.key === "Enter" || e.key === " ") &&
!isRegenerateTitleDisabled
) {
e.preventDefault();
onRegenerateTitle();
}
}}
aria-label="Generate new title"
className="shrink-0 cursor-pointer text-content-secondary opacity-0 transition-opacity group-hover/title:opacity-100 hover:text-content-primary"
>
<WandSparklesIcon className="h-3.5 w-3.5" />
</span>
)}{" "}
</>
)
) : (
<span
className={cn(
"truncate text-sm text-content-primary",
isRegeneratingTitle && "animate-pulse",
)}
>
{chatTitle}
</span>
)}
{showSaved && (
<span className="flex shrink-0 items-center gap-1 text-xs leading-5 translate-y-px text-content-secondary animate-in fade-in">
Saved
<SaveIcon className="h-3 w-3" />
</span>
)}
{isRegeneratingTitle && (
<Spinner
aria-label="Regenerating title"