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) => {