feat: allow renaming of agent chat title (#24489)

Co-authored-by: Coder Agents <noreply@coder.com>
This commit is contained in:
Jaayden Halko
2026-04-20 20:00:46 +07:00
committed by GitHub
parent 18a30a7a10
commit 410f9a5e19
26 changed files with 1911 additions and 297 deletions
+1
View File
@@ -1257,6 +1257,7 @@ func New(options *Options) *API {
r.Post("/interrupt", api.interruptChat)
r.Post("/tool-results", api.postChatToolResults)
r.Post("/title/regenerate", api.regenerateChatTitle)
r.Post("/title/propose", api.proposeChatTitle)
r.Get("/diff", api.getChatDiffContents)
r.Route("/queue/{queuedMessage}", func(r chi.Router) {
r.Delete("/", api.deleteChatQueuedMessage)
+11
View File
@@ -6206,6 +6206,17 @@ func (q *querier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg dat
return q.db.UpdateChatStatusPreserveUpdatedAt(ctx, arg)
}
func (q *querier) UpdateChatTitleByID(ctx context.Context, arg database.UpdateChatTitleByIDParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return database.Chat{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.Chat{}, err
}
return q.db.UpdateChatTitleByID(ctx, arg)
}
func (q *querier) UpdateChatWorkspaceBinding(ctx context.Context, arg database.UpdateChatWorkspaceBindingParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
+10
View File
@@ -983,6 +983,16 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpdateChatByID(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatTitleByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatTitleByIDParams{
ID: chat.ID,
Title: "Updated title",
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatTitleByID(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatLabelsByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatLabelsByIDParams{
@@ -4472,6 +4472,14 @@ func (m queryMetricsStore) UpdateChatStatusPreserveUpdatedAt(ctx context.Context
return r0, r1
}
func (m queryMetricsStore) UpdateChatTitleByID(ctx context.Context, arg database.UpdateChatTitleByIDParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatTitleByID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatTitleByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatTitleByID").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatWorkspaceBinding(ctx context.Context, arg database.UpdateChatWorkspaceBindingParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatWorkspaceBinding(ctx, arg)
+15
View File
@@ -8460,6 +8460,21 @@ func (mr *MockStoreMockRecorder) UpdateChatStatusPreserveUpdatedAt(ctx, arg any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatStatusPreserveUpdatedAt", reflect.TypeOf((*MockStore)(nil).UpdateChatStatusPreserveUpdatedAt), ctx, arg)
}
// UpdateChatTitleByID mocks base method.
func (m *MockStore) UpdateChatTitleByID(ctx context.Context, arg database.UpdateChatTitleByIDParams) (database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatTitleByID", ctx, arg)
ret0, _ := ret[0].(database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatTitleByID indicates an expected call of UpdateChatTitleByID.
func (mr *MockStoreMockRecorder) UpdateChatTitleByID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatTitleByID", reflect.TypeOf((*MockStore)(nil).UpdateChatTitleByID), ctx, arg)
}
// UpdateChatWorkspaceBinding mocks base method.
func (m *MockStore) UpdateChatWorkspaceBinding(ctx context.Context, arg database.UpdateChatWorkspaceBindingParams) (database.Chat, error) {
m.ctrl.T.Helper()
+1
View File
@@ -1057,6 +1057,7 @@ type sqlcQuerier interface {
UpdateChatProvider(ctx context.Context, arg UpdateChatProviderParams) (ChatProvider, error)
UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error)
UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg UpdateChatStatusPreserveUpdatedAtParams) (Chat, error)
UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitleByIDParams) (Chat, error)
UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error)
UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error)
UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error)
+54
View File
@@ -8565,6 +8565,60 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg
return i, err
}
const updateChatTitleByID = `-- name: UpdateChatTitleByID :one
UPDATE
chats
SET
-- NOTE: updated_at is intentionally NOT touched here to avoid
-- changing list ordering when a user renames an older chat
-- out-of-band.
title = $1::text
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type
`
type UpdateChatTitleByIDParams struct {
Title string `db:"title" json:"title"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitleByIDParams) (Chat, error) {
row := q.db.QueryRowContext(ctx, updateChatTitleByID, arg.Title, arg.ID)
var i Chat
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.WorkspaceID,
&i.Title,
&i.Status,
&i.WorkerID,
&i.StartedAt,
&i.HeartbeatAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentChatID,
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
&i.Mode,
pq.Array(&i.MCPServerIDs),
&i.Labels,
&i.BuildID,
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
)
return i, err
}
const updateChatWorkspaceBinding = `-- name: UpdateChatWorkspaceBinding :one
UPDATE chats SET
workspace_id = $1::uuid,
+13
View File
@@ -553,6 +553,19 @@ WHERE
RETURNING
*;
-- name: UpdateChatTitleByID :one
UPDATE
chats
SET
-- NOTE: updated_at is intentionally NOT touched here to avoid
-- changing list ordering when a user renames an older chat
-- out-of-band.
title = @title::text
WHERE
id = @id::uuid
RETURNING
*;
-- name: UpdateChatPlanModeByID :one
UPDATE
chats
+154
View File
@@ -16,6 +16,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
@@ -111,6 +112,30 @@ func maybeWriteLimitErr(ctx context.Context, rw http.ResponseWriter, err error)
return false
}
func publishChatTitleChange(logger slog.Logger, ps dbpubsub.Pubsub, chat database.Chat) {
if ps == nil {
return
}
event := codersdk.ChatWatchEvent{
Kind: codersdk.ChatWatchEventKindTitleChange,
Chat: db2sdk.Chat(chat, nil, nil),
}
payload, err := json.Marshal(event)
if err != nil {
logger.Error(context.Background(), "failed to marshal chat title change event",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
return
}
if err := ps.Publish(pubsub.ChatWatchEventChannel(chat.OwnerID), payload); err != nil {
logger.Error(context.Background(), "failed to publish chat title change event",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
}
}
func publishChatConfigEvent(logger slog.Logger, ps dbpubsub.Pubsub, kind pubsub.ChatConfigEventKind, entityID uuid.UUID) {
payload, err := json.Marshal(pubsub.ChatConfigEvent{
Kind: kind,
@@ -1929,6 +1954,86 @@ func (api *API) watchChatDesktop(rw http.ResponseWriter, r *http.Request) {
logger.Debug(ctx, "desktop Bicopy finished")
}
func (api *API) applyChatTitleUpdate(
ctx context.Context,
rw http.ResponseWriter,
chat database.Chat,
rawTitle string,
) (database.Chat, bool) {
trimmedTitle := strings.TrimSpace(rawTitle)
if trimmedTitle == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Title cannot be empty.",
})
return chat, true
}
const maxChatTitleRunes = 200
if utf8.RuneCountInString(trimmedTitle) > maxChatTitleRunes {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Title must be at most %d characters.", maxChatTitleRunes),
})
return chat, true
}
if trimmedTitle == chat.Title {
return chat, false
}
var (
updatedChat database.Chat
wrote bool
err error
)
if api.chatDaemon != nil {
updatedChat, wrote, err = api.chatDaemon.RenameChatTitle(ctx, chat, trimmedTitle)
} else {
err = api.Database.InTx(func(tx database.Store) error {
currentChat, txErr := tx.GetChatByID(ctx, chat.ID)
if txErr != nil {
return txErr
}
if trimmedTitle == currentChat.Title {
updatedChat = currentChat
wrote = false
return nil
}
updatedChat, txErr = tx.UpdateChatTitleByID(ctx, database.UpdateChatTitleByIDParams{
ID: chat.ID,
Title: trimmedTitle,
})
if txErr != nil {
return txErr
}
wrote = true
return nil
}, nil)
}
if err != nil {
if errors.Is(err, chatd.ErrManualTitleRegenerationInProgress) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Title regeneration already in progress for this chat.",
})
return chat, true
}
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return chat, true
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat title.",
Detail: err.Error(),
})
return chat, true
}
if wrote {
if api.chatDaemon != nil {
api.chatDaemon.PublishTitleChange(updatedChat)
} else {
publishChatTitleChange(api.Logger, api.Pubsub, updatedChat)
}
}
return updatedChat, false
}
// patchChat updates a chat resource. Supports updating labels,
// workspace binding, archiving, pinning, and pinned-chat ordering.
func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
@@ -1952,6 +2057,13 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
planModeUpdate = &resolvedPlanMode
}
if req.Title != nil {
updatedChat, handled := api.applyChatTitleUpdate(ctx, rw, chat, *req.Title)
if handled {
return
}
chat = updatedChat
}
if req.Labels != nil {
if errs := httpapi.ValidateChatLabels(*req.Labels); len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@@ -2756,6 +2868,48 @@ func (api *API) regenerateChatTitle(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updatedChat, nil, nil))
}
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
if api.chatDaemon == nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat processor is unavailable.",
Detail: "Chat processor is not configured.",
})
return
}
title, err := api.chatDaemon.ProposeChatTitle(ctx, chat)
if err != nil {
if errors.Is(err, chatd.ErrManualTitleRegenerationInProgress) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Title regeneration already in progress for this chat.",
})
return
}
if maybeWriteLimitErr(ctx, rw, err) {
return
}
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to generate chat title.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ProposeChatTitleResponse{Title: title})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
+351
View File
@@ -4149,6 +4149,271 @@ func TestPatchChat(t *testing.T) {
require.Nil(t, updated.AgentID)
})
})
t.Run("Title", func(t *testing.T) {
t.Parallel()
t.Run("Rename", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "original title")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref("renamed title"),
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, "renamed title", updated.Title)
})
t.Run("TrimsWhitespace", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "before trim")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref(" padded title "),
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, "padded title", updated.Title)
})
t.Run("RejectsEmpty", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "keep original")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref(" "),
})
requireSDKError(t, err, http.StatusBadRequest)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, chat.Title, updated.Title)
})
t.Run("RejectsTooLong", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "keep original length")
tooLong := strings.Repeat("a", 201)
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref(tooLong),
})
requireSDKError(t, err, http.StatusBadRequest)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, chat.Title, updated.Title)
})
t.Run("LengthBoundaries", func(t *testing.T) {
t.Parallel()
cases := []struct {
name string
title string
expectOK bool
storedAs string
}{
{
name: "ExactlyMaxASCII",
title: strings.Repeat("a", 200),
expectOK: true,
storedAs: strings.Repeat("a", 200),
},
{
name: "OneOverMaxASCII",
title: strings.Repeat("a", 201),
expectOK: false,
},
{
name: "ExactlyMaxMultiByte",
title: strings.Repeat("é", 200),
expectOK: true,
storedAs: strings.Repeat("é", 200),
},
{
name: "OneOverMaxMultiByte",
title: strings.Repeat("é", 201),
expectOK: false,
},
{
name: "TrimsDownToMax",
title: " " + strings.Repeat("a", 200) + " ",
expectOK: true,
storedAs: strings.Repeat("a", 200),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "boundary baseline")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref(tc.title),
})
updated := getChat(ctx, t, client, chat.ID)
if tc.expectOK {
require.NoError(t, err)
require.Equal(t, tc.storedAs, updated.Title)
} else {
requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, chat.Title, updated.Title)
}
})
}
})
t.Run("PreservesUpdatedAt", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t)
clientRaw := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: chatDeploymentValues(t),
Database: db,
Pubsub: ps,
})
client := codersdk.NewExperimentalClient(clientRaw)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "rename me")
require.Eventually(t, func() bool {
c, getErr := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
if getErr != nil {
return false
}
return c.Status != database.ChatStatusPending &&
c.Status != database.ChatStatusRunning
}, testutil.WaitShort, testutil.IntervalFast)
past := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second)
_, err := sqlDB.ExecContext(ctx,
"UPDATE chats SET updated_at = $1 WHERE id = $2",
past, chat.ID,
)
require.NoError(t, err)
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref("renamed in place"),
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, "renamed in place", updated.Title)
require.WithinDuration(t, past, updated.UpdatedAt, time.Second,
"rename bumped updated_at; it should be preserved to keep list ordering stable")
})
t.Run("NoOpWhenTitleUnchanged", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t)
clientRaw := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: chatDeploymentValues(t),
Database: db,
Pubsub: ps,
})
client := codersdk.NewExperimentalClient(clientRaw)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "steady title")
require.Eventually(t, func() bool {
c, getErr := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
if getErr != nil {
return false
}
return c.Status != database.ChatStatusPending &&
c.Status != database.ChatStatusRunning
}, testutil.WaitShort, testutil.IntervalFast)
past := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second)
_, err := sqlDB.ExecContext(ctx,
"UPDATE chats SET title = $1, updated_at = $2 WHERE id = $3",
"steady title", past, chat.ID,
)
require.NoError(t, err)
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref("steady title"),
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, "steady title", updated.Title)
require.WithinDuration(t, past, updated.UpdatedAt, time.Second,
"no-op rename bumped updated_at; it should have been short-circuited before the write")
})
t.Run("PublishesWatchEvent", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "announce me")
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
go func() {
_ = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref("announced name"),
})
}()
var received codersdk.ChatWatchEvent
for {
if err := wsjson.Read(ctx, conn, &received); err != nil {
break
}
if received.Kind == codersdk.ChatWatchEventKindTitleChange &&
received.Chat.ID == chat.ID {
require.Equal(t, "announced name", received.Chat.Title)
return
}
}
t.Fatalf("did not observe title_change event for chat %s", chat.ID)
})
})
}
func TestArchiveChat(t *testing.T) {
@@ -6592,6 +6857,92 @@ func TestRegenerateChatTitle(t *testing.T) {
})
}
func TestProposeChatTitle(t *testing.T) {
t.Parallel()
t.Run("ChatNotFound", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
_, err := client.ProposeChatTitle(ctx, uuid.New())
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("UpdateDenied", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
clientRaw, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
Authorizer: &coderdtest.FakeAuthorizer{
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
if action == policy.ActionUpdate && object.Type == rbac.ResourceChat.Type {
return xerrors.New("denied")
}
return nil
},
},
DeploymentValues: chatDeploymentValues(t),
})
client := codersdk.NewExperimentalClient(clientRaw)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "chat with update denied",
})
require.NoError(t, err)
_, err = client.ProposeChatTitle(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("DoesNotPersistTitleOrBumpUpdatedAt", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "test chat"},
},
})
require.NoError(t, err)
require.Eventually(t, func() bool {
c, getErr := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
if getErr != nil {
return false
}
return c.Status != database.ChatStatusPending && c.Status != database.ChatStatusRunning
}, testutil.WaitShort, testutil.IntervalFast)
before, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
_, err = client.ProposeChatTitle(ctx, chat.ID)
requireSDKError(t, err, http.StatusInternalServerError)
after, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
require.Equal(t, before.Title, after.Title,
"propose must not persist the suggested title")
require.True(t, after.UpdatedAt.Equal(before.UpdatedAt),
"propose must not bump updated_at")
})
}
func TestGetChatDiffStatus(t *testing.T) {
t.Parallel()
+162 -48
View File
@@ -2196,44 +2196,112 @@ func (p *Server) RegenerateChatTitle(
keys,
)
if err != nil {
var generationErr *manualTitleGenerationError
if errors.As(err, &generationErr) {
// Reuse chatd's scoped auth context for failure accounting while
// detaching from request cancellation so usage is still recorded.
//nolint:gocritic // Failure accounting still needs chatd-scoped config reads.
recordCtx, recordCancel := context.WithTimeout(
dbauthz.AsChatd(context.WithoutCancel(ctx)),
5*time.Second,
)
defer recordCancel()
if _, recordErr := recordManualTitleUsage(
recordCtx,
p.db,
chat,
generationErr.modelConfig,
generationErr.usage,
"",
); recordErr != nil {
return database.Chat{}, errors.Join(
generationErr,
xerrors.Errorf("record manual title usage: %w", recordErr),
)
}
return database.Chat{}, generationErr
}
return database.Chat{}, err
return database.Chat{}, p.recordManualTitleGenerationFailure(ctx, chat, err)
}
return updatedChat, nil
}
func (p *Server) regenerateChatTitleWithStore(
// RenameChatTitle persists a user-supplied chat title.
func (p *Server) RenameChatTitle(
ctx context.Context,
chat database.Chat,
newTitle string,
) (updated database.Chat, wrote bool, err error) {
//nolint:gocritic // Lock release needs chatd-scoped writes.
chatdCtx := dbauthz.AsChatd(ctx)
if err := p.acquireManualTitleLock(ctx, chat.ID); err != nil {
return database.Chat{}, false, err
}
defer p.releaseManualTitleLock(chatdCtx, chat.ID)
currentChat, err := p.db.GetChatByID(ctx, chat.ID)
if err != nil {
return database.Chat{}, false, xerrors.Errorf("get chat for rename: %w", err)
}
if newTitle == currentChat.Title {
return currentChat, false, nil
}
updatedChat, err := p.db.UpdateChatTitleByID(ctx, database.UpdateChatTitleByIDParams{
ID: chat.ID,
Title: newTitle,
})
if err != nil {
return database.Chat{}, false, xerrors.Errorf("update chat title: %w", err)
}
return updatedChat, true, nil
}
// PublishTitleChange broadcasts a title_change event for the given chat.
func (p *Server) PublishTitleChange(chat database.Chat) {
p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindTitleChange, nil)
}
// ProposeChatTitle generates a title suggestion from the chat's visible messages without persisting it.
func (p *Server) ProposeChatTitle(
ctx context.Context,
chat database.Chat,
) (string, error) {
//nolint:gocritic // Non-admin users need chatd-scoped config reads here.
chatdCtx := dbauthz.AsChatd(ctx)
keys, err := p.resolveUserProviderAPIKeys(chatdCtx, chat.OwnerID)
if err != nil {
return "", xerrors.Errorf("resolve chat providers: %w", err)
}
if err := p.acquireManualTitleLock(ctx, chat.ID); err != nil {
return "", err
}
defer p.releaseManualTitleLock(chatdCtx, chat.ID)
title, err := p.proposeChatTitleWithStore(chatdCtx, p.db, chat, keys)
if err != nil {
return "", p.recordManualTitleGenerationFailure(ctx, chat, err)
}
return title, nil
}
func (p *Server) recordManualTitleGenerationFailure(
ctx context.Context,
chat database.Chat,
err error,
) error {
var generationErr *manualTitleGenerationError
if !errors.As(err, &generationErr) {
return err
}
//nolint:gocritic // Failure accounting still needs chatd-scoped config reads.
recordCtx, recordCancel := context.WithTimeout(
dbauthz.AsChatd(context.WithoutCancel(ctx)),
5*time.Second,
)
defer recordCancel()
if _, recordErr := recordManualTitleUsage(
recordCtx,
p.db,
chat,
generationErr.modelConfig,
generationErr.usage,
"",
); recordErr != nil {
return errors.Join(
generationErr,
xerrors.Errorf("record manual title usage: %w", recordErr),
)
}
return generationErr
}
//nolint:revive // flag-parameter: enableDebug toggles optional debug capture on a shared code path; splitting would duplicate message fetch and model resolution.
func (p *Server) fetchAndGenerateManualTitle(
ctx context.Context,
store database.Store,
chat database.Chat,
keys chatprovider.ProviderAPIKeys,
) (database.Chat, error) {
enableDebug bool,
) (title string, modelConfig database.ChatModelConfig, usage fantasy.Usage, hasMessages bool, err error) {
if limitErr := p.checkUsageLimit(ctx, store, chat.OwnerID, uuid.NullUUID{UUID: chat.OrganizationID, Valid: true}); limitErr != nil {
return database.Chat{}, limitErr
return "", database.ChatModelConfig{}, fantasy.Usage{}, false, limitErr
}
headMessages, err := store.GetChatMessagesByChatIDAscPaginated(
@@ -2245,7 +2313,7 @@ func (p *Server) regenerateChatTitleWithStore(
},
)
if err != nil {
return database.Chat{}, xerrors.Errorf("get head chat messages: %w", err)
return "", database.ChatModelConfig{}, fantasy.Usage{}, false, xerrors.Errorf("get head chat messages: %w", err)
}
tailMessages, err := store.GetChatMessagesByChatIDDescPaginated(
ctx,
@@ -2256,49 +2324,95 @@ func (p *Server) regenerateChatTitleWithStore(
},
)
if err != nil {
return database.Chat{}, xerrors.Errorf("get tail chat messages: %w", err)
return "", database.ChatModelConfig{}, fantasy.Usage{}, false, xerrors.Errorf("get tail chat messages: %w", err)
}
messages := mergeManualTitleMessages(headMessages, tailMessages)
if len(messages) == 0 {
return chat, nil
return "", database.ChatModelConfig{}, fantasy.Usage{}, false, nil
}
model, modelConfig, err := p.resolveManualTitleModel(ctx, store, chat, keys)
if err != nil {
return database.Chat{}, err
return "", database.ChatModelConfig{}, fantasy.Usage{}, true, err
}
debugSvc := p.debugService()
debugEnabled := debugSvc != nil && debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID)
titleCtx := ctx
titleModel := model
finishDebugRun := func(error) {}
if debugEnabled {
titleCtx, titleModel, finishDebugRun = p.prepareManualTitleDebugRun(
ctx,
debugSvc,
chat,
modelConfig,
keys,
messages,
model,
)
if enableDebug {
if debugSvc := p.debugService(); debugSvc != nil && debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID) {
titleCtx, titleModel, finishDebugRun = p.prepareManualTitleDebugRun(
ctx,
debugSvc,
chat,
modelConfig,
keys,
messages,
model,
)
}
}
title, usage, err := generateManualTitle(titleCtx, messages, titleModel)
title, usage, err = generateManualTitle(titleCtx, messages, titleModel)
finishDebugRun(err)
if err != nil {
wrappedErr := xerrors.Errorf("generate manual title: %w", err)
if usage == (fantasy.Usage{}) {
return database.Chat{}, wrappedErr
return "", modelConfig, fantasy.Usage{}, true, wrappedErr
}
return database.Chat{}, &manualTitleGenerationError{
return "", modelConfig, usage, true, &manualTitleGenerationError{
cause: wrappedErr,
modelConfig: modelConfig,
usage: usage,
}
}
return title, modelConfig, usage, true, nil
}
func (p *Server) proposeChatTitleWithStore(
ctx context.Context,
store database.Store,
chat database.Chat,
keys chatprovider.ProviderAPIKeys,
) (string, error) {
title, modelConfig, usage, hasMessages, err := p.fetchAndGenerateManualTitle(ctx, store, chat, keys, false)
if err != nil {
return "", err
}
if !hasMessages {
return "", nil
}
recordCtx, recordCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer recordCancel()
if _, recordErr := recordManualTitleUsage(
recordCtx,
store,
chat,
modelConfig,
usage,
"",
); recordErr != nil {
return "", xerrors.Errorf("record manual title usage: %w", recordErr)
}
return title, nil
}
func (p *Server) regenerateChatTitleWithStore(
ctx context.Context,
store database.Store,
chat database.Chat,
keys chatprovider.ProviderAPIKeys,
) (database.Chat, error) {
title, modelConfig, usage, hasMessages, err := p.fetchAndGenerateManualTitle(ctx, store, chat, keys, true)
if err != nil {
return database.Chat{}, err
}
if !hasMessages {
return chat, nil
}
recordCtx, recordCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer recordCancel()
+93
View File
@@ -287,6 +287,99 @@ func TestStopAfterBehaviorTools(t *testing.T) {
// TestArchiveChatWaitsForEveryInterruptedChat were removed along with
// the process-local activeChats mechanism. Archive cleanup is now
// best-effort; stale finalization handles any orphaned rows.
func TestRenameChatTitle(t *testing.T) {
t.Parallel()
setupRealWorkerLock := func(
db *dbmock.MockStore,
chatID uuid.UUID,
lockedChat database.Chat,
) {
lockTx := dbmock.NewMockStore(gomock.NewController(t))
unlockTx := dbmock.NewMockStore(gomock.NewController(t))
gomock.InOrder(
db.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("chat_title_regenerate_lock")).DoAndReturn(
func(fn func(database.Store) error, _ *database.TxOptions) error {
return fn(lockTx)
},
),
db.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("chat_title_regenerate_unlock")).DoAndReturn(
func(fn func(database.Store) error, _ *database.TxOptions) error {
return fn(unlockTx)
},
),
)
lockTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(lockedChat, nil)
unlockTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(lockedChat, nil)
}
t.Run("WritesAndReturnsWroteTrue", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
chatID := uuid.New()
workerID := uuid.New()
stored := database.Chat{
ID: chatID,
Status: database.ChatStatusRunning,
WorkerID: uuid.NullUUID{UUID: workerID, Valid: true},
Title: "original",
}
updated := stored
updated.Title = "renamed"
server := &Server{db: db, logger: logger}
setupRealWorkerLock(db, chatID, stored)
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(stored, nil)
db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{
ID: chatID,
Title: "renamed",
}).Return(updated, nil)
got, wrote, err := server.RenameChatTitle(ctx, stored, "renamed")
require.NoError(t, err)
require.True(t, wrote, "fresh rename must report wrote=true")
require.Equal(t, updated, got)
})
t.Run("SkipsWriteWhenAlreadyAtNewTitle", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
chatID := uuid.New()
workerID := uuid.New()
stale := database.Chat{
ID: chatID,
Status: database.ChatStatusRunning,
WorkerID: uuid.NullUUID{UUID: workerID, Valid: true},
Title: "pre-race",
}
landed := stale
landed.Title = "landed-concurrently"
server := &Server{db: db, logger: logger}
setupRealWorkerLock(db, chatID, landed)
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(landed, nil)
got, wrote, err := server.RenameChatTitle(ctx, stale, "landed-concurrently")
require.NoError(t, err)
require.False(t, wrote,
"must report wrote=false when the stored row already matches newTitle so the handler suppresses a redundant title_change event")
require.Equal(t, landed, got)
})
}
func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
t.Parallel()
+19
View File
@@ -2558,6 +2558,25 @@ func (c *ExperimentalClient) RegenerateChatTitle(ctx context.Context, chatID uui
return chat, json.NewDecoder(res.Body).Decode(&chat)
}
// ProposeChatTitleResponse is returned by the propose-title endpoint.
type ProposeChatTitleResponse struct {
Title string `json:"title"`
}
// ProposeChatTitle requests the server to generate a suggested chat title without persisting it.
func (c *ExperimentalClient) ProposeChatTitle(ctx context.Context, chatID uuid.UUID) (ProposeChatTitleResponse, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/title/propose", chatID), nil)
if err != nil {
return ProposeChatTitleResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ProposeChatTitleResponse{}, readBodyAsChatUsageLimitError(res)
}
var resp ProposeChatTitleResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// GetChatGitChanges returns git changes for a chat.
func (c *ExperimentalClient) GetChatGitChanges(ctx context.Context, chatID uuid.UUID) ([]ChatGitChange, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s/git-changes", chatID), nil)
+7
View File
@@ -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,
+32
View File
@@ -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),
+8
View File
@@ -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";
+40 -11
View File
@@ -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,
+11 -3
View File
@@ -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">
&minus;{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">
&minus;{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>
);
};