mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
+14
-12
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -56,8 +56,7 @@ const AgentChatPageLayout: FC = () => {
|
||||
requestPinAgent: () => {},
|
||||
requestUnpinAgent: () => {},
|
||||
onRegenerateTitle: () => {},
|
||||
isRegeneratingTitle: false,
|
||||
regeneratingTitleChatId: null,
|
||||
regeneratingTitleChatIds: [],
|
||||
isSidebarCollapsed: false,
|
||||
onToggleSidebarCollapsed: () => {},
|
||||
onExpandSidebar: () => {},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -227,6 +227,10 @@ const AgentsPage: FC = () => {
|
||||
toast.error(getErrorMessage(error, "Failed to generate new title."));
|
||||
},
|
||||
});
|
||||
const regeneratingTitleChatIdsRef = useRef<ReadonlySet<string>>(new Set());
|
||||
const [regeneratingTitleChatIds, setRegeneratingTitleChatIds] = useState<
|
||||
readonly string[]
|
||||
>([]);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [chatErrorReasons, setChatErrorReasons] = useState<
|
||||
Record<string, ChatDetailError>
|
||||
@@ -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}
|
||||
|
||||
@@ -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<typeof AgentsPageView> = {
|
||||
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<typeof AgentsPageView> = {
|
||||
title: "pages/AgentsPage/AgentsPageView",
|
||||
component: AgentsPageView,
|
||||
@@ -250,36 +285,7 @@ const meta: Meta<typeof AgentsPageView> = {
|
||||
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: [],
|
||||
|
||||
@@ -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<AgentsPageViewProps> = ({
|
||||
requestUnpinAgent,
|
||||
requestReorderPinnedAgent,
|
||||
onRegenerateTitle,
|
||||
isRegeneratingTitle,
|
||||
regeneratingTitleChatId,
|
||||
regeneratingTitleChatIds,
|
||||
onToggleSidebarCollapsed,
|
||||
isAgentsAdmin,
|
||||
hasNextPage,
|
||||
@@ -143,8 +140,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
|
||||
requestUnpinAgent,
|
||||
requestReorderPinnedAgent,
|
||||
onRegenerateTitle,
|
||||
isRegeneratingTitle,
|
||||
regeneratingTitleChatId,
|
||||
regeneratingTitleChatIds,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed,
|
||||
onExpandSidebar,
|
||||
@@ -179,8 +175,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
|
||||
onUnpinAgent={requestUnpinAgent}
|
||||
onReorderPinnedAgent={requestReorderPinnedAgent}
|
||||
onRegenerateTitle={onRegenerateTitle}
|
||||
isRegeneratingTitle={isRegeneratingTitle}
|
||||
regeneratingTitleChatId={regeneratingTitleChatId}
|
||||
regeneratingTitleChatIds={regeneratingTitleChatIds}
|
||||
onBeforeNewAgent={handleNewAgent}
|
||||
isCreating={isCreating}
|
||||
isArchiving={isArchiving}
|
||||
|
||||
@@ -78,8 +78,7 @@ const meta: Meta<typeof AgentsSidebar> = {
|
||||
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: [
|
||||
|
||||
@@ -108,8 +108,7 @@ const defaultProps: React.ComponentProps<typeof AgentsSidebar> = {
|
||||
onPinAgent: vi.fn(),
|
||||
onUnpinAgent: vi.fn(),
|
||||
onRegenerateTitle: vi.fn(),
|
||||
isRegeneratingTitle: false,
|
||||
regeneratingTitleChatId: null,
|
||||
regeneratingTitleChatIds: [],
|
||||
onBeforeNewAgent: vi.fn(),
|
||||
isCreating: false,
|
||||
archivedFilter: "active" as const,
|
||||
|
||||
@@ -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<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
|
||||
activeChatId,
|
||||
isArchiving,
|
||||
archivingChatId,
|
||||
isRegeneratingTitle,
|
||||
regeneratingTitleChatId,
|
||||
regeneratingTitleChatIds,
|
||||
toggleExpanded,
|
||||
onArchiveAgent,
|
||||
onUnarchiveAgent,
|
||||
@@ -462,8 +459,7 @@ const ChatTreeNode: FC<ChatTreeNodeProps> = ({ 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<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
disabled={isRegeneratingTitle}
|
||||
disabled={isRegeneratingThisChat}
|
||||
onSelect={() => onRegenerateTitle(chat.id)}
|
||||
>
|
||||
<WandSparklesIcon className="h-3.5 w-3.5" />
|
||||
@@ -748,8 +744,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
||||
isCreating,
|
||||
isArchiving = false,
|
||||
archivingChatId = null,
|
||||
isRegeneratingTitle = false,
|
||||
regeneratingTitleChatId = null,
|
||||
regeneratingTitleChatIds,
|
||||
isLoading = false,
|
||||
loadError,
|
||||
onRetryLoad,
|
||||
@@ -960,8 +955,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
||||
activeChatId,
|
||||
isArchiving,
|
||||
archivingChatId,
|
||||
isRegeneratingTitle,
|
||||
regeneratingTitleChatId,
|
||||
regeneratingTitleChatIds,
|
||||
toggleExpanded,
|
||||
onArchiveAgent,
|
||||
onUnarchiveAgent,
|
||||
|
||||
Reference in New Issue
Block a user