mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 07:34:25 +02:00
* feat(skills): search runtime local skills * feat(skills): highlight matched substrings in runtime local skill search Reuse the shared HighlightText (the same component the global search command uses) to highlight matched substrings in a result's name, provider, description, and path, so styling stays consistent across the app. Narrow the search to the fields the row actually renders and drop `key`, so every match maps to something visible. While a query is active, lift the description's 2-line clamp so a match past the first two lines stays on screen instead of being clipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): zero unread for archived chat sessions across all badges (MUL-4360) Archiving a chat session flips status but deliberately does not advance last_read_at, and ListAllChatSessionsByCreator counted unread unconditionally. So an archived session that had unread replies kept reporting has_unread=true / unread_count>0 — a stuck badge the user can never clear (archived sessions are read-only and hidden from history, so there is no mark-as-read entry). MUL-4372 fixed only the quick-chat FAB surface; the sidebar Chat tab badge and the chat-window "other unread" header still counted it. Fix at the source: derive unread_count = 0 for status='archived' rows in ListAllChatSessionsByCreator. Because has_unread is server-derived as unread_count > 0, and all surfaces (FAB, sidebar via countUnreadChatMessages, chat-window header, and mobile) read this one payload, every badge drops archived sessions with no per-surface filter. last_read_at is left untouched so unarchiving restores the true unread state. Installed desktop clients benefit without an app update. Also zero unread optimistically in the archive mutation so no badge counts a just-archived session in the frame before the refetch lands (FAB already filtered archived; this keeps sidebar/header consistent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): stop auto-mark-read from clearing a transiently-active session on mount (MUL-4360) The chat page persists `activeSessionId`, so on a bare `/chat` navigation it restores the last-open session as active for one frame before its URL→store effect (which runs AFTER useChatController's effects, since the hook is called first) clears it back to null. The auto-mark-read effect fired in that gap and marked the restored-but-never-opened session read — its unread badge vanished though the right pane still showed "select a chat" and the user never opened it. This is why the sidebar count dropped (e.g. 2 → 1) just by entering the tab. Defer the read by a tick and cancel it on cleanup: a session that is only momentarily active (restored on mount, then cleared) has its pending read cancelled when activeSessionId changes; only a session that stays active past the tick — a real select, deep link, or refresh — is marked read. A live-store re-check in the timer is a belt-and-suspenders guard. Adds the previously-missing auto-mark-read coverage: a stable-active session is read after the tick; a momentarily-active one is not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: coderbaozi <YHbaozi1988@163.com> Co-authored-by: abun <103836393+coderbaozi@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
128 lines
4.2 KiB
TypeScript
128 lines
4.2 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { act, renderHook } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ReactNode } from "react";
|
|
|
|
import { setApiInstance } from "../api";
|
|
import type { ApiClient } from "../api/client";
|
|
import { useSetChatSessionArchived } from "./mutations";
|
|
import { chatKeys } from "./queries";
|
|
import type { ChatSession } from "../types";
|
|
|
|
vi.mock("../hooks", () => ({
|
|
useWorkspaceId: () => "ws-1",
|
|
}));
|
|
|
|
const WS_ID = "ws-1";
|
|
|
|
function makeSession(overrides: Partial<ChatSession> = {}): ChatSession {
|
|
return {
|
|
id: "s1",
|
|
workspace_id: WS_ID,
|
|
agent_id: "agent-1",
|
|
creator_id: "user-1",
|
|
title: "Session 1",
|
|
status: "active",
|
|
has_unread: true,
|
|
unread_count: 2,
|
|
created_at: "2026-07-10T00:00:00Z",
|
|
updated_at: "2026-07-10T00:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createWrapper(qc: QueryClient) {
|
|
return function Wrapper({ children }: { children: ReactNode }) {
|
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
|
};
|
|
}
|
|
|
|
describe("useSetChatSessionArchived", () => {
|
|
let qc: QueryClient;
|
|
let setChatSessionArchived: ReturnType<
|
|
typeof vi.fn<(id: string, archived: boolean) => Promise<ChatSession>>
|
|
>;
|
|
|
|
beforeEach(() => {
|
|
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
setChatSessionArchived = vi.fn();
|
|
setApiInstance({ setChatSessionArchived } as unknown as ApiClient);
|
|
});
|
|
|
|
afterEach(() => {
|
|
qc.clear();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// MUL-4360: archiving must zero the row's unread locally so no badge (FAB,
|
|
// sidebar Chat tab, chat-window header) keeps counting a just-archived
|
|
// session in the frame before the refetch lands. Mirrors the backend, which
|
|
// forces unread to 0 for archived rows in ListAllChatSessionsByCreator.
|
|
it("optimistically zeroes unread when archiving", async () => {
|
|
setChatSessionArchived.mockResolvedValue(
|
|
makeSession({ status: "archived" }),
|
|
);
|
|
qc.setQueryData<ChatSession[]>(chatKeys.sessions(WS_ID), [makeSession()]);
|
|
|
|
const { result } = renderHook(() => useSetChatSessionArchived(), {
|
|
wrapper: createWrapper(qc),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({ sessionId: "s1", archived: true });
|
|
});
|
|
|
|
const row = qc.getQueryData<ChatSession[]>(chatKeys.sessions(WS_ID))![0]!;
|
|
expect(row.status).toBe("archived");
|
|
expect(row.unread_count).toBe(0);
|
|
expect(row.has_unread).toBe(false);
|
|
});
|
|
|
|
// Unarchive must NOT fabricate an unread count — the true state comes back
|
|
// from the server refetch (last_read_at is untouched), so the optimistic
|
|
// patch leaves the row's unread fields as-is.
|
|
it("does not resurrect unread when unarchiving", async () => {
|
|
setChatSessionArchived.mockResolvedValue(makeSession({ status: "active" }));
|
|
qc.setQueryData<ChatSession[]>(chatKeys.sessions(WS_ID), [
|
|
makeSession({ status: "archived", has_unread: false, unread_count: 0 }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useSetChatSessionArchived(), {
|
|
wrapper: createWrapper(qc),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({ sessionId: "s1", archived: false });
|
|
});
|
|
|
|
const row = qc.getQueryData<ChatSession[]>(chatKeys.sessions(WS_ID))![0]!;
|
|
expect(row.status).toBe("active");
|
|
expect(row.unread_count).toBe(0);
|
|
expect(row.has_unread).toBe(false);
|
|
});
|
|
|
|
// On failure the optimistic patch (status + zeroed unread) rolls back whole.
|
|
it("rolls back the unread patch when the request fails", async () => {
|
|
setChatSessionArchived.mockRejectedValue(new Error("boom"));
|
|
qc.setQueryData<ChatSession[]>(chatKeys.sessions(WS_ID), [makeSession()]);
|
|
|
|
const { result } = renderHook(() => useSetChatSessionArchived(), {
|
|
wrapper: createWrapper(qc),
|
|
});
|
|
|
|
await act(async () => {
|
|
await expect(
|
|
result.current.mutateAsync({ sessionId: "s1", archived: true }),
|
|
).rejects.toThrow("boom");
|
|
});
|
|
|
|
const row = qc.getQueryData<ChatSession[]>(chatKeys.sessions(WS_ID))![0]!;
|
|
expect(row.status).toBe("active");
|
|
expect(row.unread_count).toBe(2);
|
|
expect(row.has_unread).toBe(true);
|
|
});
|
|
});
|