create-astram-app 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The WEB agent stream transport — the platform half of @astram/frontend-core's injected
3
+ * `AgentStreamTransport` (G-036 M1). `fetch` with a streamed body (never `EventSource`, which
4
+ * cannot send a bearer token or a POST body) to the same `/api` base as `httpClient.js`,
5
+ * decoded with `TextDecoder` (stream mode, so a UTF-8 sequence split across reads is kept
6
+ * whole). The service in frontend-core parses the SSE frames, folds them and runs the stall
7
+ * watchdog; this file only moves bytes.
8
+ *
9
+ * Auth: `Authorization: Bearer <getToken()>`. A `401` BEFORE THE FIRST BYTE (the response status,
10
+ * so no stream byte has been read) runs `refreshAfterUnauthorized(sentToken)` once — the
11
+ * refreshing http client's shared single-flight refresh, so the stream never spends a refresh
12
+ * token in parallel with a JSON call — then retries once with the rotated token. `expired` /
13
+ * `superseded` ⇒ status 401, `unavailable` ⇒ status 0, no retry. Once a `200` has arrived there is
14
+ * never a refresh: the stream is bound to the session, not the access token (SPEC §10), so token
15
+ * expiry mid-stream does not cut it.
16
+ *
17
+ * `open()` never throws: a network failure or abort is `{ status: 0 }`. A refusal (any non-200)
18
+ * carries its parsed `{ code, retryable }` body, else `error: null`.
19
+ */
20
+
21
+ const BASE = '/api'; // the same base as httpClient.js (Vite proxies /api to the server)
22
+
23
+ function refusalBody(data) {
24
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) return null;
25
+ return typeof data.code === 'string' && data.code !== '' && typeof data.retryable === 'boolean'
26
+ ? { code: data.code, retryable: data.retryable }
27
+ : null;
28
+ }
29
+
30
+ async function readJson(res) {
31
+ try {
32
+ return await res.json();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** Release a response body we will not read (best-effort). */
39
+ function discard(res) {
40
+ try {
41
+ const p = res.body && typeof res.body.cancel === 'function' ? res.body.cancel() : null;
42
+ if (p && typeof p.catch === 'function') p.catch(() => {});
43
+ } catch {
44
+ /* nothing to release */
45
+ }
46
+ }
47
+
48
+ const noop = () => {};
49
+
50
+ /**
51
+ * @param {{ getToken: () => (string | null),
52
+ * refreshAfterUnauthorized?: (sentToken: string) => Promise<
53
+ * { kind: 'rotated', accessToken: string } | { kind: 'expired' | 'superseded' | 'unavailable' }>,
54
+ * fetchImpl?: typeof fetch }} deps
55
+ * `getToken` — the current access token (AuthContext's token ref).
56
+ * `refreshAfterUnauthorized` — the refreshing http client's method of the same name.
57
+ * `fetchImpl` — for tests; defaults to the global `fetch`.
58
+ * @returns {import('@astram/frontend-core').AgentStreamTransport}
59
+ */
60
+ export function createAgentStreamTransport({ getToken, refreshAfterUnauthorized, fetchImpl } = {}) {
61
+ const doFetch = (...args) => (fetchImpl ?? globalThis.fetch)(...args);
62
+
63
+ return {
64
+ async open({ method, path, body, lastEventId, signal }) {
65
+ // Our own controller, so close() can stop the read; the caller's signal feeds it.
66
+ const ctl = new AbortController();
67
+ const onAbort = () => ctl.abort();
68
+ if (signal) {
69
+ if (signal.aborted) ctl.abort();
70
+ else signal.addEventListener('abort', onAbort);
71
+ }
72
+ const detach = () => {
73
+ if (signal) signal.removeEventListener('abort', onAbort);
74
+ };
75
+ const failed = (status, error = null) => {
76
+ detach();
77
+ return { status, error, chunks: null, close: noop };
78
+ };
79
+
80
+ const send = (token) => {
81
+ const headers = { accept: 'text/event-stream' };
82
+ if (token) headers.authorization = `Bearer ${token}`;
83
+ if (method === 'POST') headers['content-type'] = 'application/json';
84
+ if (typeof lastEventId === 'number') headers['last-event-id'] = String(lastEventId);
85
+ return doFetch(`${BASE}${path}`, {
86
+ method,
87
+ headers,
88
+ body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined,
89
+ cache: 'no-store',
90
+ signal: ctl.signal,
91
+ });
92
+ };
93
+
94
+ let res;
95
+ const sent = getToken ? getToken() : null;
96
+ try {
97
+ res = await send(sent);
98
+ } catch {
99
+ return failed(0);
100
+ }
101
+
102
+ // 401 before the first byte: one shared refresh, then one retry.
103
+ if (res.status === 401 && sent && typeof refreshAfterUnauthorized === 'function') {
104
+ discard(res);
105
+ let outcome;
106
+ try {
107
+ outcome = await refreshAfterUnauthorized(sent);
108
+ } catch {
109
+ outcome = { kind: 'unavailable' };
110
+ }
111
+ if (outcome && outcome.kind === 'rotated' && outcome.accessToken) {
112
+ try {
113
+ res = await send(outcome.accessToken);
114
+ } catch {
115
+ return failed(0);
116
+ }
117
+ } else if (outcome && (outcome.kind === 'expired' || outcome.kind === 'superseded')) {
118
+ return failed(401);
119
+ } else {
120
+ return failed(0);
121
+ }
122
+ }
123
+
124
+ if (res.status !== 200) {
125
+ const error = refusalBody(await readJson(res));
126
+ return failed(res.status, error);
127
+ }
128
+ if (!res.body || typeof res.body.getReader !== 'function') {
129
+ return failed(0);
130
+ }
131
+
132
+ const reader = res.body.getReader();
133
+ const decoder = new TextDecoder();
134
+ let closed = false;
135
+ const close = () => {
136
+ if (closed) return;
137
+ closed = true;
138
+ detach();
139
+ try {
140
+ const p = reader.cancel();
141
+ if (p && typeof p.catch === 'function') p.catch(() => {});
142
+ } catch {
143
+ /* already released */
144
+ }
145
+ ctl.abort();
146
+ };
147
+
148
+ async function* chunks() {
149
+ try {
150
+ for (;;) {
151
+ const { done, value } = await reader.read();
152
+ if (done) {
153
+ const tail = decoder.decode();
154
+ if (tail) yield tail;
155
+ return;
156
+ }
157
+ const text = decoder.decode(value, { stream: true });
158
+ if (text) yield text;
159
+ }
160
+ } finally {
161
+ close();
162
+ }
163
+ }
164
+
165
+ return { status: 200, error: null, chunks: chunks(), close };
166
+ },
167
+ };
168
+ }
@@ -0,0 +1,273 @@
1
+ // agentStreamTransport.test.mjs — the web agent stream transport (G-036 M1, SPEC
2
+ // agent-read-capabilities §5 frontend-core, §14 item 7): fetch with a streamed body, bearer token,
3
+ // and a refresh ONLY before the first byte (one shared refresh, one retry); never after a 200.
4
+ //
5
+ // node --test src/platform/agentStreamTransport.test.mjs
6
+ //
7
+ // `fetch` is injected (`fetchImpl`), so this runs under plain Node (>= 20: Response,
8
+ // ReadableStream, TextEncoder/TextDecoder are globals).
9
+ import { test, describe } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { createAgentStreamTransport } from './agentStreamTransport.js';
12
+
13
+ const enc = new TextEncoder();
14
+
15
+ /** A 200 SSE response whose body yields `parts` (strings or byte arrays), then optionally fails. */
16
+ function sseResponse(parts, { failAfter = false } = {}) {
17
+ let i = 0;
18
+ const body = new ReadableStream({
19
+ pull(controller) {
20
+ if (i < parts.length) {
21
+ const p = parts[i++];
22
+ controller.enqueue(typeof p === 'string' ? enc.encode(p) : p);
23
+ } else if (failAfter) {
24
+ controller.error(new Error('connection reset'));
25
+ } else {
26
+ controller.close();
27
+ }
28
+ },
29
+ });
30
+ return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } });
31
+ }
32
+
33
+ const json = (status, data) =>
34
+ new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
35
+
36
+ /** fetch stub: answers in order, records each call. */
37
+ function fetchSeq(...answers) {
38
+ const calls = [];
39
+ const fetchImpl = async (url, init) => {
40
+ calls.push({ url, init });
41
+ const a = answers.shift();
42
+ if (a instanceof Error) throw a;
43
+ return typeof a === 'function' ? a() : a;
44
+ };
45
+ return { fetchImpl, calls };
46
+ }
47
+
48
+ async function readAll(chunks) {
49
+ let text = '';
50
+ for await (const c of chunks) text += c;
51
+ return text;
52
+ }
53
+
54
+ const signal = () => new AbortController().signal;
55
+ const FRAME = 'id: 1\nevent: step\ndata: {}\n\n';
56
+
57
+ describe('createAgentStreamTransport', () => {
58
+ test('POST to /api with bearer, JSON body and SSE accept; 200 streams decoded text', async () => {
59
+ const { fetchImpl, calls } = fetchSeq(sseResponse([': hb\n\n', FRAME]));
60
+ const t = createAgentStreamTransport({ getToken: () => 'tok-1', fetchImpl });
61
+ const res = await t.open({
62
+ method: 'POST',
63
+ path: '/agent/tasks',
64
+ body: { message: 'Hi' },
65
+ signal: signal(),
66
+ });
67
+ assert.equal(res.status, 200);
68
+ assert.equal(res.error, null);
69
+ assert.equal(await readAll(res.chunks), `: hb\n\n${FRAME}`);
70
+ assert.equal(calls.length, 1);
71
+ assert.equal(calls[0].url, '/api/agent/tasks');
72
+ assert.equal(calls[0].init.method, 'POST');
73
+ assert.equal(calls[0].init.headers.authorization, 'Bearer tok-1');
74
+ assert.equal(calls[0].init.headers.accept, 'text/event-stream');
75
+ assert.equal(calls[0].init.headers['content-type'], 'application/json');
76
+ assert.equal(calls[0].init.body, '{"message":"Hi"}');
77
+ });
78
+
79
+ test('GET replay sends Last-Event-ID and no body', async () => {
80
+ const { fetchImpl, calls } = fetchSeq(sseResponse([]));
81
+ const t = createAgentStreamTransport({ getToken: () => 'tok-1', fetchImpl });
82
+ const res = await t.open({
83
+ method: 'GET',
84
+ path: '/agent/tasks/x/events',
85
+ body: null,
86
+ lastEventId: 7,
87
+ signal: signal(),
88
+ });
89
+ assert.equal(res.status, 200);
90
+ assert.equal(calls[0].init.headers['last-event-id'], '7');
91
+ assert.equal(calls[0].init.body, undefined);
92
+ assert.equal(calls[0].init.headers['content-type'], undefined);
93
+ });
94
+
95
+ test('a UTF-8 sequence split across reads is decoded whole', async () => {
96
+ const bytes = enc.encode('data: "— 𝔸"\n\n');
97
+ const { fetchImpl } = fetchSeq(
98
+ sseResponse([bytes.subarray(0, 8), bytes.subarray(8, 10), bytes.subarray(10)]),
99
+ );
100
+ const res = await createAgentStreamTransport({ getToken: () => 't', fetchImpl }).open({
101
+ method: 'GET',
102
+ path: '/p',
103
+ body: null,
104
+ signal: signal(),
105
+ });
106
+ assert.equal(await readAll(res.chunks), 'data: "— 𝔸"\n\n');
107
+ });
108
+
109
+ test('401 before the first byte: one refresh, one retry with the rotated token', async () => {
110
+ const { fetchImpl, calls } = fetchSeq(
111
+ json(401, { message: 'Unauthorized' }),
112
+ sseResponse([FRAME]),
113
+ );
114
+ const refreshes = [];
115
+ const t = createAgentStreamTransport({
116
+ getToken: () => 'old',
117
+ refreshAfterUnauthorized: async (sent) => {
118
+ refreshes.push(sent);
119
+ return { kind: 'rotated', accessToken: 'new' };
120
+ },
121
+ fetchImpl,
122
+ });
123
+ const res = await t.open({
124
+ method: 'POST',
125
+ path: '/agent/tasks',
126
+ body: { message: 'Hi' },
127
+ signal: signal(),
128
+ });
129
+ assert.deepEqual(refreshes, ['old']);
130
+ assert.equal(calls.length, 2);
131
+ assert.equal(calls[1].init.headers.authorization, 'Bearer new');
132
+ assert.equal(calls[1].init.body, '{"message":"Hi"}');
133
+ assert.equal(res.status, 200);
134
+ assert.equal(await readAll(res.chunks), FRAME);
135
+ });
136
+
137
+ test('a second 401 after the retry is returned: no second refresh', async () => {
138
+ const { fetchImpl, calls } = fetchSeq(json(401, null), json(401, null));
139
+ let refreshes = 0;
140
+ const t = createAgentStreamTransport({
141
+ getToken: () => 'old',
142
+ refreshAfterUnauthorized: async () => {
143
+ refreshes += 1;
144
+ return { kind: 'rotated', accessToken: 'new' };
145
+ },
146
+ fetchImpl,
147
+ });
148
+ const res = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
149
+ assert.equal(res.status, 401);
150
+ assert.equal(res.chunks, null);
151
+ assert.equal(refreshes, 1);
152
+ assert.equal(calls.length, 2);
153
+ });
154
+
155
+ test('expired / superseded ⇒ 401, unavailable ⇒ 0, a throwing refresh ⇒ 0; never a retry', async () => {
156
+ for (const [outcome, status] of [
157
+ [{ kind: 'expired' }, 401],
158
+ [{ kind: 'superseded' }, 401],
159
+ [{ kind: 'unavailable' }, 0],
160
+ [new Error('boom'), 0],
161
+ ]) {
162
+ const { fetchImpl, calls } = fetchSeq(json(401, null));
163
+ const t = createAgentStreamTransport({
164
+ getToken: () => 'old',
165
+ refreshAfterUnauthorized: async () => {
166
+ if (outcome instanceof Error) throw outcome;
167
+ return outcome;
168
+ },
169
+ fetchImpl,
170
+ });
171
+ const res = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
172
+ assert.equal(res.status, status);
173
+ assert.equal(res.chunks, null);
174
+ assert.equal(calls.length, 1);
175
+ }
176
+ });
177
+
178
+ test('after a 200 there is never a refresh: a failing stream or a token change mid-stream', async () => {
179
+ const { fetchImpl, calls } = fetchSeq(sseResponse([FRAME], { failAfter: true }));
180
+ let token = 'tok-1';
181
+ let refreshes = 0;
182
+ const t = createAgentStreamTransport({
183
+ getToken: () => token,
184
+ refreshAfterUnauthorized: async () => {
185
+ refreshes += 1;
186
+ return { kind: 'rotated', accessToken: 'x' };
187
+ },
188
+ fetchImpl,
189
+ });
190
+ const res = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
191
+ assert.equal(res.status, 200);
192
+ const it = res.chunks[Symbol.asyncIterator]();
193
+ assert.equal((await it.next()).value, FRAME);
194
+ token = 'tok-2'; // another tab refreshed: the open stream is unaffected
195
+ await assert.rejects(it.next());
196
+ assert.equal(refreshes, 0);
197
+ assert.equal(calls.length, 1);
198
+ });
199
+
200
+ test('no token ⇒ no Authorization header and no refresh on 401', async () => {
201
+ const { fetchImpl, calls } = fetchSeq(json(401, null));
202
+ let refreshes = 0;
203
+ const t = createAgentStreamTransport({
204
+ getToken: () => null,
205
+ refreshAfterUnauthorized: async () => {
206
+ refreshes += 1;
207
+ return { kind: 'rotated', accessToken: 'x' };
208
+ },
209
+ fetchImpl,
210
+ });
211
+ const res = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
212
+ assert.equal(res.status, 401);
213
+ assert.equal(calls[0].init.headers.authorization, undefined);
214
+ assert.equal(refreshes, 0);
215
+ });
216
+
217
+ test('a refusal carries its parsed { code, retryable } body; other bodies ⇒ null', async () => {
218
+ const { fetchImpl } = fetchSeq(
219
+ json(409, { code: 'task_busy', retryable: true, extra: 'ignored' }),
220
+ json(503, { message: 'server text' }),
221
+ );
222
+ const t = createAgentStreamTransport({ getToken: () => 't', fetchImpl });
223
+ const a = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
224
+ assert.deepEqual(a.error, { code: 'task_busy', retryable: true });
225
+ assert.equal(a.status, 409);
226
+ const b = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
227
+ assert.equal(b.status, 503);
228
+ assert.equal(b.error, null);
229
+ });
230
+
231
+ test('network failure ⇒ status 0; an aborted signal aborts the fetch', async () => {
232
+ const { fetchImpl } = fetchSeq(new TypeError('Failed to fetch'));
233
+ const t = createAgentStreamTransport({ getToken: () => 't', fetchImpl });
234
+ assert.equal(
235
+ (await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() })).status,
236
+ 0,
237
+ );
238
+
239
+ let seenSignal;
240
+ const t2 = createAgentStreamTransport({
241
+ getToken: () => 't',
242
+ fetchImpl: async (_u, init) => {
243
+ seenSignal = init.signal;
244
+ throw new DOMException('aborted', 'AbortError');
245
+ },
246
+ });
247
+ const ctl = new AbortController();
248
+ ctl.abort();
249
+ assert.equal(
250
+ (await t2.open({ method: 'POST', path: '/p', body: {}, signal: ctl.signal })).status,
251
+ 0,
252
+ );
253
+ assert.equal(seenSignal.aborted, true);
254
+ });
255
+
256
+ test('close() ends the stream and aborts the request', async () => {
257
+ let seenSignal;
258
+ const t = createAgentStreamTransport({
259
+ getToken: () => 't',
260
+ fetchImpl: async (_u, init) => {
261
+ seenSignal = init.signal;
262
+ return sseResponse([FRAME, FRAME, FRAME]);
263
+ },
264
+ });
265
+ const res = await t.open({ method: 'GET', path: '/p', body: null, signal: signal() });
266
+ const it = res.chunks[Symbol.asyncIterator]();
267
+ await it.next();
268
+ res.close();
269
+ assert.equal(seenSignal.aborted, true);
270
+ const next = await it.next().catch(() => ({ done: true }));
271
+ assert.equal(next.done, true);
272
+ });
273
+ });
@@ -5,7 +5,8 @@
5
5
  * On open: for holders of `identity:role:update`, the open admin tasks (a new permission no role
6
6
  * in a company holds yet): "Grant…" opens `/access/add?key=` with the key ticked; "Reviewed, not
