mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00: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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user