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*
This commit is contained in:
Matt Vollmer
2026-03-26 16:52:02 -04:00
committed by GitHub
parent f3a8096ff6
commit 113aaa79a0
34 changed files with 1786 additions and 107 deletions
+1
View File
@@ -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,
+1
View File
@@ -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"},
}
+33
View File
@@ -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
+22
View File
@@ -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{
+24
View File
@@ -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)
+42
View File
@@ -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()
+2 -1
View File
@@ -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 (
@@ -0,0 +1 @@
ALTER TABLE chats DROP COLUMN pin_order;
@@ -0,0 +1 @@
ALTER TABLE chats ADD COLUMN pin_order integer DEFAULT 0 NOT NULL;
+1
View File
@@ -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
}
+1
View File
@@ -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 {
+8
View File
@@ -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)
+179
View File
@@ -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() {
+218 -14
View File
@@ -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
}
+169 -1
View File
@@ -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