From bfeb91d9cdce782bdb3721ed52f09fb34f5a489c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:01:53 +0100 Subject: [PATCH] fix: scope title regeneration per chat (#23729) Previously, generating a new agent title used a page-global pending state, so one in-flight regeneration disabled the action for every chat in the Agents UI. This change tracks regenerations by chat ID, updates the Agents page contracts to use `regeneratingTitleChatIds`, and adds sidebar story coverage that proves only the active chat is disabled. --- coderd/exp_chats_test.go | 7 +- coderd/x/chatd/chatd.go | 26 ++- coderd/x/chatd/chatd_internal_test.go | 211 ++++++++++++++++-- .../AgentsPage/AgentChatPage.stories.tsx | 3 +- site/src/pages/AgentsPage/AgentChatPage.tsx | 10 +- site/src/pages/AgentsPage/AgentEmbedPage.tsx | 3 +- site/src/pages/AgentsPage/AgentsPage.tsx | 39 +++- .../AgentsPage/AgentsPageView.stories.tsx | 68 +++--- site/src/pages/AgentsPage/AgentsPageView.tsx | 15 +- .../Sidebar/AgentsSidebar.stories.tsx | 61 ++++- .../components/Sidebar/AgentsSidebar.test.tsx | 3 +- .../components/Sidebar/AgentsSidebar.tsx | 20 +- 12 files changed, 358 insertions(+), 108 deletions(-) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 9f1666f1b1..f4b210537e 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3677,11 +3677,12 @@ func TestRegenerateChatTitle(t *testing.T) { ) require.NoError(t, err) defer res.Body.Close() - require.Equal(t, http.StatusConflict, res.StatusCode) + require.Equal(t, http.StatusOK, res.StatusCode) - var resp codersdk.Response + var resp codersdk.Chat require.NoError(t, json.NewDecoder(res.Body).Decode(&resp)) - require.Equal(t, "Title regeneration already in progress for this chat.", resp.Message) + require.Equal(t, chat.ID, resp.ID) + require.Equal(t, "pending chat without worker", resp.Title) persisted, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) require.NoError(t, err) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index d0b3ab8769..242623f78c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1520,15 +1520,6 @@ var manualTitleLockWorkerID = uuid.MustParse( const manualTitleLockStaleAfter = time.Minute -func isPendingOrRunningChatStatus(status database.ChatStatus) bool { - switch status { - case database.ChatStatusPending, database.ChatStatusRunning: - return true - default: - return false - } -} - func isFreshManualTitleLock(chat database.Chat, now time.Time) bool { if !chat.WorkerID.Valid || chat.WorkerID.UUID != manualTitleLockWorkerID { return false @@ -1571,17 +1562,28 @@ func (p *Server) acquireManualTitleLock(ctx context.Context, chatID uuid.UUID) e if err != nil { return xerrors.Errorf("lock chat for manual title regeneration: %w", err) } - if isPendingOrRunningChatStatus(lockedChat.Status) || - isFreshManualTitleLock(lockedChat, now) { + if isFreshManualTitleLock(lockedChat, now) { return ErrManualTitleRegenerationInProgress } + + // Only write the lock marker when no real worker owns WorkerID. + // When a real worker is running, we skip the DB lock but still + // allow regeneration. The frontend prevents same-browser + // double-clicks, and concurrent regeneration from different + // replicas is harmless, last write wins. + hasRealWorker := lockedChat.WorkerID.Valid && + lockedChat.WorkerID.UUID != manualTitleLockWorkerID + if hasRealWorker { + return nil + } + _, err = updateChatStatusPreserveUpdatedAt( ctx, tx, lockedChat, uuid.NullUUID{UUID: manualTitleLockWorkerID, Valid: true}, sql.NullTime{Time: now, Valid: true}, - sql.NullTime{Time: now, Valid: true}, + sql.NullTime{}, ) if err != nil { return xerrors.Errorf("mark chat for manual title regeneration: %w", err) diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index c71b2edd09..81772fcb05 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -47,6 +47,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { ownerID := uuid.New() chatID := uuid.New() modelConfigID := uuid.New() + workerID := uuid.New() userPrompt := "review pull request 23633 and fix review threads" wantTitle := "Review PR 23633" @@ -54,7 +55,8 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { ID: chatID, OwnerID: ownerID, LastModelConfigID: modelConfigID, - Status: database.ChatStatusCompleted, + Status: database.ChatStatusRunning, + WorkerID: uuid.NullUUID{UUID: workerID, Valid: true}, Title: fallbackChatTitle(userPrompt), } modelConfig := database.ChatModelConfig{ @@ -154,16 +156,6 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { ) lockTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(chat, nil) - lockTx.EXPECT().UpdateChatStatusPreserveUpdatedAt(gomock.Any(), gomock.AssignableToTypeOf(database.UpdateChatStatusPreserveUpdatedAtParams{})).DoAndReturn( - func(_ context.Context, arg database.UpdateChatStatusPreserveUpdatedAtParams) (database.Chat, error) { - require.Equal(t, chatID, arg.ID) - require.Equal(t, chat.Status, arg.Status) - require.Equal(t, uuid.NullUUID{UUID: manualTitleLockWorkerID, Valid: true}, arg.WorkerID) - require.True(t, arg.StartedAt.Valid) - require.True(t, arg.HeartbeatAt.Valid) - return chat, nil - }, - ) usageTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(chat, nil) usageTx.EXPECT().InsertChatMessages(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatMessagesParams{})).DoAndReturn( @@ -180,18 +172,195 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { Title: wantTitle, }).Return(updatedChat, nil) - lockedChatWithMarker := updatedChat - lockedChatWithMarker.WorkerID = uuid.NullUUID{UUID: manualTitleLockWorkerID, Valid: true} - unlockTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(lockedChatWithMarker, nil) - unlockTx.EXPECT().UpdateChatStatusPreserveUpdatedAt(gomock.Any(), gomock.AssignableToTypeOf(database.UpdateChatStatusPreserveUpdatedAtParams{})).DoAndReturn( - func(_ context.Context, arg database.UpdateChatStatusPreserveUpdatedAtParams) (database.Chat, error) { - require.Equal(t, chatID, arg.ID) - require.False(t, arg.WorkerID.Valid) - require.False(t, arg.StartedAt.Valid) - require.False(t, arg.HeartbeatAt.Valid) - return updatedChat, nil + unlockTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(updatedChat, nil) + + gotChat, err := server.RegenerateChatTitle(ctx, chat) + require.NoError(t, err) + require.Equal(t, updatedChat, gotChat) + + select { + case event := <-messageEvents: + require.NoError(t, event.err) + require.Equal(t, coderdpubsub.ChatEventKindTitleChange, event.payload.Kind) + require.Equal(t, chatID, event.payload.Chat.ID) + require.Equal(t, wantTitle, event.payload.Chat.Title) + case <-time.After(time.Second): + t.Fatal("timed out waiting for title change pubsub event") + } +} + +func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + lockTx := dbmock.NewMockStore(ctrl) + usageTx := dbmock.NewMockStore(ctrl) + unlockTx := dbmock.NewMockStore(ctrl) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + pubsub := dbpubsub.NewInMemory() + clock := quartz.NewReal() + + ownerID := uuid.New() + chatID := uuid.New() + modelConfigID := uuid.New() + userPrompt := "review pull request 23633 and fix review threads" + wantTitle := "Review PR 23633" + + chat := database.Chat{ + ID: chatID, + OwnerID: ownerID, + LastModelConfigID: modelConfigID, + Status: database.ChatStatusCompleted, + Title: fallbackChatTitle(userPrompt), + } + lockedChat := chat + lockedChat.WorkerID = uuid.NullUUID{UUID: manualTitleLockWorkerID, Valid: true} + lockedChat.StartedAt = sql.NullTime{Time: time.Now(), Valid: true} + modelConfig := database.ChatModelConfig{ + ID: modelConfigID, + Provider: "anthropic", + Model: "claude-haiku-4-5", + ContextLimit: 8192, + } + updatedChat := lockedChat + updatedChat.Title = wantTitle + unlockedChat := updatedChat + unlockedChat.WorkerID = uuid.NullUUID{} + unlockedChat.StartedAt = sql.NullTime{} + + messageEvents := make(chan struct { + payload coderdpubsub.ChatEvent + err error + }, 1) + cancelSub, err := pubsub.SubscribeWithErr( + coderdpubsub.ChatEventChannel(ownerID), + coderdpubsub.HandleChatEvent(func(_ context.Context, payload coderdpubsub.ChatEvent, err error) { + messageEvents <- struct { + payload coderdpubsub.ChatEvent + err error + }{payload: payload, err: err} + }), + ) + require.NoError(t, err) + defer cancelSub() + + serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { + require.Equal(t, "claude-haiku-4-5", req.Model) + return chattest.AnthropicNonStreamingResponse(wantTitle) + }) + + server := &Server{ + db: db, + logger: logger, + pubsub: pubsub, + configCache: newChatConfigCache(context.Background(), db, clock), + } + + db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil) + db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{{ + Provider: "anthropic", + APIKey: "test-key", + BaseUrl: serverURL, + }}, nil) + db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(database.ChatUsageLimitConfig{}, sql.ErrNoRows) + db.EXPECT().GetChatMessagesByChatIDAscPaginated( + gomock.Any(), + database.GetChatMessagesByChatIDAscPaginatedParams{ + ChatID: chatID, + AfterID: 0, + LimitVal: manualTitleMessageWindowLimit, + }, + ).Return([]database.ChatMessage{ + mustChatMessage( + t, + database.ChatMessageRoleUser, + database.ChatMessageVisibilityBoth, + codersdk.ChatMessageText(userPrompt), + ), + mustChatMessage( + t, + database.ChatMessageRoleAssistant, + database.ChatMessageVisibilityBoth, + codersdk.ChatMessageText("checking the diff now"), + ), + }, nil) + db.EXPECT().GetChatMessagesByChatIDDescPaginated( + gomock.Any(), + database.GetChatMessagesByChatIDDescPaginatedParams{ + ChatID: chatID, + BeforeID: 0, + LimitVal: manualTitleMessageWindowLimit, + }, + ).Return(nil, nil) + db.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return(nil, nil) + + gomock.InOrder( + db.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("chat_title_regenerate_lock")).DoAndReturn( + func(fn func(database.Store) error, opts *database.TxOptions) error { + require.Equal(t, "chat_title_regenerate_lock", opts.TxIdentifier) + return fn(lockTx) + }, + ), + db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn( + func(fn func(database.Store) error, opts *database.TxOptions) error { + require.Nil(t, opts) + return fn(usageTx) + }, + ), + db.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("chat_title_regenerate_unlock")).DoAndReturn( + func(fn func(database.Store) error, opts *database.TxOptions) error { + require.Equal(t, "chat_title_regenerate_unlock", opts.TxIdentifier) + return fn(unlockTx) + }, + ), + ) + + lockTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(chat, nil) + lockTx.EXPECT().UpdateChatStatusPreserveUpdatedAt( + gomock.Any(), + gomock.AssignableToTypeOf(database.UpdateChatStatusPreserveUpdatedAtParams{}), + ).DoAndReturn(func(_ context.Context, arg database.UpdateChatStatusPreserveUpdatedAtParams) (database.Chat, error) { + require.Equal(t, chat.ID, arg.ID) + require.Equal(t, chat.Status, arg.Status) + require.Equal(t, uuid.NullUUID{UUID: manualTitleLockWorkerID, Valid: true}, arg.WorkerID) + require.True(t, arg.StartedAt.Valid) + require.WithinDuration(t, time.Now(), arg.StartedAt.Time, time.Second) + require.False(t, arg.HeartbeatAt.Valid) + require.Equal(t, chat.LastError, arg.LastError) + require.Equal(t, chat.UpdatedAt, arg.UpdatedAt) + return lockedChat, nil + }) + + usageTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(lockedChat, nil) + usageTx.EXPECT().InsertChatMessages(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatMessagesParams{})).DoAndReturn( + func(_ context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { + require.Equal(t, []uuid.UUID{ownerID}, arg.CreatedBy) + require.Equal(t, []uuid.UUID{modelConfigID}, arg.ModelConfigID) + require.Equal(t, []string{"[]"}, arg.Content) + return []database.ChatMessage{{ID: 91}}, nil }, ) + usageTx.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), int64(91)).Return(nil) + usageTx.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{ + ID: chatID, + Title: wantTitle, + }).Return(updatedChat, nil) + + unlockTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(updatedChat, nil) + unlockTx.EXPECT().UpdateChatStatusPreserveUpdatedAt( + gomock.Any(), + database.UpdateChatStatusPreserveUpdatedAtParams{ + ID: updatedChat.ID, + Status: updatedChat.Status, + WorkerID: uuid.NullUUID{}, + StartedAt: sql.NullTime{}, + HeartbeatAt: sql.NullTime{}, + LastError: updatedChat.LastError, + UpdatedAt: updatedChat.UpdatedAt, + }, + ).Return(unlockedChat, nil) gotChat, err := server.RegenerateChatTitle(ctx, chat) require.NoError(t, err) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 00a1974422..d541491887 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -56,8 +56,7 @@ const AgentChatPageLayout: FC = () => { requestPinAgent: () => {}, requestUnpinAgent: () => {}, onRegenerateTitle: () => {}, - isRegeneratingTitle: false, - regeneratingTitleChatId: null, + regeneratingTitleChatIds: [], isSidebarCollapsed: false, onToggleSidebarCollapsed: () => {}, onExpandSidebar: () => {}, diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 53d0e221c9..10552a3c11 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -293,8 +293,7 @@ const AgentChatPage: FC = () => { requestArchiveAndDeleteWorkspace, requestUnarchiveAgent, onRegenerateTitle, - isRegeneratingTitle, - regeneratingTitleChatId, + regeneratingTitleChatIds, isSidebarCollapsed, onToggleSidebarCollapsed, onChatReady, @@ -329,8 +328,9 @@ const AgentChatPage: FC = () => { }); }; - const isRegeneratingThisChat = - isRegeneratingTitle && regeneratingTitleChatId === agentId; + const isRegeneratingThisChat = agentId + ? regeneratingTitleChatIds.includes(agentId) + : false; const chatQuery = useQuery({ ...chat(agentId ?? ""), @@ -486,7 +486,7 @@ const AgentChatPage: FC = () => { } : undefined; const isArchived = chatRecord?.archived ?? false; - const isRegenerateTitleDisabled = isArchived || isRegeneratingTitle; + const isRegenerateTitleDisabled = isArchived || isRegeneratingThisChat; const chatLastModelConfigID = chatRecord?.last_model_config_id; const sendMutation = useMutation( diff --git a/site/src/pages/AgentsPage/AgentEmbedPage.tsx b/site/src/pages/AgentsPage/AgentEmbedPage.tsx index 80b793641c..729d03cb4b 100644 --- a/site/src/pages/AgentsPage/AgentEmbedPage.tsx +++ b/site/src/pages/AgentsPage/AgentEmbedPage.tsx @@ -222,8 +222,7 @@ const AgentEmbedPage: FC = () => { requestUnpinAgent: () => {}, requestArchiveAndDeleteWorkspace, // Title regeneration is not supported in embed mode. - isRegeneratingTitle: false, - regeneratingTitleChatId: null, + regeneratingTitleChatIds: [], isSidebarCollapsed, onToggleSidebarCollapsed, onExpandSidebar: () => {}, diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 17a2fcc67e..0277cb8379 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -227,6 +227,10 @@ const AgentsPage: FC = () => { toast.error(getErrorMessage(error, "Failed to generate new title.")); }, }); + const regeneratingTitleChatIdsRef = useRef>(new Set()); + const [regeneratingTitleChatIds, setRegeneratingTitleChatIds] = useState< + readonly string[] + >([]); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [chatErrorReasons, setChatErrorReasons] = useState< Record @@ -366,11 +370,37 @@ const AgentsPage: FC = () => { const requestReorderPinnedAgent = (chatId: string, pinOrder: number) => { reorderPinnedChatMutation.mutate({ chatId, pinOrder }); }; - const requestRegenerateTitle = (chatId: string) => { - if (regenerateTitleMutation.isPending) { + const addRegeneratingTitleChatId = (chatId: string) => { + if (!chatId || regeneratingTitleChatIdsRef.current.has(chatId)) { + return false; + } + const next = new Set(regeneratingTitleChatIdsRef.current); + next.add(chatId); + regeneratingTitleChatIdsRef.current = next; + setRegeneratingTitleChatIds(Array.from(next)); + return true; + }; + const removeRegeneratingTitleChatId = (chatId: string) => { + if (!regeneratingTitleChatIdsRef.current.has(chatId)) { return; } - regenerateTitleMutation.mutate(chatId); + const next = new Set(regeneratingTitleChatIdsRef.current); + next.delete(chatId); + regeneratingTitleChatIdsRef.current = next; + setRegeneratingTitleChatIds(Array.from(next)); + }; + const requestRegenerateTitle = (chatId: string) => { + if (!addRegeneratingTitleChatId(chatId)) { + return; + } + void regenerateTitleMutation + .mutateAsync(chatId) + .catch(() => { + // The shared mutation onError already reports the failure. + }) + .finally(() => { + removeRegeneratingTitleChatId(chatId); + }); }; const handleToggleSidebarCollapsed = () => setIsSidebarCollapsed((prev) => !prev); @@ -660,8 +690,7 @@ const AgentsPage: FC = () => { requestUnpinAgent={requestUnpinAgent} requestReorderPinnedAgent={requestReorderPinnedAgent} onRegenerateTitle={requestRegenerateTitle} - isRegeneratingTitle={regenerateTitleMutation.isPending} - regeneratingTitleChatId={regenerateTitleMutation.variables ?? null} + regeneratingTitleChatIds={regeneratingTitleChatIds} onToggleSidebarCollapsed={handleToggleSidebarCollapsed} isAgentsAdmin={isAgentsAdmin} hasNextPage={chatsQuery.hasNextPage} diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index a3c60b49b8..662be1d85a 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import dayjs from "dayjs"; -import { useState } from "react"; +import { type ComponentProps, useState } from "react"; import { Navigate } from "react-router"; import { expect, @@ -237,6 +237,41 @@ const agentsRouting = { ], }; +const defaultArgs: ComponentProps = { + agentId: undefined, + chatList: [], + catalogModelOptions: defaultModelOptions, + modelConfigs: defaultModelConfigs, + logoUrl: "", + handleNewAgent: fn(), + isCreating: false, + isArchiving: false, + archivingChatId: undefined, + isChatsLoading: false, + chatsLoadError: null, + onRetryChatsLoad: fn(), + onCollapseSidebar: fn(), + isSidebarCollapsed: false, + onExpandSidebar: fn(), + chatErrorReasons: {}, + setChatErrorReason: fn(), + clearChatErrorReason: fn(), + requestArchiveAgent: fn(), + requestUnarchiveAgent: fn(), + requestArchiveAndDeleteWorkspace: fn(), + requestPinAgent: fn(), + requestUnpinAgent: fn(), + onRegenerateTitle: fn(), + regeneratingTitleChatIds: [], + onToggleSidebarCollapsed: fn(), + isAgentsAdmin: false, + archivedFilter: "active", + onArchivedFilterChange: fn(), + hasNextPage: false, + onLoadMore: fn(), + isFetchingNextPage: false, +}; + const meta: Meta = { title: "pages/AgentsPage/AgentsPageView", component: AgentsPageView, @@ -250,36 +285,7 @@ const meta: Meta = { routing: agentsRouting, }), }, - args: { - agentId: undefined, - chatList: [], - catalogModelOptions: defaultModelOptions, - modelConfigs: defaultModelConfigs, - logoUrl: "", - handleNewAgent: fn(), - isCreating: false, - isArchiving: false, - archivingChatId: undefined, - isChatsLoading: false, - chatsLoadError: null, - onRetryChatsLoad: fn(), - onCollapseSidebar: fn(), - isSidebarCollapsed: false, - onExpandSidebar: fn(), - chatErrorReasons: {}, - setChatErrorReason: fn(), - clearChatErrorReason: fn(), - requestArchiveAgent: fn(), - requestUnarchiveAgent: fn(), - requestArchiveAndDeleteWorkspace: fn(), - onToggleSidebarCollapsed: fn(), - isAgentsAdmin: false, - archivedFilter: "active" as const, - onArchivedFilterChange: fn(), - hasNextPage: false, - onLoadMore: fn(), - isFetchingNextPage: false, - }, + args: defaultArgs, beforeEach: () => { spyOn(API, "getWorkspaces").mockResolvedValue({ workspaces: [], diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 40bfce700e..c5ab984fe9 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -24,8 +24,7 @@ export interface AgentsOutletContext { requestUnpinAgent: (chatId: string) => void; requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; onRegenerateTitle?: (chatId: string) => void; - isRegeneratingTitle: boolean; - regeneratingTitleChatId: string | null; + regeneratingTitleChatIds: readonly string[]; isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; onExpandSidebar: () => void; @@ -63,8 +62,7 @@ interface AgentsPageViewProps { requestUnpinAgent: (chatId: string) => void; requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; onRegenerateTitle: (chatId: string) => void; - isRegeneratingTitle: boolean; - regeneratingTitleChatId: string | null; + regeneratingTitleChatIds: readonly string[]; onToggleSidebarCollapsed: () => void; isAgentsAdmin: boolean; hasNextPage: boolean | undefined; @@ -100,8 +98,7 @@ export const AgentsPageView: FC = ({ requestUnpinAgent, requestReorderPinnedAgent, onRegenerateTitle, - isRegeneratingTitle, - regeneratingTitleChatId, + regeneratingTitleChatIds, onToggleSidebarCollapsed, isAgentsAdmin, hasNextPage, @@ -143,8 +140,7 @@ export const AgentsPageView: FC = ({ requestUnpinAgent, requestReorderPinnedAgent, onRegenerateTitle, - isRegeneratingTitle, - regeneratingTitleChatId, + regeneratingTitleChatIds, isSidebarCollapsed, onToggleSidebarCollapsed, onExpandSidebar, @@ -179,8 +175,7 @@ export const AgentsPageView: FC = ({ onUnpinAgent={requestUnpinAgent} onReorderPinnedAgent={requestReorderPinnedAgent} onRegenerateTitle={onRegenerateTitle} - isRegeneratingTitle={isRegeneratingTitle} - regeneratingTitleChatId={regeneratingTitleChatId} + regeneratingTitleChatIds={regeneratingTitleChatIds} onBeforeNewAgent={handleNewAgent} isCreating={isCreating} isArchiving={isArchiving} diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx index 6bd3f60180..d50f55ecf1 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx @@ -78,8 +78,7 @@ const meta: Meta = { onRegenerateTitle: fn(), onBeforeNewAgent: fn(), isCreating: false, - isRegeneratingTitle: false, - regeneratingTitleChatId: null, + regeneratingTitleChatIds: [], archivedFilter: "active" as const, onArchivedFilterChange: fn(), }, @@ -325,6 +324,64 @@ export const ActiveChatAncestryExpanded: Story = { // without embedding a literal date that drifts across calendar days. const recentTimestamp = new Date(Date.now() - 60_000).toISOString(); +export const RegeneratingTitleDisablesOnlyActiveChat: Story = { + args: { + chats: [ + buildChat({ + id: "regenerating-chat", + title: "Regenerating agent", + updated_at: recentTimestamp, + }), + buildChat({ + id: "idle-chat", + title: "Idle agent", + updated_at: recentTimestamp, + }), + ], + regeneratingTitleChatIds: ["regenerating-chat"], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await expect(canvas.getByText("Regenerating agent")).toHaveAttribute( + "aria-busy", + "true", + ); + + await userEvent.click( + canvas.getByRole("button", { + name: "Open actions for Regenerating agent", + }), + ); + await expect( + await body.findByRole("menuitem", { name: "Generate new title" }), + ).toHaveAttribute("data-disabled"); + + await userEvent.keyboard("{Escape}"); + await waitFor(() => { + expect( + body.queryByRole("menuitem", { name: "Generate new title" }), + ).not.toBeInTheDocument(); + }); + + await userEvent.click( + canvas.getByRole("button", { + name: "Open actions for Idle agent", + }), + ); + await expect( + await body.findByRole("menuitem", { name: "Generate new title" }), + ).not.toHaveAttribute("data-disabled"); + }, +}; + export const ActiveFilterShowsActiveAgents: Story = { args: { chats: [ diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx index 2634224bc3..3cf5e2809c 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx @@ -108,8 +108,7 @@ const defaultProps: React.ComponentProps = { onPinAgent: vi.fn(), onUnpinAgent: vi.fn(), onRegenerateTitle: vi.fn(), - isRegeneratingTitle: false, - regeneratingTitleChatId: null, + regeneratingTitleChatIds: [], onBeforeNewAgent: vi.fn(), isCreating: false, archivedFilter: "active" as const, diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index b6b0c977f0..c0b23f2524 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -130,8 +130,7 @@ interface AgentsSidebarProps { isCreating: boolean; isArchiving?: boolean; archivingChatId?: string | null; - isRegeneratingTitle?: boolean; - regeneratingTitleChatId?: string | null; + regeneratingTitleChatIds: readonly string[]; isLoading?: boolean; loadError?: unknown; onRetryLoad?: () => void; @@ -373,8 +372,7 @@ interface ChatTreeContextValue { readonly activeChatId: string | undefined; readonly isArchiving: boolean; readonly archivingChatId: string | null; - readonly isRegeneratingTitle: boolean; - readonly regeneratingTitleChatId: string | null; + readonly regeneratingTitleChatIds: readonly string[]; readonly toggleExpanded: (chatID: string) => void; readonly onArchiveAgent: (chatId: string) => void; readonly onUnarchiveAgent: (chatId: string) => void; @@ -415,8 +413,7 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { activeChatId, isArchiving, archivingChatId, - isRegeneratingTitle, - regeneratingTitleChatId, + regeneratingTitleChatIds, toggleExpanded, onArchiveAgent, onUnarchiveAgent, @@ -462,8 +459,7 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { }`; const workspaceId = chat.workspace_id; const isArchivingThisChat = isArchiving && archivingChatId === chat.id; - const isRegeneratingThisChat = - isRegeneratingTitle && regeneratingTitleChatId === chat.id; + const isRegeneratingThisChat = regeneratingTitleChatIds.includes(chat.id); const isExpanded = normalizedSearch ? true : (expandedById[chatID] ?? false); return ( @@ -637,7 +633,7 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { ) : ( <> onRegenerateTitle(chat.id)} > @@ -748,8 +744,7 @@ export const AgentsSidebar: FC = (props) => { isCreating, isArchiving = false, archivingChatId = null, - isRegeneratingTitle = false, - regeneratingTitleChatId = null, + regeneratingTitleChatIds, isLoading = false, loadError, onRetryLoad, @@ -960,8 +955,7 @@ export const AgentsSidebar: FC = (props) => { activeChatId, isArchiving, archivingChatId, - isRegeneratingTitle, - regeneratingTitleChatId, + regeneratingTitleChatIds, toggleExpanded, onArchiveAgent, onUnarchiveAgent,