mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
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.
106 lines
3.4 KiB
TypeScript
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);
|
|
});
|
|
});
|