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
+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() {