mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+2
-1
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user