ae3ab5feb5
- os/ 67 文件(DesktopOS/Dock/Launcher/MenuBar/MissionControl/NotificationCenter/AppStore/Window 系统 + os*Store/hooks) - pages/Hub + api/modules/hub.ts + ImportHubModal(已有) - Inbox 采用上游 Mailbox(approvals/messages 双 Tab + MailAccessControlDrawer/useMailPendingCount/mailDetail),保留云超服 Harvest 组件 - App.tsx 引入 OS Shell 分支(isOsPath→DesktopOSPage, Router 外)+ /hub/admin 路由 + BackendModeRouter + RuntimeAvailabilityGuard,保留云超服 AuthGuard/chatSocket/OPC 主题/vi-th-my-lo - 双改合并:usePluginLoader(loadPawApp)/pluginMarket(isMarketPluginApp)/registry store(removeBySource)/hostExternals(removePlugin)/LanguageSwitcher(persistRemotely+ja-ru-pt-id)/BackendLoadingPage(override props)/ApprovalCard(reasoning)/auth.ts(mode)/auth/gate.ts - SidebarSettingsPanel 加 Desktop Mode 入口(OS Shell 切换) - locales 合并 os/hub/projectDirectory/inbox/account(zh/en/vi 用上游,lo/my/th 用 en 占位) - 验证:tsc 0 错误、build 通过(1m23s)、阶段5 新增 35 文件 271 测试全过;全量回归 30 失败均预存在(stash 验证)
151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
/**
|
|
* osNotifyStore.ts — Notification state for the Desktop OS PoC.
|
|
*
|
|
* Aggregates two live sources into macOS-style notifications:
|
|
* - pending approvals (api.getPushMessages().pending_approvals)
|
|
* - unread inbox events (api.getInboxEvents({ unread_only: true }))
|
|
*
|
|
* `ingest` diffs each poll against known ids so only genuinely new items
|
|
* raise a banner (toast). The first poll only seeds known ids to avoid a
|
|
* burst of banners on mount. Badge counts always reflect the current
|
|
* pending/unread totals.
|
|
*/
|
|
import { create } from "zustand";
|
|
|
|
export type NotifyKind = "approval" | "inbox";
|
|
|
|
export interface OsNotifyItem {
|
|
/** Stable, namespaced id (e.g. "ap:<request_id>" / "ib:<event_id>"). */
|
|
id: string;
|
|
kind: NotifyKind;
|
|
title: string;
|
|
body: string;
|
|
/** Epoch milliseconds. */
|
|
createdAt: number;
|
|
read: boolean;
|
|
/** Approval action targets (approval kind only) so the notification can
|
|
* approve/deny directly via commandsApi.sendApprovalCommand. */
|
|
requestId?: string;
|
|
rootSessionId?: string;
|
|
}
|
|
|
|
const HISTORY_CAP = 50;
|
|
const TOAST_CAP = 4;
|
|
const KNOWN_INBOX_CAP = 500;
|
|
|
|
function boundedKnownIds(
|
|
known: ReadonlySet<string>,
|
|
incoming: readonly OsNotifyItem[],
|
|
): Set<string> {
|
|
const activeApprovals = incoming
|
|
.filter((item) => item.kind === "approval")
|
|
.map((item) => item.id);
|
|
const inboxIds = [
|
|
...incoming.filter((item) => item.kind === "inbox").map((item) => item.id),
|
|
...[...known].filter((id) => id.startsWith("ib:")),
|
|
].slice(0, KNOWN_INBOX_CAP);
|
|
return new Set([...activeApprovals, ...inboxIds]);
|
|
}
|
|
|
|
interface OsNotifyState {
|
|
history: OsNotifyItem[];
|
|
toasts: OsNotifyItem[];
|
|
approvalCount: number;
|
|
inboxCount: number;
|
|
centerOpen: boolean;
|
|
seeded: boolean;
|
|
knownIds: Set<string>;
|
|
|
|
ingest: (
|
|
approvals: OsNotifyItem[],
|
|
inbox: OsNotifyItem[],
|
|
inboxCount?: number,
|
|
) => void;
|
|
dismissToast: (id: string) => void;
|
|
dismissItem: (id: string) => void;
|
|
setCenter: (open: boolean) => void;
|
|
markAllRead: () => void;
|
|
clearHistory: () => void;
|
|
}
|
|
|
|
export const useOsNotify = create<OsNotifyState>((set, get) => ({
|
|
history: [],
|
|
toasts: [],
|
|
approvalCount: 0,
|
|
inboxCount: 0,
|
|
centerOpen: false,
|
|
seeded: false,
|
|
knownIds: new Set<string>(),
|
|
|
|
ingest: (approvals, inbox, exactInboxCount) => {
|
|
const state = get();
|
|
const incoming = [...approvals, ...inbox];
|
|
const approvalCount = approvals.length;
|
|
const inboxCount = exactInboxCount ?? inbox.length;
|
|
const approvalIds = new Set(approvals.map((i) => i.id));
|
|
|
|
// Drop approval items no longer pending (resolved from the Inbox, a
|
|
// notification action, or a timeout). Inbox items keep their own
|
|
// read/unread lifecycle and are left untouched.
|
|
const prune = (list: OsNotifyItem[]) =>
|
|
list.filter((i) => i.kind !== "approval" || approvalIds.has(i.id));
|
|
|
|
// First poll: seed known ids without raising banners.
|
|
if (!state.seeded) {
|
|
set({
|
|
seeded: true,
|
|
knownIds: boundedKnownIds(new Set(), incoming),
|
|
approvalCount,
|
|
inboxCount,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const known = state.knownIds;
|
|
const fresh = incoming.filter((i) => !known.has(i.id));
|
|
fresh.sort((a, b) => b.createdAt - a.createdAt);
|
|
|
|
const nextKnown = new Set(known);
|
|
for (const item of fresh) nextKnown.add(item.id);
|
|
// Forget resolved approvals so a later re-request can toast again.
|
|
for (const id of known) {
|
|
if (id.startsWith("ap:") && !approvalIds.has(id)) nextKnown.delete(id);
|
|
}
|
|
const boundedKnown = boundedKnownIds(nextKnown, incoming);
|
|
|
|
set({
|
|
approvalCount,
|
|
inboxCount,
|
|
knownIds: boundedKnown,
|
|
history: prune([...fresh, ...state.history]).slice(0, HISTORY_CAP),
|
|
toasts: prune([...fresh, ...state.toasts]).slice(0, TOAST_CAP),
|
|
});
|
|
},
|
|
|
|
dismissToast: (id) =>
|
|
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
|
|
|
dismissItem: (id) =>
|
|
set((s) => ({
|
|
toasts: s.toasts.filter((t) => t.id !== id),
|
|
history: s.history.filter((h) => h.id !== id),
|
|
})),
|
|
|
|
setCenter: (open) =>
|
|
set((s) => ({
|
|
centerOpen: open,
|
|
// Opening the center marks all history entries as seen.
|
|
history: open ? s.history.map((h) => ({ ...h, read: true })) : s.history,
|
|
})),
|
|
|
|
markAllRead: () =>
|
|
set((s) => ({ history: s.history.map((h) => ({ ...h, read: true })) })),
|
|
|
|
clearHistory: () => set({ history: [], toasts: [] }),
|
|
}));
|
|
|
|
/** Count of history items not yet seen in the notification center. */
|
|
export function unreadNotifyCount(items: OsNotifyItem[]): number {
|
|
return items.filter((i) => !i.read).length;
|
|
}
|