7
7
  * needed" confirms (naming the permission and company) then dismisses. Below, the announcement
8
- * notices with the same read behaviour as the bell. The `can` gate is cosmetic: the server
8
+ * notices with the same behaviour as the bell (unread only, severity dot, "View all
9
+ * notifications" to `/notifications?tab=announcement`; G-035). The `can` gate is cosmetic: the server
9
10
  * decides, and a 403 on the task list hides the section. Labels and company names only — the
10
11
  * task's company id and key are used in calls and URLs, never shown.
11
12
  */
@@ -164,11 +165,7 @@ export default function AnnouncementsMenu() {
164
165
  <DropdownMenuLabel>Announcements</DropdownMenuLabel>
165
166
  <DropdownMenuSeparator />
166
167
  {canTasks && <TasksSection onDismiss={setTarget} />}
167
- <NoticeSection
168
- category="announcement"
169
- heading={canTasks ? 'News' : undefined}
170
- emptyText="No announcements."
171
- />
168
+ <NoticeSection category="announcement" heading={canTasks ? 'News' : undefined} />
172
169
  </DropdownMenuContent>
173
170
  </DropdownMenu>
174
171
 
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * NotificationsMenu — the header bell (G-027 step 5): the user's PERSONAL notices (changes to
3
- * their own access). Badge = unread personal count (useNotificationCounts). The list loads when
4
- * the menu opens; opening marks nothing read. Clicking a notice marks it read and follows its
5
- * link only when app-relative; "Mark all read" clears the category. Text only, never HTML.
3
+ * their own access, and app notices). Badge = unread personal count (useNotificationCounts). The
4
+ * list (unread only, newest 20; G-035) loads when the menu opens; opening marks nothing read.
5
+ * Each row carries its severity dot. Clicking a notice marks it read and follows its link only
6
+ * when app-relative; "Mark all read" clears the category; "View all notifications" opens the
7
+ * Notifications page (`/notifications?tab=personal`), with "N more" when over 20 are unread.
8
+ * Text only, never HTML.
6
9
  *
