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
@@ -39,6 +39,7 @@ export {
|
||||
deleteSession,
|
||||
fetchMessages,
|
||||
fetchSessions,
|
||||
markSessionRead,
|
||||
} from "./api/sessions";
|
||||
export type { MessageDto } from "./api/sessions";
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown> | 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.
|
||||
});
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user