Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a58f8b8f6 | |||
| b0d34998ef | |||
| c6557a45a1 | |||
| b435b006ae | |||
| eb0bfcad5c | |||
| 9cb7db9678 | |||
| 7dd9f63226 | |||
| fae0a46cd6 |
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user