7
10
  * Also exports the pieces AnnouncementsMenu reuses: `HeaderMenuTrigger` (the header icon button
8
11
  * with tooltip and count badge) and `NoticeSection` (one category's notices).
@@ -10,7 +13,7 @@
10
13
  import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
11
14
  import { useNavigate } from 'react-router-dom';
12
15
  import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
13
- import { Bell, CheckCheck, Loader2, RotateCw } from 'lucide-react';
16
+ import { Bell, CheckCheck, ListChecks, Loader2, RotateCw } from 'lucide-react';
14
17
  import { toast } from 'sonner';
15
18
  import { useAuth } from '@/auth/AuthContext';
16
19
  import { cn } from '@/lib/utils';
@@ -22,7 +25,16 @@ import {
22
25
  DropdownMenuLabel,
23
26
  DropdownMenuSeparator,
24
27
  } from '@/components/ui/dropdown-menu';
25
- import { badgeLabel, badgeText, relativeTime, safeAppLink } from './notificationsView';
28
+ import {
29
+ DROPDOWN_LIMIT,
30
+ badgeLabel,
31
+ badgeText,
32
+ moreText,
33
+ relativeTime,
34
+ safeAppLink,
35
+ severityInfo,
36
+ viewAllHref,
37
+ } from './notificationsView';
26
38
  import { useNotificationCounts } from './useNotificationCounts';
27
39
 
28
40
  /**
@@ -110,19 +122,19 @@ export function MenuError({ text, onRetry }) {
110
122
  );
111
123
  }
112
124
 
113
- const LIST_LIMIT = 20;
114
-
115
125
  /**
116
- * One category's latest notices. Mounted only while the menu is open (Radix unmounts closed
117
- * content), so it loads on open. `heading` (optional) labels the section.
126
+ * One category's newest UNREAD notices (G-035: at most 20, plus "N more" and a "View all
127
+ * notifications" link to the Notifications page tab). Mounted only while the menu is open
128
+ * (Radix unmounts closed content), so it loads on open. `heading` (optional) labels the section;
129
+ * `emptyText` defaults to "You're all caught up".
118
130
  */
119
- export function NoticeSection({ category, heading, emptyText }) {
131
+ export function NoticeSection({ category, heading, emptyText = 'You’re all caught up' }) {
120
132
  const { tokens, notifications } = useAuth();
121
133
  const counts = useNotificationCounts();
122
134
  const { refresh } = counts;
123
135
  const navigate = useNavigate();
124
136
  const token = tokens?.accessToken ?? null;
125
- const [view, setView] = useState({ status: 'loading', rows: [], error: '', now: 0 });
137
+ const [view, setView] = useState({ status: 'loading', rows: [], total: 0, error: '', now: 0 });
126
138
  const [busyAll, setBusyAll] = useState(false);
127
139
  const alive = useRef(true);
128
140
  // Bumped per list load and when "Mark all read" starts, so only the latest load applies: a
@@ -139,9 +151,14 @@ export function NoticeSection({ category, heading, emptyText }) {
139
151
  const load = useCallback(async () => {
140
152
  if (!token) return;
141
153
  const seq = ++loadSeq.current;
142
- const r = await notifications.listNotifications(token, { category, limit: LIST_LIMIT });
154
+ const r = await notifications.listNotifications(token, {
155
+ category,
156
+ unread: true,
157
+ limit: DROPDOWN_LIMIT,
158
+ });
143
159
  if (!alive.current || seq !== loadSeq.current) return;
144
- if (r.ok) setView({ status: 'ready', rows: r.rows, error: '', now: Date.now() });
160
+ if (r.ok)
161
+ setView({ status: 'ready', rows: r.rows, total: r.total, error: '', now: Date.now() });
145
162
  else setView((v) => ({ ...v, status: 'error', error: r.error }));
146
163
  }, [notifications, token, category]);
147
164
 
@@ -181,6 +198,8 @@ export function NoticeSection({ category, heading, emptyText }) {
181
198
 
182
199
  const unreadInList = view.rows.some((row) => !row.readAt);
183
200
  const unreadCount = category === 'personal' ? counts.personal : counts.announcement;
201
+ const more = moreText(view.total, view.rows.length);
202
+ const allHref = viewAllHref(category);
184
203
 
185
204
  return (
186
205
  <section aria-label={heading || undefined}>
@@ -197,6 +216,7 @@ export function NoticeSection({ category, heading, emptyText }) {
197
216
  {view.status === 'ready' &&
198
217
  view.rows.map((row) => {
199
218
  const unread = !row.readAt;
219
+ const severity = severityInfo(row.severity);
200
220
  return (
201
221
  <DropdownMenuItem
202
222
  key={row.uuid}
@@ -205,14 +225,11 @@ export function NoticeSection({ category, heading, emptyText }) {
205
225
  >
206
226
  <span
207
227
  aria-hidden="true"
208
- className={cn(
209
- 'mt-1.5 size-2 shrink-0 rounded-full',
210
- unread ? 'bg-primary' : 'bg-transparent',
211
- )}
228
+ className={cn('mt-1.5 size-2 shrink-0 rounded-full', severity.dotClass)}
212
229
  />
213
230
  <span className="min-w-0 flex-1">
214
231
  <span className={cn('block text-sm', unread ? 'font-semibold' : 'font-normal')}>
215
- {unread && <span className="sr-only">Unread: </span>}
232
+ <span className="sr-only">{`${severity.label}${unread ? ', unread' : ''}: `}</span>
216
233
  {row.title}
217
234
  </span>
218
235
  {row.body && (
@@ -244,6 +261,20 @@ export function NoticeSection({ category, heading, emptyText }) {
244
261
  Mark all read
245
262
  </DropdownMenuItem>
246
263
  )}
264
+ {view.status !== 'loading' && (
265
+ <div className="flex items-center justify-between gap-2 border-t border-border px-1 pt-1">
266
+ <DropdownMenuItem
267
+ onSelect={() => navigate(allHref)}
268
+ className="text-xs font-medium text-primary focus:text-primary"
269
+ >
270
+ <ListChecks aria-hidden="true" />
271
+ View all notifications
272
+ </DropdownMenuItem>
273
+ {view.status === 'ready' && more && (
274
+ <span className="px-2 text-xs text-muted-foreground">{more}</span>
275
+ )}
276
+ </div>
277
+ )}
247
278
  </section>
248
279
  );
249
280
  }
@@ -256,7 +287,7 @@ export default function NotificationsMenu() {
256
287
  <DropdownMenuContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
257
288
  <DropdownMenuLabel>Notifications</DropdownMenuLabel>
258
289
  <DropdownMenuSeparator />
259
- <NoticeSection category="personal" emptyText="You have no notifications." />
290
+ <NoticeSection category="personal" />
260
291
  </DropdownMenuContent>
261
292
  </DropdownMenu>
262
293
  );
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * notificationsView — React-free helpers for the header bell and Announcements menus (G-027
3
- * step 5). Kept pure so `node --test` covers them (notificationsView.test.mjs); the menus in
4
- * NotificationsMenu.jsx / AnnouncementsMenu.jsx only render what these return.
3
+ * step 5; unread-only lists, severity dot and "View all" footer, G-035). Kept pure so
4
+ * `node --test` covers them (notificationsView.test.mjs); the menus in NotificationsMenu.jsx /
5
+ * AnnouncementsMenu.jsx and the Notifications page only render what these return.
5
6
  */
6
7
 
7
8
  /** The counts poll interval (spec §10: 60 s, plus window focus). */
@@ -136,3 +137,41 @@ export function announcementsBadgeCount(counts) {
136
137
  const t = Number.isFinite(counts?.tasks) ? counts.tasks : 0;
137
138
  return a + t;
138
139
  }
140
+
141
+ // --- G-035 notification center: unread-only dropdowns, severity, "View all" -----------------
142
+
143
+ /** How many unread notices a dropdown loads (spec §10: 20 unread). */
144
+ export const DROPDOWN_LIMIT = 20;
145
+
146
+ /** The Notifications page tab for a category: `personal` (bell) or `announcement`. */
147
+ export function viewAllHref(category) {
148
+ const tab = category === 'announcement' ? 'announcement' : 'personal';
149
+ return `/notifications?tab=${tab}`;
150
+ }
151
+
152
+ /**
153
+ * The dropdown footer's "N more" (N = unread total − rows shown), or '' when every unread
154
+ * notice is already shown (or the numbers are not counts).
155
+ */
156
+ export function moreText(total, shown) {
157
+ if (!Number.isFinite(total) || !Number.isFinite(shown)) return '';
158
+ const n = Math.floor(total) - Math.floor(shown);
159
+ return n > 0 ? `${n} more` : '';
160
+ }
161
+
162
+ const SEVERITY = Object.freeze({
163
+ info: Object.freeze({ label: 'Info', dotClass: 'bg-primary' }),
164
+ success: Object.freeze({ label: 'Success', dotClass: 'bg-ok' }),
165
+ warning: Object.freeze({ label: 'Warning', dotClass: 'bg-warn' }),
166
+ });
167
+
168
+ /**
169
+ * A notice's severity dot: `{ label, dotClass }` (theme tokens only). Anything but `success` or
170
+ * `warning` reads as `info`, the default (spec §10). The label goes in sr-only text beside the
171
+ * dot, so colour is never the only signal.
172
+ */
173
+ export function severityInfo(severity) {
174
+ return Object.prototype.hasOwnProperty.call(SEVERITY, severity)
175
+ ? SEVERITY[severity]
176
+ : SEVERITY.info;
177
+ }