diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index f2d07c2..64d59c6 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -6,6 +6,7 @@ import { AdminApp } from "./admin/AdminApp"; import { Login } from "./components/Login"; import { parseAdminHash } from "./admin/router"; import { connectWs, disconnectWs } from "./lib/ws"; +import { markSessionOpenedRead } from "./lib/sessionRead"; import { initTheme } from "./lib/theme"; import { fetchMessages, @@ -100,13 +101,21 @@ export default function App(): React.ReactNode { }, [token, setSessions, setActiveSession]); // Whenever the focused session changes (switch, new, or first load) and it is - // a session we know about, pull its initial page of history into the panel. + // a session we know about, pull its initial page of history into the panel, then + // advance that session's read cursor (协同 B3/D4) — *after* the page lands, so the + // cursor never passes messages the user was never shown. The effect hinges on + // *whether* the session is known rather than on the list's identity: clearing the + // unread badge rewrites `sessions`, and keying on it would re-fetch history (and + // so double every session switch) for a metadata-only change. + const activeSessionKnown = Boolean(sessions[activeSessionId]); useEffect(() => { - if (!activeSessionId || !sessions[activeSessionId]) return; + if (!activeSessionId || !activeSessionKnown) return; let cancelled = false; fetchMessages(activeSessionId, undefined, 50) .then((list) => { - if (!cancelled) setMessages(list); + if (cancelled) return; + setMessages(list); + markSessionOpenedRead(activeSessionId); }) .catch(() => { if (!cancelled) setMessages([]); @@ -114,7 +123,7 @@ export default function App(): React.ReactNode { return () => { cancelled = true; }; - }, [activeSessionId, sessions, setMessages]); + }, [activeSessionId, activeSessionKnown, setMessages]); // The M9 modal UI and the admin subsystem (design/10) are two separate shells. // `/admin` is reached via `#/admin`; every admin module is a hash segment. diff --git a/frontend/web/src/components/Nav.tsx b/frontend/web/src/components/Nav.tsx index 32def05..2a9db43 100644 --- a/frontend/web/src/components/Nav.tsx +++ b/frontend/web/src/components/Nav.tsx @@ -102,6 +102,7 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode active={s.id === activeSessionId} icon={s.type === "main" ? "chat" : "folder"} label={s.title ?? (s.type === "main" ? "主会话" : "次级会话")} + badge={s.unreadCount ?? 0} onClick={() => selectSession(s.id)} /> ))} @@ -190,12 +191,15 @@ function NavItem({ onClick, active = false, hint, + badge = 0, }: { icon: IconName; label: string; onClick: () => void; active?: boolean; hint?: string; + /** Unread count (协同 B3/D4); 0 or absent renders no badge. */ + badge?: number; }): React.ReactNode { return ( ); } diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 59fd55d..08958ee 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -39,6 +39,7 @@ export { deleteSession, fetchMessages, fetchSessions, + markSessionRead, } from "./api/sessions"; export type { MessageDto } from "./api/sessions"; diff --git a/frontend/web/src/lib/api/sessions.ts b/frontend/web/src/lib/api/sessions.ts index a2b0220..aca8870 100644 --- a/frontend/web/src/lib/api/sessions.ts +++ b/frontend/web/src/lib/api/sessions.ts @@ -84,3 +84,20 @@ export function deleteSession( { method: "DELETE" } ); } + +/** POST /api/v1/sessions/{id}/read — advance this account's read cursor for the + * session to `before` (ms since epoch; omitted = "now"). One cursor per session, + * forward only: a stale client can never re-open messages as unread. Unknown + * session → 404 (ApiError). */ +export function markSessionRead( + sessionId: string, + before?: number +): Promise<{ sessionId: string; readAt: number }> { + return request<{ sessionId: string; readAt: number }>( + `/sessions/${encodeURIComponent(sessionId)}/read`, + { + method: "POST", + body: JSON.stringify(before === undefined ? {} : { before }), + } + ); +} diff --git a/frontend/web/src/lib/sessionRead.ts b/frontend/web/src/lib/sessionRead.ts new file mode 100644 index 0000000..e1bc814 --- /dev/null +++ b/frontend/web/src/lib/sessionRead.ts @@ -0,0 +1,45 @@ +import { useAppStore } from "../store/useAppStore"; +import { markSessionRead } from "./api"; + +/** + * Session read cursor (Android 协同 B3/D4) — the consumer side of the per-session + * unread count. Two write paths, both landing on the same single cursor: + * + * - opening a session (this end) → POST /sessions/{id}/read + * - the `session_read` frame (other ends) → clears the badge locally only + * + * The store's `clearUnread` zeroes the badge; the network cannot fail the badge, + * because `GET /sessions` is the eventual truth (a failed POST simply means the + * dot comes back on the next list load). + */ + +/** Which session does a `session_read` frame address? The payload's own + * `sessionId` wins when present (a frame may scope itself in the body, like the + * streaming frames), else the envelope's. "" means unattributable: the caller + * must then leave every badge alone rather than guess, since clearing the wrong + * session's badge would hide messages the user has not seen. */ +export function sessionReadTarget( + envelopeSessionId: string | undefined, + r: Record | null +): string { + const payload = typeof r?.sessionId === "string" ? r.sessionId : ""; + return payload || envelopeSessionId || ""; +} + +/** Mark a session read because the user just opened it (called once its first + * page of history has landed, so the cursor never passes messages that were + * never rendered). Zeroes the badge immediately — the list must not show a stale + * dot for the conversation on screen — and pushes the cursor so every other end + * converges. A session this end already reads as read is skipped entirely: the + * WS frame (or this very call) may have cleared it first, and switching back and + * forth must not fire a request per render. */ +export function markSessionOpenedRead(sessionId: string): void { + if (!sessionId) return; + const { sessions, clearUnread } = useAppStore.getState(); + if (!(sessions[sessionId]?.unreadCount ?? 0)) return; + clearUnread(sessionId); + void markSessionRead(sessionId).catch(() => { + // Best effort: the server cursor stays where it was until the next attempt, + // and the next GET /sessions reports it as truth. + }); +} diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index 9c77abb..5d387eb 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -109,6 +109,10 @@ export interface SessionMeta { type: SessionType; title?: string; createdAt: number; + /** Messages newer than this account's read cursor for the session (Android + * 协同 B3/D4). Optional so an older backend stays compatible — absent means + * "unknown", which the UI and the read path both treat as 0. */ + unreadCount?: number; } export type AgentStatus = diff --git a/frontend/web/src/lib/ws.ts b/frontend/web/src/lib/ws.ts index 1b9e271..ec702af 100644 --- a/frontend/web/src/lib/ws.ts +++ b/frontend/web/src/lib/ws.ts @@ -14,6 +14,7 @@ import { toRecord, } from "./wsParse"; import { MODES, type AwakenState, type ChatMessage, type Mode, type WsUpEvent } from "./types"; +import { sessionReadTarget } from "./sessionRead"; import { streamFrameApplies } from "./streamScope"; /** @@ -321,6 +322,15 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn s.setSessionBusy(busy, pendingCount); break; } + case "session_read": { + // 协同 B3/D4 — one end marked the conversation read, so the unread badge goes + // out on every end. Only the addressed session is touched (a frame naming + // another conversation must not clear the dot of the one on screen), and an + // unattributable frame is dropped rather than guessed. + const id = sessionReadTarget(ev.sessionId, r); + if (id) s.clearUnread(id); + break; + } default: { // Unknown future event: keep it in the context log so no frame is lost. const text = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data); diff --git a/frontend/web/src/store/messageSync.test.ts b/frontend/web/src/store/messageSync.test.ts index 512410e..50a3128 100644 --- a/frontend/web/src/store/messageSync.test.ts +++ b/frontend/web/src/store/messageSync.test.ts @@ -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`); diff --git a/frontend/web/src/store/useAppStore.ts b/frontend/web/src/store/useAppStore.ts index 88ef240..563831e 100644 --- a/frontend/web/src/store/useAppStore.ts +++ b/frontend/web/src/store/useAppStore.ts @@ -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()( 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) => {