Files
multica/packages/core/chat/queries.test.ts
Jiayuan Zhang db84ff57c8 feat(chat): pin a chat to the top of the Chat list (MUL-4240)
Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can
keep important chats at the top of the IM-style thread list, above the
activity-sorted rest.

Backend:
- migration 148: chat_session.pinned_at (nullable) + partial index; the
  timestamp doubles as the pinned-group sort key and the boolean flag.
- list queries order pinned-first, then by most-recent activity.
- SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler;
  pinning never bumps updated_at, so an unpinned chat won't jump the list.
- ChatSessionResponse.pinned + chat:session_updated carries the new state.

Frontend:
- ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned
  with optimistic re-sort; shared sortChatSessions comparator.
- thread list: pin indicator on pinned rows + pin/unpin hover action;
  list sorted pinned-first so it stays ordered after cache patches.
- realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings.

Tests: SetChatSessionPinned handler test, sortChatSessions unit tests.
2026-07-08 16:09:56 +08:00

106 lines
3.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { TaskMessagePayload } from "../types/events";
import type { ChatSession } from "../types/chat";
import {
isTaskMessageTaskId,
mergeTaskMessagesBySeq,
sortChatSessions,
taskMessagesOptions,
} from "./queries";
const msg = (seq: number): TaskMessagePayload => ({
task_id: "task-1",
issue_id: "issue-1",
seq,
type: "text",
content: `m${seq}`,
});
describe("taskMessagesOptions", () => {
it("fetches task messages for persisted UUID task ids", () => {
const taskId = "4a2e8d1c-7f9b-4e2a-9c1d-123456789abc";
expect(isTaskMessageTaskId(taskId)).toBe(true);
expect(taskMessagesOptions(taskId).enabled).toBe(true);
});
it("does not fetch task messages for optimistic task ids", () => {
const taskId = "optimistic-optimistic-1778739487737";
expect(isTaskMessageTaskId(taskId)).toBe(false);
expect(taskMessagesOptions(taskId).enabled).toBe(false);
});
});
describe("mergeTaskMessagesBySeq", () => {
it("backfills missing seqs and keeps the list seq-ordered", () => {
const existing = [msg(1), msg(3)];
const merged = mergeTaskMessagesBySeq(existing, [msg(2), msg(4)]);
expect(merged.map((m) => m.seq)).toEqual([1, 2, 3, 4]);
});
it("drops duplicate seqs and lets the existing entry win", () => {
const existing = [{ ...msg(1), content: "ws" }];
const merged = mergeTaskMessagesBySeq(existing, [
{ ...msg(1), content: "refetch" },
msg(2),
]);
expect(merged.map((m) => m.seq)).toEqual([1, 2]);
expect(merged.find((m) => m.seq === 1)?.content).toBe("ws");
});
it("preserves the array reference when nothing new arrives", () => {
const existing = [msg(1), msg(2)];
// Empty incoming and fully-duplicate incoming must both no-op so React
// Query observers don't re-render on replayed events.
expect(mergeTaskMessagesBySeq(existing, [])).toBe(existing);
expect(mergeTaskMessagesBySeq(existing, [msg(1), msg(2)])).toBe(existing);
});
});
describe("sortChatSessions", () => {
const session = (over: Partial<ChatSession>): ChatSession => ({
id: "s",
workspace_id: "w",
agent_id: "a",
creator_id: "c",
title: "t",
status: "active",
has_unread: false,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...over,
});
it("puts pinned sessions before unpinned ones regardless of activity", () => {
const pinnedOld = session({ id: "pinned-old", pinned: true, updated_at: "2026-01-01T00:00:00Z" });
const unpinnedNew = session({ id: "unpinned-new", pinned: false, updated_at: "2026-06-01T00:00:00Z" });
const sorted = sortChatSessions([unpinnedNew, pinnedOld]);
expect(sorted.map((s) => s.id)).toEqual(["pinned-old", "unpinned-new"]);
});
it("orders within each group by most-recent activity (last message wins over updated_at)", () => {
const a = session({
id: "a",
updated_at: "2026-01-01T00:00:00Z",
last_message: { content: "x", role: "assistant", created_at: "2026-06-02T00:00:00Z" },
});
const b = session({ id: "b", updated_at: "2026-06-01T00:00:00Z" });
const sorted = sortChatSessions([b, a]);
expect(sorted.map((s) => s.id)).toEqual(["a", "b"]);
});
it("does not mutate the input array", () => {
const input = [session({ id: "1" }), session({ id: "2", pinned: true })];
const snapshot = input.map((s) => s.id);
sortChatSessions(input);
expect(input.map((s) => s.id)).toEqual(snapshot);
});
});