feat(notifications): dual-stream Notification Center and Status Hint banner - #305
Conversation
…ual-stream inbox Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Notification Center goes from dev-mock shell to a real inbox with exactly one source of truth per message (ADR-0067): - Go read proxy `/api/notification/v1alpha1`: lists the namespace's upstream Notification CRs with the caller's kubeconfig bearer and merge-patches the desktop-compatible `isRead` label; route-level and fake-cluster service tests. - Brain store `sealai_notification`: `notification_messages` with a partial-unique live `dedupe_key` (release on recovery keeps history) and a 365-day sweep on write; `notification_read_receipts` per user x workspace x source-prefixed id, cascading from swept rows and re-keyed on account merge (ADR-0059). - First producer: quota-exhausted, edge-triggered per resource at 100%, observed at the chat turn and the sidebar's quota warm-up. - Merged feed: `cr:<name>:<timestamp>` / `db:<id>` ids, real timestamps, unread = label unread AND no receipt; mark-read always writes a receipt and best-effort patches the CR unless the role is Developer. - Model moves from display-string times to epoch timestamps; the dev mock becomes a feed override. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Receipts are keyed by user x message key (no workspace): upstream writes account-level CRs into every namespace, and a person reads a message once; migration 0015 regenerated (unreleased) and the merge re-key narrowed to match. - Route-level Go tests drive list and mark-read through a fake cluster, including the 403 (Developer RBAC) and 404 mappings, via package-level service seams; plus a status-mapping table test. - Production store instance moves to `server-store.ts`; unused `isLive` removed; `WorkspaceSubscriptionRole` no longer splits a JSDoc from its symbol; "message center" wording replaced per CONTEXT.md. - ADR-0067 records the client-observed quota trade-off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…res (AIM-334) The Notification Center speaks the product's language and covers the rest of the catalog: - Override table: the 8 fixed-name debt-ladder CRs (`debt-choice-*`, `workspace-debt-*`) render Brain-voiced title, body, and CTA (Top up balance / Renew plan → Billing Plan); unknown names keep upstream's text; display-only, the CR is never touched. Rows gain an optional CTA link that records the billing return route before navigating. - Gift-only filter at the merge seam: a never-topped-up account holding nothing but gift credit does not see the low/critical balance tiers; the debt ladder is never filtered; unknown account state never hides a warning. Top-up history comes from the account's payment list (`loadHasToppedUp`), credits from the existing `credits/info` proxy. - Two producers: the `$1 welcome gift` hint (`credit-hint:<user>`, once per user, observed from the inbox's own credits read) and subscription-change receipts (`subscription-change:<workspace>:<txid>`, one per settled upgrade/downgrade/cancellation, observed at the Plan view's settlement and cancel points; the last transaction names the change and keys it). Routes `gift-observation` and `subscription-change`; A1 copy aligned to the spec table with a View usage CTA. - Dev fixtures through the real pipeline: the billing Dev Mock's cookie now drives the notification routes too — fixture CRs ride the feed as `platformItems` and the client merges, overrides, and filters them like live ones; three new scenarios (`payg-debt-deletion`, `payg-debt-final`, `quota-full`) complete the catalog coverage; the sidebar dev mock is deleted and the billing mock's panel entry mounts with the inbox. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014deH6UMJWcCYravZx19nvh
Productionize the status hint surface: one tinted, in-flow banner at the top of the shell's content column that explains a holding billing state and offers the fix, evaluated client-side from already-proxied subscription, account, and quota reads. - Pure model: payment-due > account-debt > quota-full > trial-expiry, single slot, dismiss/revive edge semantics, settled strings from the design doc; payment-due stages change copy and dates only. - Subscription summary carries the Deletion Countdown stage and derived deadline (ADR-0063) plus the recovery voice; an expired Free trial asks for an upgrade, never a renewal. - Currency-free balance read and a per-workspace quota read split out of the existing loaders; the Usage quota rows learn Pods. - Dev fixtures gain `free-expiring` so every banner state is simulable. - CONTEXT.md names the Status Hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mestamp; code-review cleanups - The Go read proxy exposes `version` (spec.timestamp, else metadata.generation, else creationTimestamp) and the client keys `cr:` ids by it: an in-place overwrite of a fixed-name CR is a new id no receipt covers, while a label patch never changes it. `timestamp` keeps its display/sort fallback. - Pin the subscription-change rule: an upgrade over a still-scheduled downgrade is the next receipt; the superseded downgrade stays as history and its transaction never re-writes. - Share what was duplicated: `lib/time` (DAY_MS/HOUR_MS/MINUTE_MS), the billing surface tint recipe, `MICRO_UNITS_PER_CURRENCY_UNIT`, and the `cr:`/`db:` id helpers in `notification-ids`. - One authorize+parse seam for the body-carrying notification handlers; one JSON post helper in the client. - `notificationCRItemSchema` is pinned to `NotificationCRItem`; `StatusHint.cta` is a `NotificationCTA`. - Sidebar CTA renders through `AppButton`; the subscription-summary SWR key uses `kubeconfigCredentialKey` like its siblings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a96db6e to
9fcd315
Compare
aimeritething
left a comment
There was a problem hiding this comment.
P1 需求实现审查:以下三处会直接导致验收条件无法成立。
| > | ||
| {item.title} | ||
| </span> | ||
| <span className="mt-0.5 block truncate text-muted-foreground text-xs"> |
There was a problem hiding this comment.
[P1] 这里没有渲染 item.body,也没有详情视图。AIM-334 要求 8 个固定 CR 名称显示覆盖后的 title、body 和 CTA;当前用户只能看到标题、时间和 CTA,A1、D4、B5 的正文同样不可见。请在列表项或可打开的详情中实际呈现正文,并补充覆盖 UI 渲染的测试。
There was a problem hiding this comment.
Still open on current HEAD. feed-model now fills item.body for the 8 CR names and for A1/D4/B5, but NotificationRow still only renders item.title, then project · time, then the CTA — item.body is never mounted.
That leaves D4 and B5 as title-only (the useful sentence is the body), and the CR overrides’ Brain-voiced bodies unreachable. Please render the body in the row (or in an openable detail) and add a UI test that the override title and body are in the document.
There was a problem hiding this comment.
Addressed. The body has rendered since 3a24a0989 (Severity redesign: click expands a clamped item.body under the title). f78062dc8 adds app-sidebar-notifications.interaction.test.tsx: all 8 CR overrides render title and body and CTA with the upstream copy absent, an unknown CR falls back to upstream title/body, and A1/D4/B5 show their body sentence; clicking a row marks it read and un-clamps the body.
| for (const target of targets) { | ||
| next.add(target.id); | ||
| } | ||
| return next; |
There was a problem hiding this comment.
[P1] 已读回执失败在这里被吞掉,但前面已经把 ID 写入本地 readIds。结果是 UI 显示已读且用户无法重试,刷新或换设备后又恢复未读,不满足 AIM-332 的 ‘always writes a receipt’ 和跨会话持久化要求。请在失败时回滚或保留可重试状态,并处理 Mark all 超过 200 个 ID 的请求上限。
There was a problem hiding this comment.
Addressed. Failed receipts now remove the optimistic ids, toast, and leave the row clickable; postNotificationReadReceipts splits at NOTIFICATION_READ_BATCH_LIMIT (200) with client tests for 401 ids and a failed second batch.
| const produced = await store.produce({ | ||
| dedupeKey: creditHintDedupeKey(userUid), | ||
| kind: "credit-hint", | ||
| namespace, |
There was a problem hiding this comment.
[P1] D4 是账户级消息,设计要求在每个 workspace 可见。这里把全局去重消息写入首次观察到的 namespace,而 feed 又只按当前 namespace 查询,因此其他 workspace 永远看不到。请将账户级消息的存储或查询作用域与 workspace 消息分开,或者在各 workspace 的 feed 中投影同一条账户消息。
There was a problem hiding this comment.
Addressed. produce now sets userUid, listMessages is workspace rows (user_uid null) ∪ this person’s account rows, and the handler/store tests list the same hint from ns-b. Receipts stay uid-keyed, so a read in one workspace covers the others.
The remaining merge gap is separate: rekeyPersonalResources still does not move notification_messages.user_uid (comment on that call).
…batching Review follow-ups on PR #305: - The gift hint (catalog D4) is account-level but was stored under the first observing namespace and the feed only listed that namespace, so other workspaces never showed it. `notification_messages` gains a nullable `user_uid` (migration 0016); an inbox now lists its workspace's rows plus the person's account-scoped rows, and `markRead` attaches receipts to either. ADR-0067 records the two scopes. - A failed read receipt was swallowed after the optimistic `readIds` write, leaving the item read for the session and unread after reload with no way to retry. The failure now rolls the optimistic ids back and says so with a toast. - "Mark all" with more than 200 unread ids hit the route's request limit and failed silently. The client splits receipts into batches of `NOTIFICATION_READ_BATCH_LIMIT`, sent in order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W33Mx9kYjBFoaQux2wKWJz
|
@cursoragent review this |
|
Reviewed #305 and posted findings on the PR. Still blocking: AIM-334 body copy is computed ( Previous P1s that are fixed: mark-read rolls back and batches at 200; D4 is account-scoped and listed in every workspace. New P2s: account merge re-keys receipts but not The hybrid CR proxy, CR id versioning, gift-only filter, and Status Hint model look sound. |
Both migrations were introduced on this branch and never left it: 0015 created the sealai_notification schema and 0016 added user_uid to notification_messages. Regenerated from schema.ts as a single 0015 so a fresh database gets the final table shape in one step. The regenerated `when` is later than the 0015 candidates on the other open PRs, so drizzle's timestamp ordering stays correct whichever lands first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKrT7639VGVh1swM6dfFu8
20e095f to
ccb7807
Compare
…verity Replace the kind axis (and the producer-less deploy-* kinds) with a Notification Severity derived from the message: the debt ladder and every workspace-debt stage are critical, low-balance tiers and quota exhaustion warn, receipts, hints, and announcements are info. The panel now renders the body (clamped; click expands and marks read), colors icon and CTA chip by severity with the Status Hint's tint recipe, filters with a SlidingToggle, offers 'Mark all as read' as a text button, shows short relative time with an absolute tooltip, and keeps a floor height so an empty inbox and a full one read as the same surface. CONTEXT.md gains the Severity term. Prototype variants live on prototype/notification-center-variants. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GStxUVYCv8odwpRXtc3gUy
ReviewTwo of the three previous P1s are fixed on current HEAD (
Still blocking AIM-334: New gaps from the account-scope follow-up and the observation points are on the diff as [P2]s: identity merge does not re-key The hybrid CR proxy + Brain store, CR id versioning by |
| .where(eq(onboardingProfiles.userUid, input.tombstoneUserUid)) | ||
| .returning({ userUid: onboardingProfiles.userUid }); | ||
|
|
||
| // Notification read receipts are keyed by (uid, message key): the |
There was a problem hiding this comment.
[P2] Account-scoped messages are uid-keyed personal resources now (ADR-0067 / ADR-0059), but the merge sweep only re-keys receipts.
After a merge, a credit-hint row stays on the tombstone user_uid and disappears from the survivor’s inbox. The live dedupe key is credit-hint:<tombstone-uid>, so a later observation writes credit-hint:<survivor-uid> — a second welcome — rather than following the existing row.
Same class: store.produce has no requireCurrentIdentityBinding, so a gift observation that authorized before the merge can commit a tombstone-keyed row after the sweep. markRead already serializes against that; produce should too when userUid is set.
Please re-key (or collapse) notification_messages.user_uid like profiles/receipts, and pin it in identity-fingerprint-core.test.ts (receipt re-key is also untested there).
There was a problem hiding this comment.
Addressed in f78062dc8. rekeyPersonalResources now re-keys notification_messages.user_uid and the uid-bearing dedupe_key (credit-hint:<tombstone> → credit-hint:<survivor>) so a later observation finds the existing row; where the survivor already holds a live row under that key the tombstone's is deleted (receipts cascade). store.produce takes account: { legacyWorkspaceActor, userUid } for account-scoped rows and runs requireCurrentIdentityBinding in the same transaction like markRead; the observation routes map a superseded binding to 401. identity-fingerprint-core.test.ts pins receipt re-key/dedup and message re-key + second-merge collapse; store.test.ts pins the produce refusal.
| ) { | ||
| return; | ||
| } | ||
| giftObservedFor.current = key; |
There was a problem hiding this comment.
[P2] The ref is set before the request. On failure the catch is empty, so this workspace+credential pair never retries for the rest of the session — D4 is missing until a reload or workspace switch.
Set the latch only after a successful POST (producer dedupe already makes a double-fire safe), or clear it in the catch so the next effect/poll can try again.
There was a problem hiding this comment.
Addressed in f78062dc8. The latch clears in the catch, and the credits SWR onSuccess calls the same observeGift on every poll (SWR keeps equal data referentially stable, so an effect dep alone would not retry). The producer dedupes by user, so a repeat is harmless.
| // without waiting on the desktop SDK (see project-workspace-layout). | ||
| // without waiting on the desktop SDK (see project-workspace-layout), and | ||
| // let the quota-exhausted producer observe the same snapshot. | ||
| useEffect(() => { |
There was a problem hiding this comment.
[P2] A1 is only observed here (once per appToken/kubeconfig/namespace) and on each chat turn. Status Hint polls workspace quota every 5 minutes and can show “CPU quota is full” while the inbox still has no A1 — and a recovery in that window never releases the live key, so the next crossing may not write a fresh entry.
The 30s quota cache used by the account popover also does not report observations. Please observe on the same cadence as the Status Hint quota read (or on cache refresh), so the banner and the inbox cannot disagree for minutes while the user never chats.
There was a problem hiding this comment.
Addressed in f78062dc8. New observeWorkspaceQuotaForInbox (read snapshot → report → mutate every notifications-feed key) is called from the Status Hint quota SWR onSuccess on its 5-minute cadence, and the sidebar mount observation goes through it too — the banner and the inbox observe on the same poll, recoveries release, and a produced A1 lands immediately rather than on the next feed poll. Unit-tested with injected deps.
…ing observations, panel UI test - Account merge now re-keys account-scoped notification_messages (user_uid and the uid-bearing dedupe key) to the survivor, collapsing onto a live survivor row; produce() re-checks the identity binding for account-scoped rows like markRead does, and the observation routes map a superseded binding to 401. - The gift-hint observation latch clears on failure and every credits poll is an observation opportunity, so a failed report no longer hides D4 for the session. - Workspace quota is observed on the Status Hint's polling cadence through a shared observeWorkspaceQuotaForInbox that also refreshes the inbox, so the banner and the inbox see the same snapshot. - Interaction test pins that the 8 CR overrides and A1/D4/B5 render title, body, and CTA in the panel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLcM4LbLxbAr6aFFUwNx2n
…v Mock cookie helper (AIM-338, AIM-339) Switching a billing Mock Scenario used mutate(() => true), refiring every SWR key on the page; with the cluster unreachable those requests hang and queue the mock's own refetches behind them. The toggle now revalidates only the keys the billing fixtures shape. features/dev-mock/ carries the cookie grammar, the server-side resolver and the panel source every route-handler Dev Mock shares; the billing mock is the first consumer, behaviour unchanged. The production gate stays inlined at each route so the fixture import can still be statically dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Nid6CzLVaZwacAdF5EhY9
… messages (AIM-339) A second, independent Dev Mock (cookie sealai-notifications-dev-mock) serves announcements, deployment outcomes and database events as fixture platform CRs, layered over whatever answers below — the billing mock's feed or the real handler — so the two mocks stack. Read receipts for fixture ids stay in memory; the rest forward. Scenarios: announcement, deployment, db-event, mixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Nid6CzLVaZwacAdF5EhY9
… (AIM-336) Cookie sealai-deploy-task-dev-mock serves one AI-runner fixture task per scenario (running, blocked, failed, succeeded, cancelled): the task list and projection stream answer with it so the dock chip appears, and the timeline routes answer for its id only, leaving real tasks reachable. The SSE routes send one snapshot frame and then only heartbeats, so the client never enters its reconnect loop. Toggling reloads the page — the stores hold SSE connections, not SWR keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Nid6CzLVaZwacAdF5EhY9
Cookie sealai-chat-dev-mock serves the session bootstrap, thread list and thread messages from three fixture threads (long with tool cards in every state and a pinned chat context, short, empty). Sending still goes to the real POST /api/chat, and the session keeps the real handler's Chat Billing Mode when it answers so the billing mock keeps driving it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Nid6CzLVaZwacAdF5EhY9
|
补充两个当前实现中的后续问题(PR 已 merged,建议跟进修复):
失败回调无条件删除本次请求的 |


Summary
The Notification Center stops being a dev-mock shell and becomes the user's real inbox, and a Status Hint banner explains a holding billing state at the top of the content area.
sealai_notification: messages with a dedupe key, per-user read receipts, 365-day sweep on write). One merged list, real timestamps,cr:/db:ids. Any role marks read via a receipt; Owners/Managers additionally patch the CR'sisReadlabel best-effort so the desktop bell follows. First producer: quota-exhausted, one entry per resource, edge-triggered, released on recovery.credit-hint:<user>) and subscription-change receipts (subscription-change:<workspace>:<txid>). The legacy sidebar dev mock is deleted; the billing dev-fixture seam now feeds the real pipeline.Review follow-ups (last commit)
version=spec.timestamp, elsemetadata.generation, else creation time. Before, a CR withoutspec.timestampfell back tocreationTimestampfor its id, which an in-place overwrite keeps — an old receipt would have hidden the revived message. A label patch never changesgeneration, so marking read does not move the id.lib/time, one billing surface tint recipe,MICRO_UNITS_PER_CURRENCY_UNIT, id helpers innotification-ids; one authorize+parse seam in the handlers and one JSON post helper in the client;notificationCRItemSchemapinned toNotificationCRItem; sidebar CTA viaAppButton; subscription-summary SWR key viakubeconfigCredentialKey.Known gaps (deliberate, tracked separately)
cancel:<periodEnd>.Testing
bun check,bun typecheck,go vet: clean.go test ./service/notification/... ./route/notification/...: pass (fallback test assertstimestamp→ creation time andversion→ generation).bun testfornotifications,status-hint,shell,billing/server/dev-fixtures: 170 pass, 0 fail;packages/apiCR hook tests pass. The twobilling-plan-surface.interactionfailures pre-date this branch (reproduced on cleanmain).Database migration
0015_notification_center,0016_notification_account_scope) are squashed into a single0015_notification_centerregenerated fromschema.ts. Nothing outside this branch ever applied them.0015migration. Whichever lands later must renumber its migration on rebase (idx, file name,_journal.jsontag) and regenerate itswhentimestamp — drizzle's migrator orders bywhen> last appliedcreated_at, not by idx, so a rebased migration with an olderwhenthan the one already onmainis silently skipped on databases that already applied it. Deleting the migration + snapshot + journal entry and re-runningbun run db:generatedoes both.🤖 Generated with Claude Code