diff --git a/coderd/coderd.go b/coderd/coderd.go index a6cacda548..b5a7ed6c84 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 8ab388bc2a..f9bb74edc9 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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 { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e117e81d38..326bbec5f2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index c8d250e6dc..553871fb2d 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 9e87a549f4..e518e772cc 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index e655641f4e..a6d279cd16 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 631832fb1e..d24723cef3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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, diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 112b72858f..0c1bd1bb02 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -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 diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 0fdf13611b..c016c5f06c 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -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. diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 7c501c6064..2aaecb0bcc 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -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() diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index d5eeb56399..99b5f924dd 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -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() diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index f3b7046dc6..6deb0db2d6 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -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() diff --git a/codersdk/chats.go b/codersdk/chats.go index 7bba7b8c1d..6f8d05d1c8 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -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) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 6addb4deb9..7a7c246010 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index b706c61db1..4c390fe801 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -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( + 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), diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b8f4bea13f..a1ed0d3ea9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 { /** diff --git a/site/src/components/ContextMenu/ContextMenu.tsx b/site/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 0000000000..778381f0d0 --- /dev/null +++ b/site/src/components/ContextMenu/ContextMenu.tsx @@ -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 +> = ({ className, ...props }) => { + return ( + + + + ); +}; + +type ContextMenuItemProps = React.ComponentPropsWithRef< + typeof ContextMenuPrimitive.Item +> & { + inset?: boolean; +}; + +export const ContextMenuItem: React.FC = ({ + className, + inset, + ...props +}) => { + return ( + + ); +}; + +export const ContextMenuSeparator: React.FC< + React.ComponentPropsWithRef +> = ({ className, ...props }) => { + return ( + + ); +}; diff --git a/site/src/components/DropdownMenu/DropdownMenu.tsx b/site/src/components/DropdownMenu/DropdownMenu.tsx index cbb1785525..f52bdc0325 100644 --- a/site/src/components/DropdownMenu/DropdownMenu.tsx +++ b/site/src/components/DropdownMenu/DropdownMenu.tsx @@ -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< @@ -51,19 +49,7 @@ export const DropdownMenuItem: React.FC = ({ }) => { return ( 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 ( ); diff --git a/site/src/components/DropdownMenu/menuClasses.ts b/site/src/components/DropdownMenu/menuClasses.ts new file mode 100644 index 0000000000..f5aa7011ba --- /dev/null +++ b/site/src/components/DropdownMenu/menuClasses.ts @@ -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"; diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 412843114e..621944ef91 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -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>(new Set()); const [regeneratingTitleChatIds, setRegeneratingTitleChatIds] = useState< readonly string[] >([]); + const regeneratingTitlePromisesRef = useRef( + new Map>(), + ); 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 => { + 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 => { + 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} diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 21ea84d800..d1c72d1d0d 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -264,7 +264,9 @@ const defaultArgs: ComponentProps = { 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, diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index c5ab984fe9..6d25b8c362 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -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; 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; + onProposeTitle: (chatId: string) => Promise; + onRenameTitle: (chatId: string, title: string) => Promise; regeneratingTitleChatIds: readonly string[]; onToggleSidebarCollapsed: () => void; isAgentsAdmin: boolean; @@ -98,6 +101,8 @@ export const AgentsPageView: FC = ({ requestUnpinAgent, requestReorderPinnedAgent, onRegenerateTitle, + onProposeTitle, + onRenameTitle, regeneratingTitleChatIds, onToggleSidebarCollapsed, isAgentsAdmin, @@ -139,7 +144,9 @@ export const AgentsPageView: FC = ({ requestPinAgent, requestUnpinAgent, requestReorderPinnedAgent, - onRegenerateTitle, + onRegenerateTitle: (chatId: string) => { + onRegenerateTitle(chatId).catch(() => {}); + }, regeneratingTitleChatIds, isSidebarCollapsed, onToggleSidebarCollapsed, @@ -174,7 +181,8 @@ export const AgentsPageView: FC = ({ onPinAgent={requestPinAgent} onUnpinAgent={requestUnpinAgent} onReorderPinnedAgent={requestReorderPinnedAgent} - onRegenerateTitle={onRegenerateTitle} + onRenameTitle={onRenameTitle} + onProposeTitle={onProposeTitle} regeneratingTitleChatIds={regeneratingTitleChatIds} onBeforeNewAgent={handleNewAgent} isCreating={isCreating} diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx index 7368f17afa..e80240e272 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx @@ -88,7 +88,7 @@ const meta: Meta = { 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("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("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("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("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("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((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("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("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((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("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("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: [ diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx index bb4a63e41c..be8cac9519 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx @@ -110,7 +110,7 @@ const defaultProps: React.ComponentProps = { onArchiveAndDeleteWorkspace: vi.fn(), onPinAgent: vi.fn(), onUnpinAgent: vi.fn(), - onRegenerateTitle: vi.fn(), + onRenameTitle: vi.fn(async () => {}), regeneratingTitleChatIds: [], onBeforeNewAgent: vi.fn(), isCreating: false, diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index a7fd96e2c0..b98b63d952 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -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; + onProposeTitle?: (chatId: string) => Promise; 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(null); @@ -441,7 +450,7 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { onArchiveAndDeleteWorkspace, onPinAgent, onUnpinAgent, - onRegenerateTitle, + onOpenRenameDialog, } = useChatTree(); const chatID = chat.id; const isActiveChat = activeChatId === chatID; @@ -483,212 +492,232 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { const isRegeneratingThisChat = regeneratingTitleChatIds.includes(chat.id); const isExpanded = normalizedSearch ? true : (expandedById[chatID] ?? false); - return ( -
-
-
( + <> + {!chat.archived && !isChildNode && ( + + chat.pin_order > 0 ? onUnpinAgent(chat.id) : onPinAgent(chat.id) + } > -
- -
- {hasChildren && ( - - )} -
- - {({ isActive }) => ( + {chat.pin_order > 0 ? ( <> -
-
- - {chat.title} - - {chat.has_unread && !isActiveChat && ( - (unread) - )} - {isRegeneratingThisChat && ( - - Regenerating title… - - )} -
-
- {hasLinkedDiffStatus && hasLineStats && ( - - - +{additions} - - - −{deletions} - - - )} -
- {subtitle} -
-
-
+ + Unpin agent - )} -
-
- {isArchivingThisChat ? ( - ) : ( <> - - {chat.has_unread && !isActiveChat ? ( - - - - - - - {!chat.archived && !isChildNode && ( - - chat.pin_order > 0 - ? onUnpinAgent(chat.id) - : onPinAgent(chat.id) - } - > - {chat.pin_order > 0 ? ( - <> - - Unpin agent - - ) : ( - <> - - Pin agent - - )} - - )} - {chat.archived ? ( - onUnarchiveAgent(chat.id)} - > - - Unarchive agent - - ) : ( - <> - onRegenerateTitle(chat.id)} - > - - Generate new title - - - onArchiveAgent(chat.id)} - > - - Archive agent - - {workspaceId && ( - - onArchiveAndDeleteWorkspace(chat.id, workspaceId) - } - > - - Archive & delete workspace - - )} - - )} - - + + Pin agent )} -
-
+ + )} + {chat.archived ? ( + onUnarchiveAgent(chat.id)}> + + Unarchive agent + + ) : ( + <> + {onOpenRenameDialog && ( + onOpenRenameDialog(chat)}> + + Rename chat + + )} + + onArchiveAgent(chat.id)} + > + + Archive agent + + {workspaceId && ( + onArchiveAndDeleteWorkspace(chat.id, workspaceId)} + > + + Archive & delete workspace + + )} + + )} + + ); + + return ( +
+ + +
+
+
+ +
+ {hasChildren && ( + + )} +
+ + {({ isActive }) => ( + <> +
+
+ + {chat.title} + + {chat.has_unread && !isActiveChat && ( + (unread) + )} + {isRegeneratingThisChat && ( + + Regenerating title… + + )} +
+
+ {hasLinkedDiffStatus && hasLineStats && ( + + + +{additions} + + + −{deletions} + + + )} +
+ {subtitle} +
+
+
+ + )} +
+
+ {isArchivingThisChat ? ( + + ) : ( + <> + + {chat.has_unread && !isActiveChat ? ( + + + + + + + {renderMenuItems({ + Item: DropdownMenuItem, + Separator: DropdownMenuSeparator, + })} + + + + )} +
+
+
+ + {renderMenuItems({ + Item: ContextMenuItem, + Separator: ContextMenuSeparator, + })} + +
{hasChildren && isExpanded && (
@@ -760,7 +789,8 @@ export const AgentsSidebar: FC = (props) => { onPinAgent, onUnpinAgent, onReorderPinnedAgent, - onRegenerateTitle, + onRenameTitle, + onProposeTitle, onBeforeNewAgent, isCreating, isArchiving = false, @@ -796,6 +826,7 @@ export const AgentsSidebar: FC = (props) => { isAdmin || isApiKeysSection || Boolean(providerConfigsQuery.data?.length); const normalizedSearch = ""; const [expandedById, setExpandedById] = useState>({}); + const [chatPendingRename, setChatPendingRename] = useState(null); const chatTree = buildChatTree(chats); const chatById = chatTree.chatById; @@ -991,7 +1022,7 @@ export const AgentsSidebar: FC = (props) => { onArchiveAndDeleteWorkspace, onPinAgent, onUnpinAgent, - onRegenerateTitle, + onOpenRenameDialog: onRenameTitle ? setChatPendingRename : undefined, }; const subNavTitle = "Settings"; @@ -1349,6 +1380,16 @@ export const AgentsSidebar: FC = (props) => { )}
+ {onRenameTitle && ( + { + if (!open) setChatPendingRename(null); + }} + /> + )}
); }; diff --git a/site/src/pages/AgentsPage/components/Sidebar/RenameChatDialog.tsx b/site/src/pages/AgentsPage/components/Sidebar/RenameChatDialog.tsx new file mode 100644 index 0000000000..8fde93642e --- /dev/null +++ b/site/src/pages/AgentsPage/components/Sidebar/RenameChatDialog.tsx @@ -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; + readonly onPropose?: (chatId: string) => Promise; + readonly onOpenChange: (open: boolean) => void; +}; + +export const RenameChatDialog: FC = ({ + chat, + onRename, + onPropose, + onOpenChange, +}) => { + const [renameTitle, setRenameTitle] = useState(""); + const [isRenamingChat, setIsRenamingChat] = useState(false); + const [isGeneratingTitle, setIsGeneratingTitle] = useState(false); + const [generateTitleError, setGenerateTitleError] = useState( + null, + ); + const inputRef = useRef(null); + const sessionRef = useRef(0); + const inputId = useId(); + const errorId = `${inputId}-error`; + + const currentChatId = chat?.id ?? null; + const [prevChatId, setPrevChatId] = useState(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 ( + { + // Block closes (escape / outside click) while a rename is in + // flight; the submit handler will close on success. + if (!open && isRenamingChat) return; + onOpenChange(open); + }} + > + { + event.preventDefault(); + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + }} + className="max-w-[440px] p-6 sm:p-6" + aria-describedby={undefined} + > + + Rename chat + {onPropose && ( + + )} + +
{ + event.preventDefault(); + void handleSubmit(); + }} + > +
+ { + 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 && ( + + )} +
+ + + + +
+
+
+ ); +};