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.
- package/package.json +1 -1
- package/template/README.md +18 -0
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +12 -0
- package/template/frontend/src/auth/AuthContext.jsx +17 -0
- package/template/frontend/src/pages/notifications/NotificationsPage.jsx +550 -0
- package/template/frontend/src/pages/notifications/notificationList.js +268 -0
- package/template/frontend/src/pages/notifications/notificationList.test.mjs +273 -0
- package/template/frontend/src/platform/agentStreamTransport.js +168 -0
- package/template/frontend/src/platform/agentStreamTransport.test.mjs +273 -0
- package/template/frontend/src/shell/AnnouncementsMenu.jsx +3 -6
- package/template/frontend/src/shell/NotificationsMenu.jsx +50 -19
- package/template/frontend/src/shell/notificationsView.js +41 -2
- package/template/frontend/src/shell/notificationsView.test.mjs +38 -0
- package/template/server/_env.example +3 -1
- package/template/server/package.json +6 -6
- package/template/server/src/app.module.ts +13 -0
- package/template/server/src/main.ts +7 -1
- package/template/server/src/notifications/app-notifications.service.spec.ts +26 -0
- package/template/server/src/notifications/app-notifications.service.ts +41 -0
|
@@ -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
|
|
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
|
|
4
|
-
* the menu opens; opening marks nothing read.
|
|
5
|
-
*
|
|
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 {
|
|
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
|
|
117
|
-
*
|
|
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, {
|
|
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)
|
|
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
|
-
|
|
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"
|
|
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
|
|
4
|
-
*
|
|
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
|
+
}
|