feat(web): D4 会话未读徽标 — 打开即已读 + 消费 session_read
- SessionMeta.unreadCount(可选,兼容旧后端)+ api/sessions.markSessionRead - lib/sessionRead.ts:会话历史落地后再推游标(best-effort,失败静默,下次 GET /sessions 收敛)+ 帧归属判定(ws.ts 因此留在 500 行内) - store.clearUnread 无变化时返回同一 state,避免多余重渲染;Nav 徽标沿用 Miuix token(mu-radius-pill/mu-err/mu-rise-in) - 逻辑测试 35 → 54 项,含变异校验(改成 clearUnread(activeSessionId) 即 FAIL)
This commit is contained in:
1 parent
e8eccd2bb2
commit
a828edacc5
9 files changed
+235
-11
No files matched your search
@@ -1,10 +1,13 @@
|
||||
// Logic test for the realtime-sync frames: the D3 edit/delete pair
|
||||
// (`message_edited` / `message_deleted`) and the streaming session guard
|
||||
// (`message_edited` / `message_deleted`), the streaming session guard
|
||||
// (design/16 §4.2 — a stream frame for another conversation must not land in the
|
||||
// bubble on screen). The project has no frontend test runner and no jsdom, so this
|
||||
// is a dependency-free plain-node check: it drives the real frame decoder
|
||||
// (`dispatchFrame` in lib/ws.ts) and the real store, and throws on the first
|
||||
// failure.
|
||||
// bubble on screen), and the session read cursor (Android 协同 B3/D4 — the
|
||||
// `session_read` frame clears exactly the conversation it names, and opening a
|
||||
// session pushes its forward-only cursor). The project has no frontend test runner
|
||||
// and no jsdom, so this is a dependency-free plain-node check: it drives the real
|
||||
// frame decoder (`dispatchFrame` in lib/ws.ts), the real read path
|
||||
// (`markSessionOpenedRead` in lib/sessionRead.ts, via lib/api) and the real store,
|
||||
// and throws on the first failure.
|
||||
//
|
||||
// Run it with the esbuild that already ships with vite (no new dependency, no
|
||||
// package.json change); the banner supplies the `window.localStorage` the store's
|
||||
@@ -21,8 +24,9 @@
|
||||
// imports nothing from node:*.
|
||||
|
||||
import { dispatchFrame } from "../lib/ws";
|
||||
import { markSessionOpenedRead } from "../lib/sessionRead";
|
||||
import { messageEventApplies, useAppStore } from "./useAppStore";
|
||||
import type { ChatMessage } from "../lib/types";
|
||||
import type { ChatMessage, SessionMeta } from "../lib/types";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
@@ -216,4 +220,112 @@ seed(streaming(), MAIN);
|
||||
dispatchFrame({ type: "stream_end", data: { stopReason: "end_turn" } });
|
||||
ok(messages()[1].streaming === false, "an unscoped stream_end keeps the legacy behavior");
|
||||
|
||||
// ⑥ Unread badges / read cursor (Android 协同 B3/D4). The session list carries a
|
||||
// per-session `unreadCount`; `session_read` is the frame another end emits after
|
||||
// it marked a conversation read, and it must clear exactly that conversation.
|
||||
function seedSessions(list: SessionMeta[]): void {
|
||||
useAppStore.setState({
|
||||
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
|
||||
activeSessionId: MAIN,
|
||||
});
|
||||
}
|
||||
|
||||
function unreadOf(id: string): number | undefined {
|
||||
return useAppStore.getState().sessions[id]?.unreadCount;
|
||||
}
|
||||
|
||||
function twoUnread(): SessionMeta[] {
|
||||
return [
|
||||
{ id: MAIN, type: "main", createdAt: 1, unreadCount: 3 },
|
||||
{ id: OTHER, type: "sub", createdAt: 2, unreadCount: 5 },
|
||||
];
|
||||
}
|
||||
|
||||
// A frame naming another session clears that session — and only that session: the
|
||||
// conversation on screen keeps the dot that belongs to it.
|
||||
seedSessions(twoUnread());
|
||||
dispatchFrame({ type: "session_read", sessionId: OTHER, data: { readAt: 100 } });
|
||||
same(unreadOf(OTHER), 0, "session_read for another session clears that session's dot");
|
||||
same(unreadOf(MAIN), 3, "session_read for another session leaves the active dot alone");
|
||||
|
||||
// A frame naming the active session clears it.
|
||||
seedSessions(twoUnread());
|
||||
dispatchFrame({ type: "session_read", sessionId: MAIN, data: { readAt: 200 } });
|
||||
same(unreadOf(MAIN), 0, "session_read for the active session clears its dot");
|
||||
same(unreadOf(OTHER), 5, "session_read for the active session leaves the other dot alone");
|
||||
|
||||
// A payload-carried sessionId wins (the envelope may be bare) …
|
||||
seedSessions(twoUnread());
|
||||
dispatchFrame({ type: "session_read", data: { sessionId: MAIN, readAt: 300 } });
|
||||
same(unreadOf(MAIN), 0, "a payload sessionId is honored");
|
||||
same(unreadOf(OTHER), 5, "a payload sessionId still scopes to one session");
|
||||
|
||||
// … an unattributable frame (no session id anywhere) touches nothing rather than
|
||||
// guessing whose badge to clear …
|
||||
seedSessions(twoUnread());
|
||||
dispatchFrame({ type: "session_read", data: { readAt: 400 } });
|
||||
same(unreadOf(MAIN), 3, "an unattributable session_read clears no active dot");
|
||||
same(unreadOf(OTHER), 5, "an unattributable session_read clears no other dot");
|
||||
|
||||
// … and an unknown session id is a no-op: no row is invented, nothing is cleared.
|
||||
seedSessions(twoUnread());
|
||||
dispatchFrame({ type: "session_read", sessionId: "web:ghost", data: { readAt: 500 } });
|
||||
ok(
|
||||
!("web:ghost" in useAppStore.getState().sessions),
|
||||
"a session_read for an unknown session invents no row"
|
||||
);
|
||||
same(unreadOf(MAIN), 3, "a session_read for an unknown session clears nothing");
|
||||
|
||||
// A session the backend did not annotate (older backend, no `unreadCount`) has
|
||||
// nothing to clear — the frame leaves the entry exactly as it was rather than
|
||||
// writing a 0 the backend never reported. The badge reads absent as 0.
|
||||
seedSessions([{ id: MAIN, type: "main", createdAt: 1 }]);
|
||||
dispatchFrame({ type: "session_read", sessionId: MAIN, data: { readAt: 600 } });
|
||||
ok(unreadOf(MAIN) === undefined, "a session without unreadCount is left unannotated");
|
||||
|
||||
// ⑦ The open-a-session trigger. `markSessionOpenedRead` (fired once the history
|
||||
// page has landed) zeroes the badge locally and pushes the forward-only cursor;
|
||||
// a session already reading as read must not produce a request on every switch.
|
||||
// The REST layer is exercised for real — only `fetch` is stubbed.
|
||||
const calls: { url: string; method?: string; body?: unknown }[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalLocalStorage = globalThis.localStorage;
|
||||
// The bundle's banner supplies `window.localStorage` (the store's persist
|
||||
// middleware probes it); the REST layer reads the bare `localStorage` global,
|
||||
// which node does not expose without --experimental-webstorage.
|
||||
globalThis.localStorage = window.localStorage;
|
||||
globalThis.fetch = ((url: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), method: init?.method, body: init?.body });
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: { sessionId: MAIN, readAt: 1 } }),
|
||||
} as unknown as Response);
|
||||
}) as typeof fetch;
|
||||
|
||||
seedSessions([{ id: MAIN, type: "main", createdAt: 1, unreadCount: 4 }]);
|
||||
markSessionOpenedRead(MAIN);
|
||||
same(unreadOf(MAIN), 0, "opening an unread session clears its badge immediately");
|
||||
same(calls.length, 1, "opening an unread session sends exactly one read call");
|
||||
same(calls[0].url, "/api/v1/sessions/web/read", "the read call targets the read endpoint");
|
||||
same(calls[0].method, "POST", "the read call is a POST");
|
||||
same(calls[0].body, "{}", "an omitted `before` sends an empty body, meaning now");
|
||||
|
||||
// Re-opening a session that already reads as read sends nothing.
|
||||
markSessionOpenedRead(MAIN);
|
||||
same(calls.length, 1, "an already-read session sends no further read call");
|
||||
// Same for an unknown session id and for an empty one.
|
||||
markSessionOpenedRead("web:ghost");
|
||||
markSessionOpenedRead("");
|
||||
same(calls.length, 1, "an unknown or empty session sends no read call");
|
||||
|
||||
// A stale open (the session was switched away and its metadata is gone) is a
|
||||
// no-op too — the await'd page can resolve after the list was replaced.
|
||||
seedSessions([]);
|
||||
markSessionOpenedRead(MAIN);
|
||||
same(calls.length, 1, "a missing session metadata entry sends no read call");
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.localStorage = originalLocalStorage;
|
||||
|
||||
console.log(`message sync logic test: ${checks} checks passed`);
|
||||
@@ -63,6 +63,11 @@ export interface AppStore {
|
||||
|
||||
/** Replace the session metadata list. */
|
||||
setSessions: (list: SessionMeta[]) => void;
|
||||
/** Zero one session's unread badge locally (Android 协同 B3/D4). Called when the
|
||||
* user opens the session and its page has loaded, and on the `session_read`
|
||||
* frame another end broadcasts — a no-op for an already-read or unknown
|
||||
* session, so neither path can invent a row or re-render the list needlessly. */
|
||||
clearUnread: (sessionId: string) => void;
|
||||
/** Focus a session and clear its message cache (refilled by the caller). */
|
||||
setActiveSession: (id: string) => void;
|
||||
/** Replace the active session's message list (initial page on switch). */
|
||||
@@ -144,6 +149,14 @@ export const useAppStore = create<AppStore>()(
|
||||
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
|
||||
}),
|
||||
setActiveSession: (id) => set({ activeSessionId: id, messages: [] }),
|
||||
clearUnread: (sessionId) =>
|
||||
set((s) => {
|
||||
const meta = s.sessions[sessionId];
|
||||
if (!meta?.unreadCount) return s;
|
||||
return {
|
||||
sessions: { ...s.sessions, [sessionId]: { ...meta, unreadCount: 0 } },
|
||||
};
|
||||
}),
|
||||
setMessages: (list) => set({ messages: list }),
|
||||
prependMessages: (list) =>
|
||||
set((s) => {
|
||||
|
||||
Reference in new issue
Block a user