From 113aaa79a057510170e3088d8a9e936879034584 Mon Sep 17 00:00:00 2001 From: Matt Vollmer Date: Thu, 26 Mar 2026 16:52:02 -0400 Subject: [PATCH] feat: add pinned chats with drag-to-reorder (#23615) https://github.com/user-attachments/assets/bd5d12a1-61b3-4b7d-83b6-317bdfb60b3c ## Summary Adds pinned chats to the agents page sidebar with server-side persistence and drag-to-reorder. Users can pin/unpin chats via the context menu, and pinned chats appear in a dedicated "Pinned" section above the time-grouped list. ## Database Migration `000453_chat_pin_order`: adds `pin_order integer DEFAULT 0 NOT NULL` column on `chats` (0 = unpinned, 1+ = pinned in display order). Three SQL queries handle pin operations server-side using CTEs with `ROW_NUMBER()`: - `PinChatByID`: normalizes existing orders and appends to end - `UnpinChatByID`: sets target to 0 and compacts remaining pins - `UpdateChatPinOrder`: shifts neighbors, clamps to `[1, pinned_count]` All queries exclude archived chats. `ArchiveChatByID` clears `pin_order` on archive. The handler rejects pinning archived chats with 400. ## Backend Pin/unpin/reorder go through the existing `PATCH /api/experimental/chats/{chat}` via the `pin_order` field on `UpdateChatRequest`. The handler routes based on current pin state: `pin_order == 0` unpins, `> 0` on an already-pinned chat reorders, `> 0` on an unpinned chat appends to end. ## Frontend - `pinChat` / `unpinChat` / `reorderPinnedChat` optimistic mutations using shared `isChatListQuery` predicate - Sidebar renders Pinned section above time groups, excludes pinned chats from time groups - Pin/Unpin context menu items (hidden for child/delegated chats) - `@dnd-kit/core` + `@dnd-kit/sortable` for drag-to-reorder with `MouseSensor`, `TouchSensor`, and `KeyboardSensor` - Local pin-order override prevents flash on drop; click blocker prevents NavLink navigation after drag --- *PR generated with Coder Agents* --- coderd/database/db2sdk/db2sdk.go | 1 + coderd/database/db2sdk/db2sdk_test.go | 1 + coderd/database/dbauthz/dbauthz.go | 33 ++ coderd/database/dbauthz/dbauthz_test.go | 22 ++ coderd/database/dbmetrics/querymetrics.go | 24 ++ coderd/database/dbmock/dbmock.go | 42 +++ coderd/database/dump.sql | 3 +- .../migrations/000453_chat_pin_order.down.sql | 1 + .../migrations/000453_chat_pin_order.up.sql | 1 + coderd/database/modelqueries.go | 1 + coderd/database/models.go | 1 + coderd/database/querier.go | 8 + coderd/database/querier_test.go | 179 ++++++++++ coderd/database/queries.sql.go | 232 ++++++++++++- coderd/database/queries/chats.sql | 170 +++++++++- coderd/exp_chats.go | 52 ++- coderd/exp_chats_test.go | 121 +++++++ codersdk/chats.go | 15 +- site/package.json | 3 + site/pnpm-lock.yaml | 56 ++++ site/src/api/queries/chats.test.ts | 144 +++++++++ site/src/api/queries/chats.ts | 184 ++++++++++- site/src/api/typesGenerated.ts | 13 + .../pages/AgentsPage/AgentDetail.stories.tsx | 3 + site/src/pages/AgentsPage/AgentEmbedPage.tsx | 2 + site/src/pages/AgentsPage/AgentsPage.tsx | 37 +++ .../AgentsPage/AgentsPageView.stories.tsx | 1 + site/src/pages/AgentsPage/AgentsPageView.tsx | 15 + .../AgentDetail/ChatContext.test.tsx | 1 + .../components/AgentDetail/TopBar.stories.tsx | 1 + .../components/AgentDetailView.stories.tsx | 1 + .../Sidebar/AgentsSidebar.stories.tsx | 216 +++++++++++-- .../components/Sidebar/AgentsSidebar.test.tsx | 3 + .../components/Sidebar/AgentsSidebar.tsx | 306 +++++++++++++++--- 34 files changed, 1786 insertions(+), 107 deletions(-) create mode 100644 coderd/database/migrations/000453_chat_pin_order.down.sql create mode 100644 coderd/database/migrations/000453_chat_pin_order.up.sql diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 8f8980c65c..7f3a7d8c0c 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1535,6 +1535,7 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.Chat { Title: c.Title, Status: codersdk.ChatStatus(c.Status), Archived: c.Archived, + PinOrder: c.PinOrder, CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, MCPServerIDs: mcpServerIDs, diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 9c1b4f109d..0695ea903b 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -538,6 +538,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) { CreatedAt: now, UpdatedAt: now, Archived: true, + PinOrder: 1, MCPServerIDs: []uuid.UUID{uuid.New()}, Labels: database.StringMap{"env": "prod"}, } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 51e32e0ba4..ba86137ab8 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5523,6 +5523,17 @@ func (q *querier) PaginatedOrganizationMembers(ctx context.Context, arg database return q.db.PaginatedOrganizationMembers(ctx, arg) } +func (q *querier) PinChatByID(ctx context.Context, id uuid.UUID) error { + chat, err := q.db.GetChatByID(ctx, id) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.PinChatByID(ctx, id) +} + func (q *querier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (database.ChatQueuedMessage, error) { chat, err := q.db.GetChatByID(ctx, chatID) if err != nil { @@ -5648,6 +5659,17 @@ func (q *querier) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error { return update(q.log, q.auth, fetch, q.db.UnfavoriteWorkspace)(ctx, id) } +func (q *querier) UnpinChatByID(ctx context.Context, id uuid.UUID) error { + chat, err := q.db.GetChatByID(ctx, id) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.UnpinChatByID(ctx, id) +} + func (q *querier) UnsetDefaultChatModelConfigs(ctx context.Context) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err @@ -5748,6 +5770,17 @@ func (q *querier) UpdateChatModelConfig(ctx context.Context, arg database.Update return q.db.UpdateChatModelConfig(ctx, arg) } +func (q *querier) UpdateChatPinOrder(ctx context.Context, arg database.UpdateChatPinOrderParams) error { + chat, err := q.db.GetChatByID(ctx, arg.ID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.UpdateChatPinOrder(ctx, arg) +} + func (q *querier) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return database.ChatProvider{}, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 76dd630590..42e81e3e2e 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -401,6 +401,18 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UnarchiveChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes() check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns() })) + s.Run("PinChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().PinChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns() + })) + s.Run("UnpinChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UnpinChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns() + })) s.Run("SoftDeleteChatMessagesAfterID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) arg := database.SoftDeleteChatMessagesAfterIDParams{ @@ -827,6 +839,16 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpdateChatProvider(gomock.Any(), arg).Return(provider, nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(provider) })) + s.Run("UpdateChatPinOrder", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.UpdateChatPinOrderParams{ + ID: chat.ID, + PinOrder: 2, + } + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatPinOrder(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns() + })) s.Run("UpdateChatStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) arg := database.UpdateChatStatusParams{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 0373ea802a..53cd82a3da 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3920,6 +3920,14 @@ func (m queryMetricsStore) PaginatedOrganizationMembers(ctx context.Context, arg return r0, r1 } +func (m queryMetricsStore) PinChatByID(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.PinChatByID(ctx, id) + m.queryLatencies.WithLabelValues("PinChatByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "PinChatByID").Inc() + return r0 +} + func (m queryMetricsStore) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (database.ChatQueuedMessage, error) { start := time.Now() r0, r1 := m.s.PopNextQueuedMessage(ctx, chatID) @@ -4024,6 +4032,14 @@ func (m queryMetricsStore) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID return r0 } +func (m queryMetricsStore) UnpinChatByID(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.UnpinChatByID(ctx, id) + m.queryLatencies.WithLabelValues("UnpinChatByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UnpinChatByID").Inc() + return r0 +} + func (m queryMetricsStore) UnsetDefaultChatModelConfigs(ctx context.Context) error { start := time.Now() r0 := m.s.UnsetDefaultChatModelConfigs(ctx) @@ -4104,6 +4120,14 @@ func (m queryMetricsStore) UpdateChatModelConfig(ctx context.Context, arg databa return r0, r1 } +func (m queryMetricsStore) UpdateChatPinOrder(ctx context.Context, arg database.UpdateChatPinOrderParams) error { + start := time.Now() + r0 := m.s.UpdateChatPinOrder(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatPinOrder").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatPinOrder").Inc() + return r0 +} + func (m queryMetricsStore) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) { start := time.Now() r0, r1 := m.s.UpdateChatProvider(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 4b51306e8f..ea437248c4 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -7411,6 +7411,20 @@ func (mr *MockStoreMockRecorder) PaginatedOrganizationMembers(ctx, arg any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaginatedOrganizationMembers", reflect.TypeOf((*MockStore)(nil).PaginatedOrganizationMembers), ctx, arg) } +// PinChatByID mocks base method. +func (m *MockStore) PinChatByID(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PinChatByID", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// PinChatByID indicates an expected call of PinChatByID. +func (mr *MockStoreMockRecorder) PinChatByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PinChatByID", reflect.TypeOf((*MockStore)(nil).PinChatByID), ctx, id) +} + // Ping mocks base method. func (m *MockStore) Ping(ctx context.Context) (time.Duration, error) { m.ctrl.T.Helper() @@ -7614,6 +7628,20 @@ func (mr *MockStoreMockRecorder) UnfavoriteWorkspace(ctx, id any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnfavoriteWorkspace", reflect.TypeOf((*MockStore)(nil).UnfavoriteWorkspace), ctx, id) } +// UnpinChatByID mocks base method. +func (m *MockStore) UnpinChatByID(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnpinChatByID", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnpinChatByID indicates an expected call of UnpinChatByID. +func (mr *MockStoreMockRecorder) UnpinChatByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnpinChatByID", reflect.TypeOf((*MockStore)(nil).UnpinChatByID), ctx, id) +} + // UnsetDefaultChatModelConfigs mocks base method. func (m *MockStore) UnsetDefaultChatModelConfigs(ctx context.Context) error { m.ctrl.T.Helper() @@ -7762,6 +7790,20 @@ func (mr *MockStoreMockRecorder) UpdateChatModelConfig(ctx, arg any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatModelConfig", reflect.TypeOf((*MockStore)(nil).UpdateChatModelConfig), ctx, arg) } +// UpdateChatPinOrder mocks base method. +func (m *MockStore) UpdateChatPinOrder(ctx context.Context, arg database.UpdateChatPinOrderParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatPinOrder", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateChatPinOrder indicates an expected call of UpdateChatPinOrder. +func (mr *MockStoreMockRecorder) UpdateChatPinOrder(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatPinOrder", reflect.TypeOf((*MockStore)(nil).UpdateChatPinOrder), ctx, arg) +} + // UpdateChatProvider mocks base method. func (m *MockStore) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 154d22cd62..0f4a0bc011 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1401,7 +1401,8 @@ CREATE TABLE chats ( mcp_server_ids uuid[] DEFAULT '{}'::uuid[] NOT NULL, labels jsonb DEFAULT '{}'::jsonb NOT NULL, build_id uuid, - agent_id uuid + agent_id uuid, + pin_order integer DEFAULT 0 NOT NULL ); CREATE TABLE connection_logs ( diff --git a/coderd/database/migrations/000453_chat_pin_order.down.sql b/coderd/database/migrations/000453_chat_pin_order.down.sql new file mode 100644 index 0000000000..e2d66eb97d --- /dev/null +++ b/coderd/database/migrations/000453_chat_pin_order.down.sql @@ -0,0 +1 @@ +ALTER TABLE chats DROP COLUMN pin_order; diff --git a/coderd/database/migrations/000453_chat_pin_order.up.sql b/coderd/database/migrations/000453_chat_pin_order.up.sql new file mode 100644 index 0000000000..31f058b432 --- /dev/null +++ b/coderd/database/migrations/000453_chat_pin_order.up.sql @@ -0,0 +1 @@ +ALTER TABLE chats ADD COLUMN pin_order integer DEFAULT 0 NOT NULL; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index af1fd954c7..69353a8dcb 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -793,6 +793,7 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ); err != nil { return nil, err } diff --git a/coderd/database/models.go b/coderd/database/models.go index d1c614923b..acf41bfb73 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4173,6 +4173,7 @@ type Chat struct { Labels StringMap `db:"labels" json:"labels"` BuildID uuid.NullUUID `db:"build_id" json:"build_id"` AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` + PinOrder int32 `db:"pin_order" json:"pin_order"` } type ChatDiffStatus struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d26febbe28..cfc25b077e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -809,6 +809,12 @@ type sqlcQuerier interface { // - Use both to get a specific org member row OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error) PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error) + // Under READ COMMITTED, concurrent pin operations for the same + // owner may momentarily produce duplicate pin_order values because + // each CTE snapshot does not see the other's writes. The next + // pin/unpin/reorder operation's ROW_NUMBER() self-heals the + // sequence, so this is acceptable. + PinChatByID(ctx context.Context, id uuid.UUID) error PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (ChatQueuedMessage, error) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) @@ -836,6 +842,7 @@ type sqlcQuerier interface { // This will always work regardless of the current state of the template version. UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error + UnpinChatByID(ctx context.Context, id uuid.UUID) error UnsetDefaultChatModelConfigs(ctx context.Context) error UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error @@ -848,6 +855,7 @@ type sqlcQuerier interface { UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatMCPServerIDsParams) (Chat, error) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMessageByIDParams) (ChatMessage, error) UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error) + UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOrderParams) error UpdateChatProvider(ctx context.Context, arg UpdateChatProviderParams) (ChatProvider, error) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index b1f98ffe42..dcc7ee0541 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -10487,6 +10487,185 @@ func TestGetPRInsights(t *testing.T) { }) } +func TestChatPinOrderQueries(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + + setup := func(t *testing.T) (context.Context, database.Store, uuid.UUID, uuid.UUID) { + t.Helper() + + db, _ := dbtestutil.NewDB(t) + owner := dbgen.User(t, db, database.User{}) + + // Use background context for fixture setup so the + // timed test context doesn't tick during DB init. + bg := context.Background() + _, err := db.InsertChatProvider(bg, database.InsertChatProviderParams{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test-key", + Enabled: true, + }) + require.NoError(t, err) + + modelCfg, err := db.InsertChatModelConfig(bg, database.InsertChatModelConfigParams{ + Provider: "openai", + Model: "test-model", + DisplayName: "Test Model", + CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + ctx := testutil.Context(t, testutil.WaitMedium) + return ctx, db, owner.ID, modelCfg.ID + } + + createChat := func(t *testing.T, ctx context.Context, db database.Store, ownerID, modelCfgID uuid.UUID, title string) database.Chat { + t.Helper() + + chat, err := db.InsertChat(ctx, database.InsertChatParams{ + OwnerID: ownerID, + LastModelConfigID: modelCfgID, + Title: title, + }) + require.NoError(t, err) + return chat + } + + requirePinOrders := func(t *testing.T, ctx context.Context, db database.Store, want map[uuid.UUID]int32) { + t.Helper() + + for chatID, wantPinOrder := range want { + chat, err := db.GetChatByID(ctx, chatID) + require.NoError(t, err) + require.EqualValues(t, wantPinOrder, chat.PinOrder) + } + } + + t.Run("PinChatByIDAppendsWithinOwner", func(t *testing.T) { + t.Parallel() + + ctx, db, ownerID, modelCfgID := setup(t) + first := createChat(t, ctx, db, ownerID, modelCfgID, "first") + second := createChat(t, ctx, db, ownerID, modelCfgID, "second") + third := createChat(t, ctx, db, ownerID, modelCfgID, "third") + + otherOwner := dbgen.User(t, db, database.User{}) + other := createChat(t, ctx, db, otherOwner.ID, modelCfgID, "other-owner") + + require.NoError(t, db.PinChatByID(ctx, other.ID)) + require.NoError(t, db.PinChatByID(ctx, first.ID)) + require.NoError(t, db.PinChatByID(ctx, second.ID)) + require.NoError(t, db.PinChatByID(ctx, third.ID)) + + requirePinOrders(t, ctx, db, map[uuid.UUID]int32{ + first.ID: 1, + second.ID: 2, + third.ID: 3, + other.ID: 1, + }) + }) + + t.Run("UpdateChatPinOrderShiftsNeighborsAndClamps", func(t *testing.T) { + t.Parallel() + + ctx, db, ownerID, modelCfgID := setup(t) + first := createChat(t, ctx, db, ownerID, modelCfgID, "first") + second := createChat(t, ctx, db, ownerID, modelCfgID, "second") + third := createChat(t, ctx, db, ownerID, modelCfgID, "third") + + for _, chat := range []database.Chat{first, second, third} { + require.NoError(t, db.PinChatByID(ctx, chat.ID)) + } + + require.NoError(t, db.UpdateChatPinOrder(ctx, database.UpdateChatPinOrderParams{ + ID: third.ID, + PinOrder: 1, + })) + requirePinOrders(t, ctx, db, map[uuid.UUID]int32{ + first.ID: 2, + second.ID: 3, + third.ID: 1, + }) + + require.NoError(t, db.UpdateChatPinOrder(ctx, database.UpdateChatPinOrderParams{ + ID: third.ID, + PinOrder: 99, + })) + requirePinOrders(t, ctx, db, map[uuid.UUID]int32{ + first.ID: 1, + second.ID: 2, + third.ID: 3, + }) + }) + + t.Run("UnpinChatByIDCompactsPinnedChats", func(t *testing.T) { + t.Parallel() + + ctx, db, ownerID, modelCfgID := setup(t) + first := createChat(t, ctx, db, ownerID, modelCfgID, "first") + second := createChat(t, ctx, db, ownerID, modelCfgID, "second") + third := createChat(t, ctx, db, ownerID, modelCfgID, "third") + + for _, chat := range []database.Chat{first, second, third} { + require.NoError(t, db.PinChatByID(ctx, chat.ID)) + } + + require.NoError(t, db.UnpinChatByID(ctx, second.ID)) + requirePinOrders(t, ctx, db, map[uuid.UUID]int32{ + first.ID: 1, + second.ID: 0, + third.ID: 2, + }) + }) + + t.Run("ArchiveClearsPinAndExcludesFromRanking", func(t *testing.T) { + t.Parallel() + + ctx, db, ownerID, modelCfgID := setup(t) + first := createChat(t, ctx, db, ownerID, modelCfgID, "first") + second := createChat(t, ctx, db, ownerID, modelCfgID, "second") + third := createChat(t, ctx, db, ownerID, modelCfgID, "third") + + for _, chat := range []database.Chat{first, second, third} { + require.NoError(t, db.PinChatByID(ctx, chat.ID)) + } + + // Archive the middle pin. + require.NoError(t, db.ArchiveChatByID(ctx, second.ID)) + + // Archived chat should have pin_order cleared. Remaining + // pins keep their original positions; the next mutation + // compacts via ROW_NUMBER(). + requirePinOrders(t, ctx, db, map[uuid.UUID]int32{ + first.ID: 1, + second.ID: 0, + third.ID: 3, + }) + + // Reorder among remaining active pins — archived chat + // should not interfere with position calculation. + require.NoError(t, db.UpdateChatPinOrder(ctx, database.UpdateChatPinOrderParams{ + ID: third.ID, + PinOrder: 1, + })) + // After reorder, ROW_NUMBER() compacts the sequence. + requirePinOrders(t, ctx, db, map[uuid.UUID]int32{ + first.ID: 2, + second.ID: 0, + third.ID: 1, + }) + }) +} + func TestChatLabels(t *testing.T) { t.Parallel() if testing.Short() { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 81c69a6ae9..06ba299204 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4013,7 +4013,7 @@ WHERE $3::int ) 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 + 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 ` type AcquireChatsParams struct { @@ -4054,6 +4054,7 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) ( &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ); err != nil { return nil, err } @@ -4188,7 +4189,7 @@ func (q *sqlQuerier) AcquireStaleChatDiffStatuses(ctx context.Context, limitVal } const archiveChatByID = `-- name: ArchiveChatByID :exec -UPDATE chats SET archived = true, updated_at = NOW() +UPDATE chats SET archived = true, pin_order = 0, updated_at = NOW() WHERE id = $1 OR root_chat_id = $1 ` @@ -4287,7 +4288,7 @@ func (q *sqlQuerier) DeleteChatUsageLimitUserOverride(ctx context.Context, userI const getChatByID = `-- name: GetChatByID :one SELECT - 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 + 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 FROM chats WHERE @@ -4318,12 +4319,13 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ) return i, err } const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one -SELECT 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 FROM chats WHERE id = $1::uuid FOR UPDATE +SELECT 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 FROM chats WHERE id = $1::uuid FOR UPDATE ` func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) { @@ -4350,6 +4352,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ) return i, err } @@ -5194,7 +5197,7 @@ func (q *sqlQuerier) GetChatUsageLimitUserOverride(ctx context.Context, userID u const getChats = `-- name: GetChats :many SELECT - 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 + 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 FROM chats WHERE @@ -5287,6 +5290,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]Chat, &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ); err != nil { return nil, err } @@ -5302,7 +5306,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]Chat, } const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many -SELECT 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 +SELECT 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 FROM chats WHERE archived = false AND workspace_id = ANY($1::uuid[]) @@ -5339,6 +5343,7 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ); err != nil { return nil, err } @@ -5404,7 +5409,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh const getStaleChats = `-- name: GetStaleChats :many SELECT - 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 + 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 FROM chats WHERE @@ -5444,6 +5449,7 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ); err != nil { return nil, err } @@ -5525,7 +5531,7 @@ INSERT INTO chats ( COALESCE($11::jsonb, '{}'::jsonb) ) 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 + 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 ` type InsertChatParams struct { @@ -5578,6 +5584,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ) return i, err } @@ -5862,6 +5869,67 @@ func (q *sqlQuerier) ListChatUsageLimitOverrides(ctx context.Context) ([]ListCha return items, nil } +const pinChatByID = `-- name: PinChatByID :exec +WITH target_chat AS ( + SELECT + id, + owner_id + FROM + chats + WHERE + id = $1::uuid +), +ranked AS ( + SELECT + c.id, + ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS next_pin_order + FROM + chats c + JOIN + target_chat ON c.owner_id = target_chat.owner_id + WHERE + c.pin_order > 0 + AND c.archived = FALSE + AND c.id <> target_chat.id +), +updates AS ( + SELECT + ranked.id, + ranked.next_pin_order AS pin_order + FROM + ranked + UNION ALL + SELECT + target_chat.id, + COALESCE(( + SELECT + MAX(ranked.next_pin_order) + FROM + ranked + ), 0) + 1 AS pin_order + FROM + target_chat +) +UPDATE + chats c +SET + pin_order = updates.pin_order +FROM + updates +WHERE + c.id = updates.id +` + +// Under READ COMMITTED, concurrent pin operations for the same +// owner may momentarily produce duplicate pin_order values because +// each CTE snapshot does not see the other's writes. The next +// pin/unpin/reorder operation's ROW_NUMBER() self-heals the +// sequence, so this is acceptable. +func (q *sqlQuerier) PinChatByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, pinChatByID, id) + return err +} + const popNextQueuedMessage = `-- name: PopNextQueuedMessage :one DELETE FROM chat_queued_messages WHERE id = ( @@ -5964,6 +6032,65 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) error return err } +const unpinChatByID = `-- name: UnpinChatByID :exec +WITH target_chat AS ( + SELECT + id, + owner_id + FROM + chats + WHERE + id = $1::uuid +), +ranked AS ( + SELECT + c.id, + ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position + FROM + chats c + JOIN + target_chat ON c.owner_id = target_chat.owner_id + WHERE + c.pin_order > 0 + AND c.archived = FALSE +), +target AS ( + SELECT + ranked.id, + ranked.current_position + FROM + ranked + WHERE + ranked.id = $1::uuid +), +updates AS ( + SELECT + ranked.id, + CASE + WHEN ranked.id = target.id THEN 0 + WHEN ranked.current_position > target.current_position THEN ranked.current_position - 1 + ELSE ranked.current_position + END AS pin_order + FROM + ranked + CROSS JOIN + target +) +UPDATE + chats c +SET + pin_order = updates.pin_order +FROM + updates +WHERE + c.id = updates.id +` + +func (q *sqlQuerier) UnpinChatByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, unpinChatByID, id) + return err +} + const updateChatBuildAgentBinding = `-- name: UpdateChatBuildAgentBinding :one UPDATE chats SET build_id = $1::uuid, @@ -5971,7 +6098,7 @@ UPDATE chats SET updated_at = NOW() WHERE id = $3::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 +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 ` type UpdateChatBuildAgentBindingParams struct { @@ -6004,6 +6131,7 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ) return i, err } @@ -6017,7 +6145,7 @@ SET 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 + 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 ` type UpdateChatByIDParams struct { @@ -6049,6 +6177,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ) return i, err } @@ -6088,7 +6217,7 @@ SET 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 + 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 ` type UpdateChatLabelsByIDParams struct { @@ -6120,6 +6249,7 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ) return i, err } @@ -6133,7 +6263,7 @@ SET 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 + 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 ` type UpdateChatMCPServerIDsParams struct { @@ -6165,6 +6295,7 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ) return i, err } @@ -6216,6 +6347,77 @@ func (q *sqlQuerier) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMe return i, err } +const updateChatPinOrder = `-- name: UpdateChatPinOrder :exec +WITH target_chat AS ( + SELECT + id, + owner_id + FROM + chats + WHERE + id = $1::uuid +), +ranked AS ( + SELECT + c.id, + ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position, + COUNT(*) OVER () :: integer AS pinned_count + FROM + chats c + JOIN + target_chat ON c.owner_id = target_chat.owner_id + WHERE + c.pin_order > 0 + AND c.archived = FALSE +), +target AS ( + SELECT + ranked.id, + ranked.current_position, + LEAST(GREATEST($2::integer, 1), ranked.pinned_count) AS desired_position + FROM + ranked + WHERE + ranked.id = $1::uuid +), +updates AS ( + SELECT + ranked.id, + CASE + WHEN ranked.id = target.id THEN target.desired_position + WHEN target.desired_position < target.current_position + AND ranked.current_position >= target.desired_position + AND ranked.current_position < target.current_position THEN ranked.current_position + 1 + WHEN target.desired_position > target.current_position + AND ranked.current_position > target.current_position + AND ranked.current_position <= target.desired_position THEN ranked.current_position - 1 + ELSE ranked.current_position + END AS pin_order + FROM + ranked + CROSS JOIN + target +) +UPDATE + chats c +SET + pin_order = updates.pin_order +FROM + updates +WHERE + c.id = updates.id +` + +type UpdateChatPinOrderParams struct { + ID uuid.UUID `db:"id" json:"id"` + PinOrder int32 `db:"pin_order" json:"pin_order"` +} + +func (q *sqlQuerier) UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOrderParams) error { + _, err := q.db.ExecContext(ctx, updateChatPinOrder, arg.ID, arg.PinOrder) + return err +} + const updateChatStatus = `-- name: UpdateChatStatus :one UPDATE chats @@ -6229,7 +6431,7 @@ SET WHERE id = $6::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 + 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 ` type UpdateChatStatusParams struct { @@ -6272,6 +6474,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ) return i, err } @@ -6283,7 +6486,7 @@ UPDATE chats SET agent_id = $3::uuid, updated_at = NOW() WHERE id = $4::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 +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 ` type UpdateChatWorkspaceBindingParams struct { @@ -6322,6 +6525,7 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC &i.Labels, &i.BuildID, &i.AgentID, + &i.PinOrder, ) return i, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 548e05949f..957e917f65 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1,10 +1,178 @@ -- name: ArchiveChatByID :exec -UPDATE chats SET archived = true, updated_at = NOW() +UPDATE chats SET archived = true, pin_order = 0, updated_at = NOW() WHERE id = @id OR root_chat_id = @id; -- name: UnarchiveChatByID :exec UPDATE chats SET archived = false, updated_at = NOW() WHERE id = @id::uuid; +-- name: PinChatByID :exec +WITH target_chat AS ( + SELECT + id, + owner_id + FROM + chats + WHERE + id = @id::uuid +), +-- Under READ COMMITTED, concurrent pin operations for the same +-- owner may momentarily produce duplicate pin_order values because +-- each CTE snapshot does not see the other's writes. The next +-- pin/unpin/reorder operation's ROW_NUMBER() self-heals the +-- sequence, so this is acceptable. +ranked AS ( + SELECT + c.id, + ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS next_pin_order + FROM + chats c + JOIN + target_chat ON c.owner_id = target_chat.owner_id + WHERE + c.pin_order > 0 + AND c.archived = FALSE + AND c.id <> target_chat.id +), +updates AS ( + SELECT + ranked.id, + ranked.next_pin_order AS pin_order + FROM + ranked + UNION ALL + SELECT + target_chat.id, + COALESCE(( + SELECT + MAX(ranked.next_pin_order) + FROM + ranked + ), 0) + 1 AS pin_order + FROM + target_chat +) +UPDATE + chats c +SET + pin_order = updates.pin_order +FROM + updates +WHERE + c.id = updates.id; + +-- name: UnpinChatByID :exec +WITH target_chat AS ( + SELECT + id, + owner_id + FROM + chats + WHERE + id = @id::uuid +), +ranked AS ( + SELECT + c.id, + ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position + FROM + chats c + JOIN + target_chat ON c.owner_id = target_chat.owner_id + WHERE + c.pin_order > 0 + AND c.archived = FALSE +), +target AS ( + SELECT + ranked.id, + ranked.current_position + FROM + ranked + WHERE + ranked.id = @id::uuid +), +updates AS ( + SELECT + ranked.id, + CASE + WHEN ranked.id = target.id THEN 0 + WHEN ranked.current_position > target.current_position THEN ranked.current_position - 1 + ELSE ranked.current_position + END AS pin_order + FROM + ranked + CROSS JOIN + target +) +UPDATE + chats c +SET + pin_order = updates.pin_order +FROM + updates +WHERE + c.id = updates.id; + +-- name: UpdateChatPinOrder :exec +WITH target_chat AS ( + SELECT + id, + owner_id + FROM + chats + WHERE + id = @id::uuid +), +ranked AS ( + SELECT + c.id, + ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position, + COUNT(*) OVER () :: integer AS pinned_count + FROM + chats c + JOIN + target_chat ON c.owner_id = target_chat.owner_id + WHERE + c.pin_order > 0 + AND c.archived = FALSE +), +target AS ( + SELECT + ranked.id, + ranked.current_position, + LEAST(GREATEST(@pin_order::integer, 1), ranked.pinned_count) AS desired_position + FROM + ranked + WHERE + ranked.id = @id::uuid +), +updates AS ( + SELECT + ranked.id, + CASE + WHEN ranked.id = target.id THEN target.desired_position + WHEN target.desired_position < target.current_position + AND ranked.current_position >= target.desired_position + AND ranked.current_position < target.current_position THEN ranked.current_position + 1 + WHEN target.desired_position > target.current_position + AND ranked.current_position > target.current_position + AND ranked.current_position <= target.desired_position THEN ranked.current_position - 1 + ELSE ranked.current_position + END AS pin_order + FROM + ranked + CROSS JOIN + target +) +UPDATE + chats c +SET + pin_order = updates.pin_order +FROM + updates +WHERE + c.id = updates.id; + -- name: SoftDeleteChatMessagesAfterID :exec UPDATE chat_messages diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 573a565fa9..2c8d80fbf1 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1555,8 +1555,8 @@ func (api *API) watchChatDesktop(rw http.ResponseWriter, r *http.Request) { logger.Debug(ctx, "desktop Bicopy finished") } -// patchChat updates a chat resource. Supports updating labels and -// toggling the archived state. +// patchChat updates a chat resource. Supports updating labels, +// archiving, pinning, and pinned-chat ordering. func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() chat := httpmw.ChatParam(r) @@ -1643,6 +1643,54 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) { } } + if req.PinOrder != nil { + pinOrder := *req.PinOrder + if pinOrder < 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Pin order must be non-negative.", + }) + return + } + + if pinOrder > 0 && chat.Archived { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Cannot pin an archived chat.", + }) + return + } + + // The behavior depends on current pin state: + // - pinOrder == 0: unpin. + // - pinOrder > 0 && already pinned: reorder (shift + // neighbors, clamp to [1, count]). + // - pinOrder > 0 && not pinned: append to end. The + // requested value is intentionally ignored because + // PinChatByID also bumps updated_at to keep the + // chat visible in the paginated sidebar. + var err error + errMsg := "Failed to pin chat." + switch { + case pinOrder == 0: + errMsg = "Failed to unpin chat." + err = api.Database.UnpinChatByID(ctx, chat.ID) + case chat.PinOrder > 0: + errMsg = "Failed to reorder pinned chat." + err = api.Database.UpdateChatPinOrder(ctx, database.UpdateChatPinOrderParams{ + ID: chat.ID, + PinOrder: pinOrder, + }) + default: + err = api.Database.PinChatByID(ctx, chat.ID) + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: errMsg, + Detail: err.Error(), + }) + return + } + } + rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 59cb7ced1b..105b3e2173 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -2162,6 +2162,127 @@ func TestUnarchiveChat(t *testing.T) { }) } +func TestChatPinOrder(t *testing.T) { + t.Parallel() + + createChat := func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, title string) codersdk.Chat { + t.Helper() + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: title, + }, + }, + }) + require.NoError(t, err) + return chat + } + + getChat := func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, chatID uuid.UUID) codersdk.Chat { + t.Helper() + + chat, err := client.GetChat(ctx, chatID) + require.NoError(t, err) + return chat + } + + t.Run("PinReorderAndUnpin", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + first := createChat(ctx, t, client, "first pinned chat") + second := createChat(ctx, t, client, "second pinned chat") + third := createChat(ctx, t, client, "third pinned chat") + + err := client.UpdateChat(ctx, first.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))}) + require.NoError(t, err) + err = client.UpdateChat(ctx, second.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))}) + require.NoError(t, err) + err = client.UpdateChat(ctx, third.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))}) + require.NoError(t, err) + + first = getChat(ctx, t, client, first.ID) + second = getChat(ctx, t, client, second.ID) + third = getChat(ctx, t, client, third.ID) + require.EqualValues(t, 1, first.PinOrder) + require.EqualValues(t, 2, second.PinOrder) + require.EqualValues(t, 3, third.PinOrder) + + err = client.UpdateChat(ctx, third.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))}) + require.NoError(t, err) + + first = getChat(ctx, t, client, first.ID) + second = getChat(ctx, t, client, second.ID) + third = getChat(ctx, t, client, third.ID) + require.EqualValues(t, 2, first.PinOrder) + require.EqualValues(t, 3, second.PinOrder) + require.EqualValues(t, 1, third.PinOrder) + + err = client.UpdateChat(ctx, first.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(0))}) + require.NoError(t, err) + + first = getChat(ctx, t, client, first.ID) + second = getChat(ctx, t, client, second.ID) + third = getChat(ctx, t, client, third.ID) + require.Zero(t, first.PinOrder) + require.EqualValues(t, 2, second.PinOrder) + require.EqualValues(t, 1, third.PinOrder) + }) + + t.Run("ArchiveClearsPinOrder", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + first := createChat(ctx, t, client, "pinned then archived") + second := createChat(ctx, t, client, "stays pinned") + + // Pin both. + err := client.UpdateChat(ctx, first.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))}) + require.NoError(t, err) + err = client.UpdateChat(ctx, second.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))}) + require.NoError(t, err) + + // Archive the first — pin_order should be cleared. + err = client.UpdateChat(ctx, first.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)}) + require.NoError(t, err) + + first = getChat(ctx, t, client, first.ID) + second = getChat(ctx, t, client, second.ID) + require.Zero(t, first.PinOrder, "archived chat should have pin_order 0") + require.True(t, first.Archived) + // The remaining pin keeps its original position. The next + // pin/unpin/reorder operation compacts via ROW_NUMBER(). + require.EqualValues(t, 2, second.PinOrder, "remaining pin keeps original position") + }) + + t.Run("RejectsNegative", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + chat := createChat(ctx, t, client, "negative pin order") + err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(-1))}) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Pin order must be non-negative.", sdkErr.Message) + + chat = getChat(ctx, t, client, chat.ID) + require.Zero(t, chat.PinOrder) + }) +} + func TestPostChatMessages(t *testing.T) { t.Parallel() diff --git a/codersdk/chats.go b/codersdk/chats.go index 0fb34e2838..75223858e1 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -61,6 +61,7 @@ type Chat struct { CreatedAt time.Time `json:"created_at" format:"date-time"` UpdatedAt time.Time `json:"updated_at" format:"date-time"` Archived bool `json:"archived"` + PinOrder int32 `json:"pin_order"` MCPServerIDs []uuid.UUID `json:"mcp_server_ids" format:"uuid"` Labels map[string]string `json:"labels"` } @@ -323,8 +324,18 @@ type CreateChatRequest struct { // UpdateChatRequest is the request to update a chat. type UpdateChatRequest struct { - Title *string `json:"title,omitempty"` - Archived *bool `json:"archived,omitempty"` + Title *string `json:"title,omitempty"` + Archived *bool `json:"archived,omitempty"` + // PinOrder controls the chat's pinned state and position. + // - nil: no change to pin state. + // - 0: unpin the chat. + // - >0 (chat is unpinned): pin the chat, appending it to + // the end of the pinned list. The specific value is + // ignored; the server assigns the next available position. + // - >0 (chat is already pinned): move the chat to the + // requested position, shifting neighbors as needed. The + // value is clamped to [1, pinned_count]. + PinOrder *int32 `json:"pin_order,omitempty"` Labels *map[string]string `json:"labels,omitempty"` } diff --git a/site/package.json b/site/package.json index 23548b12f9..919806da74 100644 --- a/site/package.json +++ b/site/package.json @@ -40,6 +40,9 @@ "#/*": "./src/*" }, "dependencies": { + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", "@emoji-mart/data": "1.2.1", "@emoji-mart/react": "1.1.1", "@emotion/cache": "11.14.0", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index f3fefe5f3c..9ab7753ef7 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -19,6 +19,15 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: 6.3.1 + version: 6.3.1(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + '@dnd-kit/sortable': + specifier: 10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.2(react@19.2.2))(react@19.2.2))(react@19.2.2) + '@dnd-kit/utilities': + specifier: 3.2.2 + version: 3.2.2(react@19.2.2) '@emoji-mart/data': specifier: 1.2.1 version: 1.2.1 @@ -878,6 +887,28 @@ packages: '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==, tarball: https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==, tarball: https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==, tarball: https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==, tarball: https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==, tarball: https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==, tarball: https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz} @@ -7604,6 +7635,31 @@ snapshots: '@date-fns/tz@1.4.1': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.2)': + dependencies: + react: 19.2.2 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.2(react@19.2.2))(react@19.2.2)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.2) + '@dnd-kit/utilities': 3.2.2(react@19.2.2) + react: 19.2.2 + react-dom: 19.2.2(react@19.2.2) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.2(react@19.2.2))(react@19.2.2))(react@19.2.2)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + '@dnd-kit/utilities': 3.2.2(react@19.2.2) + react: 19.2.2 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.2)': + dependencies: + react: 19.2.2 + tslib: 2.8.1 + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 39f1458ef2..fece4b5120 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -20,8 +20,11 @@ import { infiniteChats, interruptChat, invalidateChatListQueries, + pinChat, promoteChatQueuedMessage, + reorderPinnedChat, unarchiveChat, + unpinChat, updateInfiniteChatsCache, } from "./chats"; @@ -84,6 +87,7 @@ const makeChat = ( created_at: "2025-01-01T00:00:00.000Z", updated_at: "2025-01-01T00:00:00.000Z", archived: false, + pin_order: 0, last_error: null, ...overrides, }); @@ -412,6 +416,146 @@ describe("unarchiveChat optimistic update", () => { }); }); +describe("pinChat optimistic update", () => { + it("optimistically appends a newly pinned chat after the highest cached pin order", async () => { + const queryClient = createTestQueryClient(); + const chatId = "chat-new"; + seedInfiniteChats(queryClient, [ + makeChat("chat-pinned-1", { pin_order: 1 }), + makeChat(chatId), + makeChat("chat-pinned-2", { pin_order: 2 }), + ]); + queryClient.setQueryData([...chatsKey, { archived: true }], { + pages: [[makeChat("chat-pinned-archived", { pin_order: 4 })]], + pageParams: [0], + }); + queryClient.setQueryData(chatKey(chatId), makeChat(chatId)); + + const mutation = pinChat(queryClient); + await mutation.onMutate(chatId); + + expect( + readInfiniteChats(queryClient)?.find((chat) => chat.id === chatId) + ?.pin_order, + ).toBe(5); + expect( + queryClient.getQueryData(chatKey(chatId))?.pin_order, + ).toBe(5); + }); +}); + +describe("unpinChat optimistic update", () => { + it("optimistically sets pin_order to 0 in the chats list", async () => { + const queryClient = createTestQueryClient(); + const chatId = "chat-1"; + seedInfiniteChats(queryClient, [makeChat(chatId, { pin_order: 2 })]); + + const mutation = unpinChat(queryClient); + await mutation.onMutate(chatId); + + expect(readInfiniteChats(queryClient)?.[0].pin_order).toBe(0); + }); + + it("optimistically sets pin_order to 0 in the individual chat cache", async () => { + const queryClient = createTestQueryClient(); + const chatId = "chat-1"; + seedInfiniteChats(queryClient, [makeChat(chatId, { pin_order: 2 })]); + queryClient.setQueryData( + chatKey(chatId), + makeChat(chatId, { pin_order: 2 }), + ); + + const mutation = unpinChat(queryClient); + await mutation.onMutate(chatId); + + expect( + queryClient.getQueryData(chatKey(chatId))?.pin_order, + ).toBe(0); + }); + + it("rolls back both caches on error", async () => { + const queryClient = createTestQueryClient(); + const chatId = "chat-1"; + seedInfiniteChats(queryClient, [makeChat(chatId, { pin_order: 3 })]); + queryClient.setQueryData( + chatKey(chatId), + makeChat(chatId, { pin_order: 3 }), + ); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const mutation = unpinChat(queryClient); + const context = await mutation.onMutate(chatId); + + // Verify optimistic update. + expect(readInfiniteChats(queryClient)?.[0].pin_order).toBe(0); + expect( + queryClient.getQueryData(chatKey(chatId))?.pin_order, + ).toBe(0); + + // Roll back. + mutation.onError(new Error("server error"), chatId, context); + + // The chats list is rolled back via invalidation. + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: chatsKey }), + ); + // The individual chat cache is restored directly. + expect( + queryClient.getQueryData(chatKey(chatId))?.pin_order, + ).toBe(3); + }); + + it("invalidates queries on settled", async () => { + const queryClient = createTestQueryClient(); + const chatId = "chat-1"; + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const mutation = unpinChat(queryClient); + await mutation.onSettled(undefined, undefined, chatId); + + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: chatsKey }), + ); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: chatKey(chatId), + exact: true, + }); + }); +}); + +describe("reorderPinnedChat", () => { + it("updates a single chat via updateChat and invalidates list and detail queries", async () => { + const queryClient = createTestQueryClient(); + const chatId = "chat-1"; + vi.mocked(API.experimental.updateChat).mockResolvedValue(undefined); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + const cancelSpy = vi.spyOn(queryClient, "cancelQueries"); + + const mutation = reorderPinnedChat(queryClient); + await mutation.onMutate?.({ chatId, pinOrder: 2 }); + await mutation.mutationFn({ chatId, pinOrder: 2 }); + await mutation.onSettled?.(undefined, undefined, { chatId, pinOrder: 2 }); + + expect(cancelSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: chatsKey }), + ); + expect(cancelSpy).toHaveBeenCalledWith({ + queryKey: chatKey(chatId), + exact: true, + }); + expect(API.experimental.updateChat).toHaveBeenCalledWith(chatId, { + pin_order: 2, + }); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: chatsKey }), + ); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: chatKey(chatId), + exact: true, + }); + }); +}); + describe("chat cost query factories", () => { it("builds the summary query key and forwards snake_case params", async () => { const user = "user-1"; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index b9502a4838..e12d552072 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -89,8 +89,37 @@ export const readInfiniteChatsCache = ( return undefined; }; -/** - * Invalidate only the sidebar chat-list queries (flat + infinite) +const getNextOptimisticPinOrder = (queryClient: QueryClient): number => { + let maxPinOrder = 0; + const queries = queryClient.getQueriesData< + TypesGen.Chat[] | { pages: TypesGen.Chat[][]; pageParams: unknown[] } + >({ + queryKey: chatsKey, + predicate: isChatListQuery, + }); + + for (const [, data] of queries) { + if (!data) { + continue; + } + + if (Array.isArray(data)) { + for (const chat of data) { + maxPinOrder = Math.max(maxPinOrder, chat.pin_order); + } + continue; + } + + for (const page of data.pages) { + for (const chat of page) { + maxPinOrder = Math.max(maxPinOrder, chat.pin_order); + } + } + } + + return maxPinOrder + 1; +}; + /** * Predicate that matches only chat-list queries (the sidebar), not * per-chat queries (detail, messages, diffs, cost). @@ -200,12 +229,7 @@ export const archiveChat = (queryClient: QueryClient) => ({ onMutate: async (chatId: string) => { await queryClient.cancelQueries({ queryKey: chatsKey, - predicate: (query) => { - const key = query.queryKey; - if (key.length <= 1) return true; - const segment = key[1]; - return segment === undefined || typeof segment === "object"; - }, + predicate: isChatListQuery, }); await queryClient.cancelQueries({ queryKey: chatKey(chatId), @@ -263,12 +287,7 @@ export const unarchiveChat = (queryClient: QueryClient) => ({ onMutate: async (chatId: string) => { await queryClient.cancelQueries({ queryKey: chatsKey, - predicate: (query) => { - const key = query.queryKey; - if (key.length <= 1) return true; - const segment = key[1]; - return segment === undefined || typeof segment === "object"; - }, + predicate: isChatListQuery, }); await queryClient.cancelQueries({ queryKey: chatKey(chatId), @@ -320,6 +339,143 @@ export const unarchiveChat = (queryClient: QueryClient) => ({ }, }); +export const pinChat = (queryClient: QueryClient) => ({ + mutationFn: (chatId: string) => + API.experimental.updateChat(chatId, { pin_order: 1 }), + onMutate: async (chatId: string) => { + await queryClient.cancelQueries({ + queryKey: chatsKey, + predicate: isChatListQuery, + }); + await queryClient.cancelQueries({ + queryKey: chatKey(chatId), + exact: true, + }); + const previousChat = queryClient.getQueryData( + chatKey(chatId), + ); + const optimisticPinOrder = getNextOptimisticPinOrder(queryClient); + updateInfiniteChatsCache(queryClient, (chats) => + chats.map((chat) => + chat.id === chatId ? { ...chat, pin_order: optimisticPinOrder } : chat, + ), + ); + if (previousChat) { + queryClient.setQueryData(chatKey(chatId), { + ...previousChat, + pin_order: optimisticPinOrder, + }); + } + return { previousChat }; + }, + onError: ( + _error: unknown, + chatId: string, + context: + | { + previousChat?: TypesGen.Chat; + } + | undefined, + ) => { + // Rollback: invalidate to re-fetch the correct state. + void invalidateChatListQueries(queryClient); + if (context?.previousChat) { + queryClient.setQueryData( + chatKey(chatId), + context.previousChat, + ); + } + }, + onSettled: async (_data: unknown, _error: unknown, chatId: string) => { + await invalidateChatListQueries(queryClient); + await queryClient.invalidateQueries({ + queryKey: chatKey(chatId), + exact: true, + }); + }, +}); + +export const unpinChat = (queryClient: QueryClient) => ({ + mutationFn: (chatId: string) => + API.experimental.updateChat(chatId, { pin_order: 0 }), + onMutate: async (chatId: string) => { + await queryClient.cancelQueries({ + queryKey: chatsKey, + predicate: isChatListQuery, + }); + await queryClient.cancelQueries({ + queryKey: chatKey(chatId), + exact: true, + }); + const previousChat = queryClient.getQueryData( + chatKey(chatId), + ); + updateInfiniteChatsCache(queryClient, (chats) => + chats.map((chat) => + chat.id === chatId ? { ...chat, pin_order: 0 } : chat, + ), + ); + if (previousChat) { + queryClient.setQueryData(chatKey(chatId), { + ...previousChat, + pin_order: 0, + }); + } + return { previousChat }; + }, + onError: ( + _error: unknown, + chatId: string, + context: + | { + previousChat?: TypesGen.Chat; + } + | undefined, + ) => { + // Rollback: invalidate to re-fetch the correct state. + void invalidateChatListQueries(queryClient); + if (context?.previousChat) { + queryClient.setQueryData( + chatKey(chatId), + context.previousChat, + ); + } + }, + onSettled: async (_data: unknown, _error: unknown, chatId: string) => { + await invalidateChatListQueries(queryClient); + await queryClient.invalidateQueries({ + queryKey: chatKey(chatId), + exact: true, + }); + }, +}); + +export const reorderPinnedChat = (queryClient: QueryClient) => ({ + mutationFn: ({ chatId, pinOrder }: { chatId: string; pinOrder: number }) => + API.experimental.updateChat(chatId, { pin_order: pinOrder }), + onMutate: async ({ chatId }: { chatId: string; pinOrder: number }) => { + await queryClient.cancelQueries({ + queryKey: chatsKey, + predicate: isChatListQuery, + }); + await queryClient.cancelQueries({ + queryKey: chatKey(chatId), + exact: true, + }); + }, + onSettled: async ( + _data: unknown, + _error: unknown, + { chatId }: { chatId: string; pinOrder: number }, + ) => { + await invalidateChatListQueries(queryClient); + await queryClient.invalidateQueries({ + queryKey: chatKey(chatId), + exact: true, + }); + }, +}); + export const createChat = (queryClient: QueryClient) => ({ mutationFn: (req: TypesGen.CreateChatRequest) => API.experimental.createChat(req), diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1025682917..eb6fbee1c9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1191,6 +1191,7 @@ export interface Chat { readonly created_at: string; readonly updated_at: string; readonly archived: boolean; + readonly pin_order: number; readonly mcp_server_ids: readonly string[]; readonly labels: Record; } @@ -7110,6 +7111,18 @@ export interface UpdateChatProviderConfigRequest { export interface UpdateChatRequest { readonly title?: string; readonly archived?: boolean; + /** + * PinOrder controls the chat's pinned state and position. + * - nil: no change to pin state. + * - 0: unpin the chat. + * - >0 (chat is unpinned): pin the chat, appending it to + * the end of the pinned list. The specific value is + * ignored; the server assigns the next available position. + * - >0 (chat is already pinned): move the chat to the + * requested position, shifting neighbors as needed. The + * value is clamped to [1, pinned_count]. + */ + readonly pin_order?: number; readonly labels?: Record; } diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index e05ff1b69d..964e38b153 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -53,6 +53,8 @@ const AgentDetailLayout: FC = () => { _workspaceId: string, ) => {}, requestUnarchiveAgent: () => {}, + requestPinAgent: () => {}, + requestUnpinAgent: () => {}, isSidebarCollapsed: false, onToggleSidebarCollapsed: () => {}, onExpandSidebar: () => {}, @@ -137,6 +139,7 @@ const baseChatFields = { created_at: "2026-02-18T00:00:00.000Z", updated_at: "2026-02-18T00:00:00.000Z", archived: false, + pin_order: 0, last_error: null, } as const; diff --git a/site/src/pages/AgentsPage/AgentEmbedPage.tsx b/site/src/pages/AgentsPage/AgentEmbedPage.tsx index bd6cfa080a..cf1f69f20b 100644 --- a/site/src/pages/AgentsPage/AgentEmbedPage.tsx +++ b/site/src/pages/AgentsPage/AgentEmbedPage.tsx @@ -230,6 +230,8 @@ const AgentEmbedPage: FC = () => { requestArchiveAgent, requestUnarchiveAgent, requestArchiveAndDeleteWorkspace, + requestPinAgent: () => {}, + requestUnpinAgent: () => {}, isSidebarCollapsed, onToggleSidebarCollapsed, onExpandSidebar: () => {}, diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index bb0f18614e..14601e7cca 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -21,9 +21,12 @@ import { chatModels, infiniteChats, invalidateChatListQueries, + pinChat, prependToInfiniteChatsCache, readInfiniteChatsCache, + reorderPinnedChat, unarchiveChat, + unpinChat, updateInfiniteChatsCache, } from "#/api/queries/chats"; import { workspaceById } from "#/api/queries/workspaces"; @@ -195,6 +198,28 @@ const AgentsPage: FC = () => { toast.error(getErrorMessage(error, "Failed to unarchive agent.")); }, }); + const pinChatBase = pinChat(queryClient); + const pinAgentMutation = useMutation({ + ...pinChatBase, + onError: (error, chatId, context) => { + pinChatBase.onError(error, chatId, context); + toast.error(getErrorMessage(error, "Failed to pin agent.")); + }, + }); + const unpinChatBase = unpinChat(queryClient); + const unpinAgentMutation = useMutation({ + ...unpinChatBase, + onError: (error, chatId, context) => { + unpinChatBase.onError(error, chatId, context); + toast.error(getErrorMessage(error, "Failed to unpin agent.")); + }, + }); + const reorderPinnedChatMutation = useMutation({ + ...reorderPinnedChat(queryClient), + onError: (error) => { + toast.error(getErrorMessage(error, "Failed to reorder pinned agents.")); + }, + }); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [chatErrorReasons, setChatErrorReasons] = useState< Record @@ -325,6 +350,15 @@ const AgentsPage: FC = () => { const requestUnarchiveAgent = (chatId: string) => { unarchiveAgentMutation.mutate(chatId); }; + const requestPinAgent = (chatId: string) => { + pinAgentMutation.mutate(chatId); + }; + const requestUnpinAgent = (chatId: string) => { + unpinAgentMutation.mutate(chatId); + }; + const requestReorderPinnedAgent = (chatId: string, pinOrder: number) => { + reorderPinnedChatMutation.mutate({ chatId, pinOrder }); + }; const handleToggleSidebarCollapsed = () => setIsSidebarCollapsed((prev) => !prev); @@ -572,6 +606,9 @@ const AgentsPage: FC = () => { requestArchiveAgent={requestArchiveAgent} requestUnarchiveAgent={requestUnarchiveAgent} requestArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace} + requestPinAgent={requestPinAgent} + requestUnpinAgent={requestUnpinAgent} + requestReorderPinnedAgent={requestReorderPinnedAgent} onToggleSidebarCollapsed={handleToggleSidebarCollapsed} isAgentsAdmin={isAgentsAdmin} hasNextPage={chatsQuery.hasNextPage} diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index e0a77b6ed9..db2d91dd78 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -126,6 +126,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ created_at: oneWeekAgo, updated_at: oneWeekAgo, archived: false, + pin_order: 0, last_error: null, ...overrides, }); diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 519db48223..5e9dc87cd8 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -20,6 +20,9 @@ export interface AgentsOutletContext { chatId: string, workspaceId: string, ) => void; + requestPinAgent: (chatId: string) => void; + requestUnpinAgent: (chatId: string) => void; + requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; onExpandSidebar: () => void; @@ -53,6 +56,9 @@ interface AgentsPageViewProps { chatId: string, workspaceId: string, ) => void; + requestPinAgent: (chatId: string) => void; + requestUnpinAgent: (chatId: string) => void; + requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; onToggleSidebarCollapsed: () => void; isAgentsAdmin: boolean; hasNextPage: boolean | undefined; @@ -84,6 +90,9 @@ export const AgentsPageView: FC = ({ requestArchiveAgent, requestUnarchiveAgent, requestArchiveAndDeleteWorkspace, + requestPinAgent, + requestUnpinAgent, + requestReorderPinnedAgent, onToggleSidebarCollapsed, isAgentsAdmin, hasNextPage, @@ -121,6 +130,9 @@ export const AgentsPageView: FC = ({ requestArchiveAgent, requestUnarchiveAgent, requestArchiveAndDeleteWorkspace, + requestPinAgent, + requestUnpinAgent, + requestReorderPinnedAgent, isSidebarCollapsed, onToggleSidebarCollapsed, onExpandSidebar, @@ -151,6 +163,9 @@ export const AgentsPageView: FC = ({ onArchiveAgent={requestArchiveAgent} onUnarchiveAgent={requestUnarchiveAgent} onArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace} + onPinAgent={requestPinAgent} + onUnpinAgent={requestUnpinAgent} + onReorderPinnedAgent={requestReorderPinnedAgent} onBeforeNewAgent={handleNewAgent} isCreating={isCreating} isArchiving={isArchiving} diff --git a/site/src/pages/AgentsPage/components/AgentDetail/ChatContext.test.tsx b/site/src/pages/AgentsPage/components/AgentDetail/ChatContext.test.tsx index 053d0ef965..4e2337d580 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/ChatContext.test.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/ChatContext.test.tsx @@ -207,6 +207,7 @@ const makeChat = (chatID: string): TypesGen.Chat => ({ created_at: "2025-01-01T00:00:00.000Z", updated_at: "2025-01-01T00:00:00.000Z", archived: false, + pin_order: 0, last_error: null, }); diff --git a/site/src/pages/AgentsPage/components/AgentDetail/TopBar.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetail/TopBar.stories.tsx index 9146150415..a71ed1fce9 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/TopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/TopBar.stories.tsx @@ -59,6 +59,7 @@ export const WithParentChat: Story = { created_at: "2026-02-18T00:00:00.000Z", updated_at: "2026-02-18T00:00:00.000Z", archived: false, + pin_order: 0, }, }, }; diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx index 043ee048a1..b00d368fde 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx @@ -45,6 +45,7 @@ const buildChat = (overrides: Partial = {}): TypesGen.Chat => ({ created_at: oneWeekAgo, updated_at: oneWeekAgo, archived: false, + pin_order: 0, last_error: null, ...overrides, }); diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx index ccebb580dd..5972506f99 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx @@ -45,6 +45,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ created_at: oneWeekAgo, updated_at: oneWeekAgo, archived: false, + pin_order: 0, last_error: null, ...overrides, }); @@ -68,6 +69,8 @@ const meta: Meta = { onArchiveAgent: fn(), onUnarchiveAgent: fn(), onArchiveAndDeleteWorkspace: fn(), + onPinAgent: fn(), + onUnpinAgent: fn(), onBeforeNewAgent: fn(), isCreating: false, archivedFilter: "active" as const, @@ -311,7 +314,9 @@ export const ActiveChatAncestryExpanded: Story = { }, }; -const todayTimestamp = new Date().toISOString(); +// Use a fixed offset so the value always falls in the "Today" bucket +// without embedding a literal date that drifts across calendar days. +const recentTimestamp = new Date(Date.now() - 60_000).toISOString(); export const ActiveFilterShowsActiveAgents: Story = { args: { @@ -319,12 +324,12 @@ export const ActiveFilterShowsActiveAgents: Story = { buildChat({ id: "active-1", title: "Active agent one", - updated_at: todayTimestamp, + updated_at: recentTimestamp, }), buildChat({ id: "active-2", title: "Active agent two", - updated_at: todayTimestamp, + updated_at: recentTimestamp, }), ], archivedFilter: "active", @@ -352,13 +357,13 @@ export const ArchivedFilterShowsArchivedAgents: Story = { id: "archived-1", title: "Archived agent one", archived: true, - updated_at: todayTimestamp, + updated_at: recentTimestamp, }), buildChat({ id: "archived-2", title: "Archived agent two", archived: true, - updated_at: todayTimestamp, + updated_at: recentTimestamp, }), ], archivedFilter: "archived", @@ -385,12 +390,12 @@ export const NoArchivedSection: Story = { buildChat({ id: "chat-a", title: "First active agent", - updated_at: todayTimestamp, + updated_at: recentTimestamp, }), buildChat({ id: "chat-b", title: "Second active agent", - updated_at: todayTimestamp, + updated_at: recentTimestamp, }), ], }, @@ -416,7 +421,7 @@ export const ArchivingShowsSpinnerOnly: Story = { buildChat({ id: "archiving-chat", title: "Chat being archived", - updated_at: todayTimestamp, + updated_at: recentTimestamp, }), ], isArchiving: true, @@ -436,7 +441,7 @@ export const DefaultShowsTimestampHidesMenu: Story = { buildChat({ id: "default-chat", title: "Default state agent", - updated_at: todayTimestamp, + updated_at: recentTimestamp, }), ], }, @@ -454,7 +459,7 @@ export const WithDiffStats: Story = { buildChat({ id: "diff-both", title: "Agent with additions and deletions", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "diff-both", url: "https://github.com/coder/coder/pull/1", @@ -469,7 +474,7 @@ export const WithDiffStats: Story = { buildChat({ id: "diff-add-only", title: "Agent with additions only", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "diff-add-only", url: "https://github.com/coder/coder/pull/2", @@ -484,7 +489,7 @@ export const WithDiffStats: Story = { buildChat({ id: "diff-del-only", title: "Agent with deletions only", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "diff-del-only", url: "https://github.com/coder/coder/pull/3", @@ -499,7 +504,7 @@ export const WithDiffStats: Story = { buildChat({ id: "diff-none", title: "Agent with no diff changes", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "diff-none", url: "https://github.com/coder/coder/pull/4", @@ -545,7 +550,7 @@ export const WithDiffStatsLight: Story = { buildChat({ id: "diff-both-light", title: "Agent with additions and deletions", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "diff-both-light", url: "https://github.com/coder/coder/pull/1", @@ -560,7 +565,7 @@ export const WithDiffStatsLight: Story = { buildChat({ id: "diff-add-only-light", title: "Agent with additions only", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "diff-add-only-light", url: "https://github.com/coder/coder/pull/2", @@ -575,7 +580,7 @@ export const WithDiffStatsLight: Story = { buildChat({ id: "diff-del-only-light", title: "Agent with deletions only", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "diff-del-only-light", url: "https://github.com/coder/coder/pull/3", @@ -610,7 +615,7 @@ export const WithPRStateIcons: Story = { buildChat({ id: "pr-open", title: "Open pull request", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "pr-open", url: "https://github.com/coder/coder/pull/100", @@ -626,7 +631,7 @@ export const WithPRStateIcons: Story = { buildChat({ id: "pr-draft", title: "Draft pull request", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "pr-draft", url: "https://github.com/coder/coder/pull/101", @@ -642,7 +647,7 @@ export const WithPRStateIcons: Story = { buildChat({ id: "pr-merged", title: "Merged pull request", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "pr-merged", url: "https://github.com/coder/coder/pull/102", @@ -658,7 +663,7 @@ export const WithPRStateIcons: Story = { buildChat({ id: "pr-closed", title: "Closed pull request", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "pr-closed", url: "https://github.com/coder/coder/pull/103", @@ -674,7 +679,7 @@ export const WithPRStateIcons: Story = { buildChat({ id: "pr-no-state", title: "No PR state (branch only)", - updated_at: todayTimestamp, + updated_at: recentTimestamp, diff_status: { chat_id: "pr-no-state", url: "https://github.com/coder/coder/tree/my-branch", @@ -703,7 +708,7 @@ export const ArchivedAgentUnarchiveOption: Story = { id: "archived-unarchive", title: "Archived agent with unarchive", archived: true, - updated_at: todayTimestamp, + updated_at: recentTimestamp, }), ], archivedFilter: "archived", @@ -738,3 +743,170 @@ export const ArchivedAgentUnarchiveOption: Story = { ).not.toBeInTheDocument(); }, }; + +export const PinnedChatsSection: Story = { + args: { + chats: [ + buildChat({ + id: "pinned-1", + title: "My pinned agent", + updated_at: recentTimestamp, + pin_order: 1, + }), + buildChat({ + id: "unpinned-1", + title: "Regular agent one", + updated_at: recentTimestamp, + }), + buildChat({ + id: "unpinned-2", + title: "Regular agent two", + updated_at: recentTimestamp, + }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("Pinned")).toBeInTheDocument(); + expect(canvas.getByText("My pinned agent")).toBeInTheDocument(); + }); + + // Pinned chat must not appear again under the "Today" time group. + const allPinnedLinks = canvas.getAllByText("My pinned agent"); + expect(allPinnedLinks).toHaveLength(1); + + // Unpinned chats appear under their time group, not Pinned. + expect(canvas.getByText("Today")).toBeInTheDocument(); + expect(canvas.getByText("Regular agent one")).toBeInTheDocument(); + }, +}; + +export const PinUnpinContextMenu: Story = { + args: { + chats: [ + buildChat({ + id: "pin-test", + title: "Agent to pin", + updated_at: recentTimestamp, + }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("Agent to pin")).toBeInTheDocument(); + }); + const trigger = canvas.getByLabelText("Open actions for Agent to pin"); + await userEvent.click(trigger); + await waitFor(() => { + const body = within(document.body); + expect(body.getByText("Pin agent")).toBeInTheDocument(); + }); + // Click Pin agent and verify callback. + const body = within(document.body); + await userEvent.click(body.getByText("Pin agent")); + expect(args.onPinAgent).toHaveBeenCalledWith("pin-test"); + }, +}; + +export const UnpinContextMenu: Story = { + args: { + chats: [ + buildChat({ + id: "unpin-test", + title: "Agent to unpin", + updated_at: recentTimestamp, + pin_order: 1, + }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("Agent to unpin")).toBeInTheDocument(); + }); + const trigger = canvas.getByLabelText("Open actions for Agent to unpin"); + await userEvent.click(trigger); + const body = within(document.body); + await waitFor(() => { + expect(body.getByText("Unpin agent")).toBeInTheDocument(); + }); + await userEvent.click(body.getByText("Unpin agent")); + expect(args.onUnpinAgent).toHaveBeenCalledWith("unpin-test"); + }, +}; + +export const FilterOnPinnedHeader: Story = { + args: { + chats: [ + buildChat({ + id: "pinned-filter", + title: "Pinned Chat", + updated_at: recentTimestamp, + pin_order: 1, + }), + buildChat({ + id: "unpinned-filter", + title: "Unpinned Chat", + updated_at: recentTimestamp, + }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("Pinned")).toBeInTheDocument(); + expect(canvas.getByLabelText("Filter agents")).toBeInTheDocument(); + }); + }, +}; + +export const FilterOnTimeGroupNoPins: Story = { + args: { + chats: [ + buildChat({ + id: "today-only", + title: "Today Chat", + updated_at: recentTimestamp, + }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("Today")).toBeInTheDocument(); + expect(canvas.getByLabelText("Filter agents")).toBeInTheDocument(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx index f14a08e5e3..1a5c625555 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx @@ -62,6 +62,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ created_at: oneWeekAgo, updated_at: oneWeekAgo, archived: false, + pin_order: 0, last_error: null, mcp_server_ids: [], labels: {}, @@ -105,6 +106,8 @@ const defaultProps: React.ComponentProps = { onArchiveAgent: vi.fn(), onUnarchiveAgent: vi.fn(), onArchiveAndDeleteWorkspace: vi.fn(), + onPinAgent: vi.fn(), + onUnpinAgent: vi.fn(), onBeforeNewAgent: vi.fn(), isCreating: false, archivedFilter: "active" as const, diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index bf572f8bfe..416629bd76 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -1,3 +1,21 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { useAuthenticated } from "hooks"; import { AlertTriangleIcon, @@ -20,6 +38,8 @@ import { Loader2Icon, PanelLeftCloseIcon, PauseIcon, + PinIcon, + PinOffIcon, SettingsIcon, ShieldAlertIcon, ShieldIcon, @@ -100,6 +120,9 @@ interface AgentsSidebarProps { onArchiveAgent: (chatId: string) => void; onUnarchiveAgent: (chatId: string) => void; onArchiveAndDeleteWorkspace: (chatId: string, workspaceId: string) => void; + onPinAgent: (chatId: string) => void; + onUnpinAgent: (chatId: string) => void; + onReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; onBeforeNewAgent?: () => void; isCreating: boolean; isArchiving?: boolean; @@ -351,6 +374,8 @@ interface ChatTreeContextValue { chatId: string, workspaceId: string, ) => void; + readonly onPinAgent: (chatId: string) => void; + readonly onUnpinAgent: (chatId: string) => void; } const ChatTreeContext = createContext(null); @@ -384,6 +409,8 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { onArchiveAgent, onUnarchiveAgent, onArchiveAndDeleteWorkspace, + onPinAgent, + onUnpinAgent, } = useChatTree(); const chatID = chat.id; const childIDs = (chatTree.childrenById.get(chatID) ?? []).filter((childID) => @@ -540,6 +567,27 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { + {!chat.archived && !isChildNode && ( + + chat.pin_order > 0 + ? onUnpinAgent(chat.id) + : onPinAgent(chat.id) + } + > + {chat.pin_order > 0 ? ( + <> + + Unpin agent + + ) : ( + <> + + Pin agent + + )} + + )} {chat.archived ? ( = ({ chat, isChildNode }) => { ); }; +const SortableChatTreeNode: FC<{ + chat: Chat; + recentDragRef: React.RefObject; +}> = ({ chat, recentDragRef }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: chat.id, + // Skip the derived-transform measurement after drop. + // localPinOrder already repositions items in the DOM, + // so the two-frame snap-back dance produces stale deltas + // and a visible jitter. This makes items snap directly. + animateLayoutChanges: () => false, + }); + + // Strip scaleX/scaleY that dnd-kit adds by default. + const adjustedTransform = transform + ? { ...transform, scaleX: 1, scaleY: 1 } + : null; + + const style = { + transform: CSS.Transform.toString(adjustedTransform), + transition: isDragging ? "opacity 200ms" : transition, + }; + + return ( + + ); +}; + export const AgentsSidebar: FC = (props) => { const { chats, @@ -604,6 +707,9 @@ export const AgentsSidebar: FC = (props) => { onArchiveAgent, onUnarchiveAgent, onArchiveAndDeleteWorkspace, + onPinAgent, + onUnpinAgent, + onReorderPinnedAgent, onBeforeNewAgent, isCreating, isArchiving = false, @@ -642,13 +748,118 @@ export const AgentsSidebar: FC = (props) => { visibleChatIDs.has(chatID), ); - // Pre-compute the first non-empty time group so the filter - // dropdown renders next to it without needing a mutable IIFE. - const firstNonEmptyGroup = TIME_GROUPS.find((group) => - visibleRootIDs.some((id) => { - const chat = chatById.get(id); - return chat !== undefined && getTimeGroup(chat.updated_at) === group; + const pinnedChats = visibleRootIDs + .map((id) => chatById.get(id)) + .filter((chat): chat is Chat => (chat?.pin_order ?? 0) > 0) + .sort((a, b) => a.pin_order - b.pin_order); + + // Local override for pinned order during drag — applied + // synchronously so there's no flash between the dnd-kit + // transform clearing and the server data arriving. + const [localPinOrder, setLocalPinOrder] = useState(null); + + // Clear the local override when fresh data arrives from + // the server (the mutation's onSettled invalidates queries). + const chatsRef = useRef(chats); + useEffect(() => { + if (chats !== chatsRef.current) { + chatsRef.current = chats; + setLocalPinOrder(null); + } + }, [chats]); + + const sortedPinnedChats = localPinOrder + ? localPinOrder + .map((id) => pinnedChats.find((c) => c.id === id)) + .filter((c) => c !== undefined) + : pinnedChats; + + const pinnedChatIds = sortedPinnedChats.map((chat) => chat.id); + + // Ref flag set after drag ends. Checked by SortableChatTreeNode's + // onClickCapture to block the synthetic click that the browser + // fires from the final pointerup. Cleared after 100ms, which + // comfortably exceeds dnd-kit's 50ms sensor cleanup window. + const recentDragRef = useRef(false); + + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { distance: 5 }, }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + recentDragRef.current = true; + setTimeout(() => { + recentDragRef.current = false; + }, 100); + + if (!over || active.id === over.id) return; + const activeId = String(active.id); + const overId = String(over.id); + const oldIndex = pinnedChatIds.indexOf(activeId); + const newIndex = pinnedChatIds.indexOf(overId); + if (oldIndex === -1 || newIndex === -1) return; + + const reordered = arrayMove(pinnedChatIds, oldIndex, newIndex); + setLocalPinOrder(reordered); + onReorderPinnedAgent?.(activeId, newIndex + 1); + }; + + // The filter dropdown attaches to the first visible section + // header. When pinned chats exist, that's the Pinned header; + // otherwise it falls through to the first non-empty time group. + const showFilterOnPinned = pinnedChats.length > 0; + const firstNonEmptyGroup = showFilterOnPinned + ? undefined + : TIME_GROUPS.find((group) => + visibleRootIDs.some((id) => { + const chat = chatById.get(id); + return ( + chat !== undefined && + getTimeGroup(chat.updated_at) === group && + chat.pin_order === 0 + ); + }), + ); + const filterDropdown = ( + + + + + + onArchivedFilterChange?.("active")}> + Active + {archivedFilter === "active" && ( + + )} + + onArchivedFilterChange?.("archived")}> + Archived + {archivedFilter === "archived" && ( + + )} + + + ); // Auto-expand ancestors of the active chat so it's always visible. @@ -703,6 +914,8 @@ export const AgentsSidebar: FC = (props) => { onArchiveAgent, onUnarchiveAgent, onArchiveAndDeleteWorkspace, + onPinAgent, + onUnpinAgent, }; const subNavTitle = "Settings"; @@ -824,13 +1037,44 @@ export const AgentsSidebar: FC = (props) => {
{visibleRootIDs.length > 0 && (
+ {/* ── Pinned section ── */} + {pinnedChats.length > 0 && ( +
+
+ Pinned + {showFilterOnPinned && filterDropdown} +
+ + +
+ {sortedPinnedChats.map((chat) => ( + + ))} +
+
+
+
+ )} + {/* ── Time-grouped sections ── */} {TIME_GROUPS.map((group) => { const groupChats = visibleRootIDs .map((id) => chatById.get(id)) .filter( (chat): chat is Chat => chat !== undefined && - getTimeGroup(chat.updated_at) === group, + getTimeGroup(chat.updated_at) === group && + chat.pin_order === 0, ); if (groupChats.length === 0) return null; return ( @@ -840,46 +1084,7 @@ export const AgentsSidebar: FC = (props) => { >
{group} - {group === firstNonEmptyGroup && ( - - - - - - - onArchivedFilterChange?.("active") - } - > - Active - {archivedFilter === "active" && ( - - )} - - - onArchivedFilterChange?.("archived") - } - > - Archived - {archivedFilter === "archived" && ( - - )} - - - - )} + {group === firstNonEmptyGroup && filterDropdown}
{groupChats.map((chat) => ( @@ -892,7 +1097,7 @@ export const AgentsSidebar: FC = (props) => {
); - })}{" "} + })}
)} @@ -910,7 +1115,6 @@ export const AgentsSidebar: FC = (props) => {
- {" "}
- {/* ── Panel 2: Sub-navigation (Settings) ── */}{" "} + {/* ── Panel 2: Sub-navigation (Settings) ── */}
= (props) => { to="/agents/settings/insights" state={location.state} adminOnly - />{" "} + /> )}