mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: allow renaming of agent chat title (#24489)
Co-authored-by: Coder Agents <noreply@coder.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Generated
+8
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
−{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">
|
||||
−{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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user