threadroom-pi 0.1.0-beta.0
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/LICENSE +21 -0
- package/README.md +110 -0
- package/extensions/README.md +15 -0
- package/extensions/index.ts +280 -0
- package/extensions/native/index.ts +614 -0
- package/extensions/native/presentation.ts +30 -0
- package/extensions/native/receipt.ts +25 -0
- package/extensions/native/ui.ts +167 -0
- package/extensions/presentation/renderers.ts +185 -0
- package/extensions/questions/README.md +41 -0
- package/extensions/questions/compose.ts +82 -0
- package/extensions/questions/external-editor.ts +24 -0
- package/extensions/questions/host.ts +415 -0
- package/extensions/questions/index.ts +6 -0
- package/extensions/questions/model.ts +175 -0
- package/extensions/questions/stream.ts +299 -0
- package/extensions/questions/text.ts +10 -0
- package/extensions/questions/tool.ts +309 -0
- package/extensions/questions/types.ts +40 -0
- package/extensions/questions/view.ts +221 -0
- package/node_modules/threadroom-service/README.md +73 -0
- package/node_modules/threadroom-service/bin/threadroom-service.js +9 -0
- package/node_modules/threadroom-service/dist/public/app.js +349 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-bloom.svg +17 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-drift.svg +16 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-prowler.svg +15 -0
- package/node_modules/threadroom-service/dist/public/client.js +30 -0
- package/node_modules/threadroom-service/dist/public/index.html +54 -0
- package/node_modules/threadroom-service/dist/public/presentations.js +194 -0
- package/node_modules/threadroom-service/dist/public/routes.js +15 -0
- package/node_modules/threadroom-service/dist/public/styles.css +263 -0
- package/node_modules/threadroom-service/dist/src/live.js +170 -0
- package/node_modules/threadroom-service/dist/src/main.js +33 -0
- package/node_modules/threadroom-service/dist/src/presentations.js +116 -0
- package/node_modules/threadroom-service/dist/src/server.js +143 -0
- package/node_modules/threadroom-service/dist/src/site.js +53 -0
- package/node_modules/threadroom-service/dist/src/store.js +459 -0
- package/node_modules/threadroom-service/dist/src/ui-main.js +14 -0
- package/node_modules/threadroom-service/lib/cli.js +188 -0
- package/node_modules/threadroom-service/lib/ensure.js +157 -0
- package/node_modules/threadroom-service/lib/paths.js +19 -0
- package/node_modules/threadroom-service/package.json +19 -0
- package/package.json +50 -0
- package/scripts/stage-service.js +32 -0
- package/scripts/verify-packed.js +85 -0
- package/scripts/verify-release.js +79 -0
- package/src/client.js +135 -0
- package/src/config.js +57 -0
- package/src/http-transport.js +44 -0
- package/src/participation.js +211 -0
- package/src/service-runtime.js +43 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
const MESSAGE_TYPE = 'threadroom:proposal';
|
|
2
|
+
const MAX_MESSAGE_BYTES = 65536;
|
|
3
|
+
const MAX_SUMMARY_LENGTH = 4096;
|
|
4
|
+
const MAX_JSON_DEPTH = 32;
|
|
5
|
+
|
|
6
|
+
// JSON only: no Date, Map, non-finite numbers or undefined silently discarded
|
|
7
|
+
// by JSON.stringify. Bound depth/work as well as the serialized byte count.
|
|
8
|
+
function isJson(value, depth = 0, budget = { remaining: MAX_MESSAGE_BYTES }) {
|
|
9
|
+
if (--budget.remaining < 0 || depth > MAX_JSON_DEPTH) return false;
|
|
10
|
+
if (value === null || typeof value === 'boolean') return true;
|
|
11
|
+
if (typeof value === 'string') {
|
|
12
|
+
// Count every occurrence, not only each distinct object. Structured clone
|
|
13
|
+
// preserves shared identity, while JSON serialization expands every use.
|
|
14
|
+
budget.remaining -= value.length;
|
|
15
|
+
return budget.remaining >= 0;
|
|
16
|
+
}
|
|
17
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
18
|
+
if (typeof value !== 'object') return false;
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
// JSON.stringify walks through array length, including holes. Reject sparse
|
|
21
|
+
// or impossibly large arrays before serialization can monopolize the host.
|
|
22
|
+
if (value.length > budget.remaining) return false;
|
|
23
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
24
|
+
if (!Object.hasOwn(value, index) || !isJson(value[index], depth + 1, budget)) return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
|
|
29
|
+
for (const [key, item] of Object.entries(value)) {
|
|
30
|
+
budget.remaining -= key.length;
|
|
31
|
+
if (budget.remaining < 0 || !isJson(item, depth + 1, budget)) return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function proposalFromMessage(data) {
|
|
37
|
+
try {
|
|
38
|
+
if (typeof data === 'string') {
|
|
39
|
+
if (data.length > MAX_MESSAGE_BYTES || new TextEncoder().encode(data).length > MAX_MESSAGE_BYTES) return null;
|
|
40
|
+
data = JSON.parse(data);
|
|
41
|
+
}
|
|
42
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || data.type !== MESSAGE_TYPE) return null;
|
|
43
|
+
if (!Object.hasOwn(data, 'values') || !isJson(data)) return null;
|
|
44
|
+
const json = JSON.stringify(data);
|
|
45
|
+
if (new TextEncoder().encode(json).length > MAX_MESSAGE_BYTES) return null;
|
|
46
|
+
if (data.summary !== undefined && (typeof data.summary !== 'string' || data.summary.length > MAX_SUMMARY_LENGTH)) return null;
|
|
47
|
+
const values = JSON.parse(json).values;
|
|
48
|
+
const summary = data.summary?.trim() || readableValue(values).slice(0, MAX_SUMMARY_LENGTH);
|
|
49
|
+
return { values, summary };
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readableValue(value) {
|
|
56
|
+
if (typeof value === 'string') return value;
|
|
57
|
+
return JSON.stringify(value, null, 2) ?? '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fallbackText(presentation) {
|
|
61
|
+
const fallback = presentation?.fallback;
|
|
62
|
+
if (typeof fallback === 'string' && fallback.trim()) return fallback;
|
|
63
|
+
if (fallback && typeof fallback === 'object') return readableValue(fallback);
|
|
64
|
+
return 'This authored canvas proposes a draft only. You can always answer in text or reject using the host controls below.';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function element(document, tag, className, text) {
|
|
68
|
+
const result = document.createElement(tag);
|
|
69
|
+
if (className) result.className = className;
|
|
70
|
+
if (text !== undefined) result.textContent = text;
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Only append/remove our own presentation subtree. The caller retains ownership
|
|
75
|
+
// of dependable answer/reject controls and decides what a proposal means.
|
|
76
|
+
export function mountPresentation(container, { node, onProposal, apiBaseUrl = '' }) {
|
|
77
|
+
if (typeof onProposal !== 'function') throw new TypeError('onProposal must be a function');
|
|
78
|
+
const document = container.ownerDocument;
|
|
79
|
+
const window = document.defaultView;
|
|
80
|
+
const root = element(document, 'div', 'presentation');
|
|
81
|
+
const presentation = node?.presentation || { kind: 'text-v1' };
|
|
82
|
+
const disposers = [];
|
|
83
|
+
let mounted = true;
|
|
84
|
+
const emit = (proposal) => { if (mounted) onProposal(proposal); };
|
|
85
|
+
const listen = (target, type, callback) => {
|
|
86
|
+
target.addEventListener(type, callback);
|
|
87
|
+
disposers.push(() => target.removeEventListener(type, callback));
|
|
88
|
+
};
|
|
89
|
+
const fallback = () => root.append(element(document, 'div', 'fallback-presentation', fallbackText(presentation)));
|
|
90
|
+
|
|
91
|
+
if (presentation.kind === 'html-v1') {
|
|
92
|
+
root.append(element(document, 'p', 'presentation-eyebrow', 'AUTHORED CANVAS · PROPOSES A DRAFT, NEVER SUBMITS'));
|
|
93
|
+
const frame = element(document, 'iframe', 'authored-frame');
|
|
94
|
+
frame.title = `Interactive presentation: ${node.title || node.prompt || 'question or answer'}`;
|
|
95
|
+
frame.setAttribute('sandbox', 'allow-scripts');
|
|
96
|
+
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
97
|
+
// Additional defense in supporting browsers. CSP on the served document is
|
|
98
|
+
// authoritative; this Permissions Policy also denies unrelated capabilities.
|
|
99
|
+
frame.setAttribute('allow', "camera 'none'; microphone 'none'; geolocation 'none'; payment 'none'; fullscreen 'none'; clipboard-read 'none'; clipboard-write 'none'; usb 'none'");
|
|
100
|
+
frame.setAttribute('height', '360');
|
|
101
|
+
frame.setAttribute('width', '100%');
|
|
102
|
+
frame.src = `${apiBaseUrl.replace(/\/$/, '')}/api/nodes/${encodeURIComponent(node.id)}/presentation`;
|
|
103
|
+
listen(window, 'message', (event) => {
|
|
104
|
+
// Sandboxed documents have opaque origin ('null'); origin cannot identify
|
|
105
|
+
// them. WindowProxy identity associates a proposal with exactly this node.
|
|
106
|
+
if (!mounted || event.source !== frame.contentWindow) return;
|
|
107
|
+
const proposal = proposalFromMessage(event.data);
|
|
108
|
+
if (proposal) emit(proposal);
|
|
109
|
+
});
|
|
110
|
+
root.append(frame);
|
|
111
|
+
fallback();
|
|
112
|
+
} else if (presentation.kind === 'comparison-v1' && Array.isArray(presentation.options)) {
|
|
113
|
+
const grid = element(document, 'div', 'comparison-grid');
|
|
114
|
+
const buttons = [];
|
|
115
|
+
for (const option of presentation.options) {
|
|
116
|
+
if (!option || typeof option.id !== 'string' || typeof option.label !== 'string') continue;
|
|
117
|
+
const button = element(document, 'button', 'option-card');
|
|
118
|
+
button.type = 'button';
|
|
119
|
+
button.setAttribute('aria-pressed', 'false');
|
|
120
|
+
if (typeof option.image === 'string' && option.image) {
|
|
121
|
+
const image = element(document, 'img', 'option-image');
|
|
122
|
+
// Image context permits self-contained SVG too; never accept script or
|
|
123
|
+
// HTML URLs. Relative asset paths resolve against the host document.
|
|
124
|
+
let safeImage = false;
|
|
125
|
+
try {
|
|
126
|
+
const url = new URL(option.image, document.baseURI);
|
|
127
|
+
safeImage = ['http:', 'https:'].includes(url.protocol) || /^data:image\//i.test(option.image);
|
|
128
|
+
} catch { /* invalid URL: retain the readable option */ }
|
|
129
|
+
if (safeImage) {
|
|
130
|
+
image.src = option.image;
|
|
131
|
+
image.alt = typeof option.alt === 'string' ? option.alt : option.label;
|
|
132
|
+
image.referrerPolicy = 'no-referrer';
|
|
133
|
+
button.append(image);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const copy = element(document, 'span', 'option-copy');
|
|
137
|
+
copy.append(element(document, 'strong', '', option.label));
|
|
138
|
+
if (typeof option.detail === 'string') copy.append(element(document, 'small', '', option.detail));
|
|
139
|
+
button.append(copy);
|
|
140
|
+
listen(button, 'click', () => {
|
|
141
|
+
for (const candidate of buttons) {
|
|
142
|
+
candidate.classList.toggle('selected', candidate === button);
|
|
143
|
+
candidate.setAttribute('aria-pressed', String(candidate === button));
|
|
144
|
+
}
|
|
145
|
+
emit({ values: { id: option.id, label: option.label }, summary: [option.label, option.detail].filter(Boolean).join(' — ') });
|
|
146
|
+
});
|
|
147
|
+
buttons.push(button);
|
|
148
|
+
grid.append(button);
|
|
149
|
+
}
|
|
150
|
+
root.append(grid);
|
|
151
|
+
if (!buttons.length) fallback();
|
|
152
|
+
} else if (presentation.kind === 'text-v1') {
|
|
153
|
+
if (typeof presentation.text === 'string') root.append(element(document, 'div', 'fallback-presentation', presentation.text));
|
|
154
|
+
const list = element(document, 'div', 'choice-list');
|
|
155
|
+
const choices = Array.isArray(presentation.choices) ? [...new Set(presentation.choices.filter((choice) => typeof choice === 'string'))] : [];
|
|
156
|
+
const selected = new Set();
|
|
157
|
+
const buttons = [];
|
|
158
|
+
for (const choice of choices) {
|
|
159
|
+
const button = element(document, 'button', 'choice-pill', choice);
|
|
160
|
+
button.type = 'button';
|
|
161
|
+
button.setAttribute('aria-pressed', 'false');
|
|
162
|
+
listen(button, 'click', () => {
|
|
163
|
+
if (presentation.multiple) {
|
|
164
|
+
if (selected.has(choice)) selected.delete(choice);
|
|
165
|
+
else selected.add(choice);
|
|
166
|
+
} else {
|
|
167
|
+
selected.clear();
|
|
168
|
+
selected.add(choice);
|
|
169
|
+
}
|
|
170
|
+
for (const item of buttons) {
|
|
171
|
+
const active = selected.has(item.choice);
|
|
172
|
+
item.button.classList.toggle('selected', active);
|
|
173
|
+
item.button.setAttribute('aria-pressed', String(active));
|
|
174
|
+
}
|
|
175
|
+
const values = choices.filter((item) => selected.has(item));
|
|
176
|
+
emit({ values, summary: values.join('; ') || 'No choices selected' });
|
|
177
|
+
});
|
|
178
|
+
buttons.push({ button, choice });
|
|
179
|
+
list.append(button);
|
|
180
|
+
}
|
|
181
|
+
root.append(list);
|
|
182
|
+
if (presentation.fallback) fallback();
|
|
183
|
+
} else {
|
|
184
|
+
fallback();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
container.append(root);
|
|
188
|
+
return () => {
|
|
189
|
+
if (!mounted) return;
|
|
190
|
+
mounted = false;
|
|
191
|
+
for (const dispose of disposers) dispose();
|
|
192
|
+
root.remove();
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function threadPath(id) {
|
|
2
|
+
return `/threads/${encodeURIComponent(String(id))}`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function threadIdFromPath(pathname) {
|
|
6
|
+
const encoded = String(pathname).match(/^\/threads\/([^/]+)$/)?.[1];
|
|
7
|
+
if (!encoded) return null;
|
|
8
|
+
try {
|
|
9
|
+
return decodeURIComponent(encoded);
|
|
10
|
+
} catch {
|
|
11
|
+
// A malformed percent escape is not a node ID. Let startup choose its normal
|
|
12
|
+
// fallback rather than crashing the whole browser application.
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&family=Newsreader:opsz,wght@6..72,500;6..72,600&display=swap');
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--ink: #1d2421;
|
|
5
|
+
--muted: #747a74;
|
|
6
|
+
--faint: #a3a69f;
|
|
7
|
+
--line: #deded7;
|
|
8
|
+
--soft-line: #e9e8e2;
|
|
9
|
+
--paper: #fbfaf7;
|
|
10
|
+
--canvas: #efeee8;
|
|
11
|
+
--accent: #315d49;
|
|
12
|
+
--accent-soft: #e4ede7;
|
|
13
|
+
--warm: #a7613f;
|
|
14
|
+
--shadow: 0 16px 42px rgba(40, 46, 42, .08);
|
|
15
|
+
}
|
|
16
|
+
* { box-sizing: border-box; }
|
|
17
|
+
html, body { height: 100%; }
|
|
18
|
+
body { margin: 0; color: var(--ink); background: var(--canvas); font: 14px/1.5 Manrope, system-ui, sans-serif; overflow: hidden; }
|
|
19
|
+
button, input, textarea { font: inherit; color: inherit; }
|
|
20
|
+
button { cursor: pointer; }
|
|
21
|
+
.app-shell { height: 100dvh; min-height: 100%; display: flex; flex-direction: column; }
|
|
22
|
+
.topbar { height: 66px; flex: none; display: grid; grid-template-columns: 276px 1fr auto; align-items: center; padding: 0 20px 0 24px; border-bottom: 1px solid #d6d5cf; background: rgba(247,246,241,.97); }
|
|
23
|
+
.brand { display: flex; align-items: center; gap: 11px; color: var(--ink); text-decoration: none; font: 600 22px Newsreader, serif; letter-spacing: -.3px; }
|
|
24
|
+
.brand-mark { width: 27px; height: 28px; position: relative; display: inline-flex; align-items: center; justify-content: center; }
|
|
25
|
+
.brand-mark i { position: absolute; width: 18px; height: 12px; border: 1.8px solid #27332e; border-radius: 50%; transform: rotate(58deg); background: transparent; }
|
|
26
|
+
.brand-mark i:nth-child(2) { transform: rotate(-58deg); }
|
|
27
|
+
.brand-mark i:nth-child(3) { transform: rotate(0); }
|
|
28
|
+
.topbar-center { justify-self: center; color: #777d77; font-size: 12px; letter-spacing: .15px; }
|
|
29
|
+
.live-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #6ca17c; box-shadow: 0 0 0 4px #e2ece4; margin: 0 9px 1px 0; }
|
|
30
|
+
.topbar-actions { display: flex; align-items: center; gap: 9px; }
|
|
31
|
+
.button, .icon-button, .filter-button { border: 0; border-radius: 8px; background: transparent; }
|
|
32
|
+
.button { min-height: 38px; padding: 0 15px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; font-size: 12px; font-weight: 600; }
|
|
33
|
+
.button-dark { color: white; background: #26312c; box-shadow: 0 2px 7px rgba(28,35,32,.18); }
|
|
34
|
+
.button-dark:hover { background: #16201c; }
|
|
35
|
+
.button-ghost { background: #f1f1ec; border: 1px solid var(--line); }
|
|
36
|
+
.icon-button { width: 37px; height: 37px; color: #6e746f; font-size: 21px; }
|
|
37
|
+
.icon-button:hover { background: #e9e9e3; }
|
|
38
|
+
.avatar { width: 34px; height: 34px; display: grid; place-items: center; margin-left: 3px; border-radius: 50%; color: white; background: #a66d52; font-size: 10px; font-weight: 700; letter-spacing: .5px; }
|
|
39
|
+
.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: 228px 340px minmax(0, 1fr); }
|
|
40
|
+
.sidebar { display: flex; flex-direction: column; min-height: 0; padding: 22px 13px 14px; background: #f3f2ed; border-right: 1px solid #dad9d2; }
|
|
41
|
+
.nav-label, .eyebrow { margin: 0 0 8px; color: #959890; font: 500 9px DM Mono, monospace; letter-spacing: 1.55px; }
|
|
42
|
+
.nav-label { padding: 0 11px; }
|
|
43
|
+
.nav-item { width: 100%; height: 39px; padding: 0 10px; border: 0; border-radius: 7px; display: grid; grid-template-columns: 22px 1fr auto; gap: 5px; align-items: center; text-align: left; background: transparent; color: #656b66; font-size: 12px; }
|
|
44
|
+
.nav-item:hover { background: #e9e9e3; }
|
|
45
|
+
.nav-item.active { color: #26312c; background: white; box-shadow: 0 1px 4px rgba(45,48,44,.09); font-weight: 600; }
|
|
46
|
+
.nav-icon { color: #858a84; font-size: 17px; text-align: center; }
|
|
47
|
+
.nav-item b { min-width: 18px; padding: 1px 5px; border-radius: 9px; color: #6d736e; background: #e5e6df; text-align: center; font-size: 9px; }
|
|
48
|
+
.nav-item.active b { color: white; background: #315d49; }
|
|
49
|
+
.sidebar-footer { display: grid; grid-template-columns: 27px 1fr 8px; align-items: center; gap: 7px; padding: 13px 10px 4px; margin-top: auto; border-top: 1px solid #dddcd5; }
|
|
50
|
+
.sidebar-footer strong, .sidebar-footer small { display: block; }
|
|
51
|
+
.sidebar-footer strong { font-size: 9px; font-weight: 600; }
|
|
52
|
+
.sidebar-footer small { color: #999d97; font-size: 8px; }
|
|
53
|
+
.persistence-icon { color: #758079; }
|
|
54
|
+
.status-light { width: 6px; height: 6px; border-radius: 50%; background: #64a277; }
|
|
55
|
+
.thread-index { min-height: 0; overflow: hidden; display: flex; flex-direction: column; background: #f8f7f3; border-right: 1px solid #d9d8d2; }
|
|
56
|
+
.index-heading { display: flex; justify-content: space-between; align-items: center; padding: 26px 22px 15px; }
|
|
57
|
+
.index-heading h1 { margin: 0; font: 600 24px/1.1 Newsreader, serif; letter-spacing: -.4px; }
|
|
58
|
+
.index-heading .eyebrow { margin-bottom: 5px; color: #748078; }
|
|
59
|
+
.filter-button { width: 31px; height: 31px; border: 1px solid var(--line); background: white; color: #7f847f; font-size: 18px; }
|
|
60
|
+
.search-box { display: flex; align-items: center; margin: 0 17px 13px; border: 1px solid #dfdfd8; border-radius: 7px; background: white; color: #92968f; }
|
|
61
|
+
.search-box span { font-size: 20px; padding-left: 10px; transform: rotate(-12deg); }
|
|
62
|
+
.search-box input { width: 100%; height: 34px; border: 0; outline: 0; background: transparent; padding: 0 10px 0 7px; font-size: 11px; }
|
|
63
|
+
.thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 9px 28px; }
|
|
64
|
+
.thread-card { width: 100%; display: block; padding: 15px 13px 14px; border: 1px solid transparent; border-bottom-color: #e6e5df; background: transparent; text-align: left; position: relative; }
|
|
65
|
+
.thread-card:hover { border-radius: 8px; background: #f1f0eb; }
|
|
66
|
+
.thread-card.active { border-color: #d8d8d1; border-radius: 8px; background: white; box-shadow: 0 3px 12px rgba(45,48,44,.06); }
|
|
67
|
+
.thread-card-top { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; }
|
|
68
|
+
.author-avatar { width: 23px; height: 23px; display: grid; place-items: center; border-radius: 6px; background: #dce6df; color: #315d49; font-size: 8px; font-weight: 700; }
|
|
69
|
+
.author-avatar.ivo { color: #70533f; background: #eee3da; }
|
|
70
|
+
.thread-author { font-size: 9px; font-weight: 600; }
|
|
71
|
+
.thread-role { color: #999d97; font-size: 8px; }
|
|
72
|
+
.thread-time { color: #a0a39e; font-size: 8px; margin-left: auto; }
|
|
73
|
+
.thread-card h2 { margin: 0 0 4px; font: 600 15px/1.25 Newsreader, serif; }
|
|
74
|
+
.thread-card p { margin: 0; color: #7d827c; font-size: 9.5px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
75
|
+
.thread-card-footer { display: flex; align-items: center; gap: 6px; margin-top: 11px; }
|
|
76
|
+
.tag { display: inline-flex; align-items: center; min-height: 19px; padding: 0 7px; border-radius: 4px; color: #68746d; background: #e9eee9; font: 500 8px DM Mono, monospace; letter-spacing: .25px; }
|
|
77
|
+
.tag.attention { color: #925337; background: #f2e4dc; }
|
|
78
|
+
.tag.team { color: #516378; background: #e5eaf0; }
|
|
79
|
+
.tag.deferred { color: #7a6844; background: #eee9db; }
|
|
80
|
+
.thread-card .arrow { margin-left: auto; color: #abb0aa; }
|
|
81
|
+
.empty-list { padding: 44px 25px; text-align: center; color: #8a8f89; font-size: 11px; }
|
|
82
|
+
.loading-card { height: 112px; margin: 8px; border-radius: 8px; background: linear-gradient(90deg,#efeee9,#f7f6f2,#efeee9); background-size: 200%; animation: shimmer 1.3s infinite; }
|
|
83
|
+
@keyframes shimmer { to { background-position: -200% 0; } }
|
|
84
|
+
.thread-room { min-width: 0; min-height: 0; overflow-y: auto; background: var(--paper); }
|
|
85
|
+
.thread-loading { height: 100%; display: grid; place-content: center; gap: 10px; color: #8d928c; font-size: 11px; text-align: center; }
|
|
86
|
+
.spinner { width: 25px; height: 25px; margin: auto; border: 2px solid #e0e1db; border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
|
87
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
88
|
+
.room-header { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; justify-content: space-between; min-height: 77px; padding: 15px 26px; border-bottom: 1px solid #e1e0da; background: rgba(251,250,247,.94); backdrop-filter: blur(12px); }
|
|
89
|
+
.room-title-row { display: flex; align-items: center; gap: 11px; }
|
|
90
|
+
.room-author { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; color: #eaf1ec; background: #52645d; font: 600 11px Newsreader, serif; }
|
|
91
|
+
.room-header h1 { margin: 0 0 3px; font: 600 20px/1.15 Newsreader, serif; letter-spacing: -.25px; }
|
|
92
|
+
.room-meta { color: #8f938e; font-size: 8.5px; }
|
|
93
|
+
.room-actions { display: flex; gap: 7px; }
|
|
94
|
+
.room-action { height: 31px; padding: 0 10px; border: 1px solid var(--line); border-radius: 6px; background: white; color: #656b66; font-size: 9px; font-weight: 600; }
|
|
95
|
+
.room-action.primary { border-color: #344f42; color: white; background: #344f42; }
|
|
96
|
+
.room-content { max-width: 1060px; margin: 0 auto; padding: 25px clamp(24px, 4.2vw, 58px) 80px; }
|
|
97
|
+
.thread-summary { margin: 0 0 22px; color: #707670; font-size: 11px; }
|
|
98
|
+
.conversation-label { display: flex; align-items: center; gap: 10px; margin: 23px 0 12px; color: #93978f; font: 500 8px DM Mono, monospace; letter-spacing: 1.2px; }
|
|
99
|
+
.conversation-label::after { content: ''; height: 1px; flex: 1; background: #e3e2dc; }
|
|
100
|
+
.question-block { margin-bottom: 18px; border: 1px solid #ddddd6; border-radius: 11px; background: #fff; box-shadow: 0 5px 20px rgba(40,46,42,.045); overflow: hidden; }
|
|
101
|
+
.question-heading { display: grid; grid-template-columns: 30px 1fr auto; gap: 10px; padding: 17px 19px 14px; align-items: start; }
|
|
102
|
+
.question-number { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 50%; color: #6c766f; background: #eef1ed; font: 500 9px DM Mono, monospace; }
|
|
103
|
+
.question-heading h2 { margin: 0 0 6px; font: 600 19px/1.25 Newsreader, serif; letter-spacing: -.2px; }
|
|
104
|
+
.question-context { margin: 0; max-width: 760px; color: #747a74; font-size: 10px; line-height: 1.55; }
|
|
105
|
+
.status-chip { min-height: 22px; display: inline-flex; align-items: center; padding: 0 8px; border-radius: 10px; font: 500 7.5px DM Mono, monospace; letter-spacing: .4px; white-space: nowrap; }
|
|
106
|
+
.status-chip.outstanding { color: #8a482e; background: #f3e5dd; }
|
|
107
|
+
.status-chip.answered { color: #326146; background: #e1eee5; }
|
|
108
|
+
.status-chip.waiting_on_team { color: #466079; background: #e4ebf2; }
|
|
109
|
+
.status-chip.deferred { color: #76603b; background: #eee8d9; }
|
|
110
|
+
.status-chip.rejected { color: #6c6261; background: #ebe7e6; }
|
|
111
|
+
.presentation { padding: 0 19px 17px 59px; }
|
|
112
|
+
.presentation-eyebrow { margin: 0 0 9px; color: #929790; font: 500 7.5px DM Mono, monospace; letter-spacing: 1px; }
|
|
113
|
+
.comparison-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 9px; }
|
|
114
|
+
.option-card { min-width: 0; padding: 0; overflow: hidden; border: 1px solid #deded7; border-radius: 8px; background: #f8f8f5; text-align: left; transition: border .15s, transform .15s, box-shadow .15s; }
|
|
115
|
+
.option-card:hover { transform: translateY(-1px); border-color: #a9b8af; box-shadow: 0 7px 18px rgba(42,52,46,.08); }
|
|
116
|
+
.option-card.selected { border: 2px solid #3d6954; box-shadow: 0 0 0 3px #e2ece6; }
|
|
117
|
+
.option-image { display: block; width: 100%; aspect-ratio: 1.39; object-fit: cover; background: #1d2928; }
|
|
118
|
+
.option-copy { display: block; min-height: 69px; padding: 10px 11px; }
|
|
119
|
+
.option-copy strong { display: block; margin-bottom: 3px; font: 600 12px Newsreader, serif; }
|
|
120
|
+
.option-copy small { display: block; color: #7f847e; font-size: 8px; line-height: 1.4; }
|
|
121
|
+
.choice-list { display: flex; flex-wrap: wrap; gap: 7px; }
|
|
122
|
+
.choice-pill { min-height: 30px; padding: 0 11px; border: 1px solid #d9dad3; border-radius: 6px; background: #f8f8f5; color: #646b65; font-size: 9px; }
|
|
123
|
+
.choice-pill.selected { border-color: #4a725e; color: #315d49; background: #e7efe9; box-shadow: 0 0 0 2px #e7efe9; }
|
|
124
|
+
.fallback-presentation { padding: 12px; border-radius: 7px; color: #717772; background: #f3f3ef; font-size: 9px; white-space: pre-wrap; }
|
|
125
|
+
.response-history { margin: 0 19px 17px 59px; padding: 13px 14px; border-left: 3px solid #72907e; border-radius: 0 7px 7px 0; background: #f1f5f2; }
|
|
126
|
+
.response-top { display: flex; align-items: center; gap: 7px; margin-bottom: 7px; }
|
|
127
|
+
.response-kind { color: #4c6456; font: 500 7.5px DM Mono, monospace; letter-spacing: .8px; text-transform: uppercase; }
|
|
128
|
+
.response-time { margin-left: auto; color: #999d97; font-size: 8px; }
|
|
129
|
+
.response-body { margin: 0; color: #38413c; font-size: 10px; line-height: 1.55; white-space: pre-wrap; }
|
|
130
|
+
.selection-summary { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; }
|
|
131
|
+
.selection-summary span { padding: 3px 7px; border-radius: 4px; color: #315d49; background: #dfeae3; font-size: 8px; font-weight: 600; }
|
|
132
|
+
.composer { margin: 0 19px 18px 59px; border: 1px solid #d8d9d2; border-radius: 8px; background: #fafaf7; overflow: hidden; }
|
|
133
|
+
.composer-modes { display: flex; gap: 3px; padding: 7px; border-bottom: 1px solid #e6e5df; background: #f3f3ef; }
|
|
134
|
+
.mode-button { height: 27px; padding: 0 9px; border: 0; border-radius: 5px; background: transparent; color: #777d77; font-size: 8px; font-weight: 600; }
|
|
135
|
+
.mode-button:hover { background: white; }
|
|
136
|
+
.mode-button.active { color: #315d49; background: white; box-shadow: 0 1px 4px rgba(40,46,42,.1); }
|
|
137
|
+
.composer textarea { display: block; width: 100%; min-height: 80px; padding: 12px 13px; resize: vertical; border: 0; outline: 0; background: white; font-size: 10px; line-height: 1.55; }
|
|
138
|
+
.composer-footer { min-height: 42px; display: flex; align-items: center; padding: 7px 9px; border-top: 1px solid #ecebe5; background: white; }
|
|
139
|
+
.draft-state { color: #9a9e98; font-size: 8px; }
|
|
140
|
+
.draft-state.unsaved::before { content: '•'; color: #b77955; margin-right: 4px; }
|
|
141
|
+
.submit-response { margin-left: auto; min-height: 29px; padding: 0 11px; border: 0; border-radius: 5px; color: white; background: #315d49; font-size: 8px; font-weight: 600; }
|
|
142
|
+
.submit-response:disabled { opacity: .55; cursor: progress; }
|
|
143
|
+
dialog { width: min(520px,calc(100vw - 30px)); padding: 0; border: 0; border-radius: 12px; background: transparent; box-shadow: 0 25px 90px rgba(25,30,27,.25); }
|
|
144
|
+
dialog::backdrop { background: rgba(25,30,27,.4); backdrop-filter: blur(3px); }
|
|
145
|
+
.dialog-card { padding: 24px; background: #fbfaf7; }
|
|
146
|
+
.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 7px; }
|
|
147
|
+
.dialog-heading h2 { margin: 0; font: 600 25px Newsreader, serif; }
|
|
148
|
+
.dialog-close { width: 31px; height: 31px; border: 0; border-radius: 6px; background: #efeee9; color: #777; font-size: 20px; }
|
|
149
|
+
.dialog-copy { margin: 0 0 19px; color: #7b817b; font-size: 10px; }
|
|
150
|
+
.dialog-card label { display: block; margin-top: 12px; color: #5d655f; font-size: 9px; font-weight: 600; }
|
|
151
|
+
.dialog-card input, .dialog-card textarea { width: 100%; margin-top: 5px; padding: 9px 10px; border: 1px solid #d8d8d2; border-radius: 6px; outline: 0; background: white; font-size: 10px; }
|
|
152
|
+
.dialog-card input:focus, .dialog-card textarea:focus { border-color: #779184; box-shadow: 0 0 0 3px #e8eee9; }
|
|
153
|
+
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
|
154
|
+
.toast { position: fixed; z-index: 30; left: 50%; bottom: 22px; transform: translate(-50%,18px); padding: 9px 14px; border-radius: 7px; color: white; background: #24302a; box-shadow: var(--shadow); font-size: 9px; opacity: 0; pointer-events: none; transition: .2s; }
|
|
155
|
+
.toast.show { opacity: 1; transform: translate(-50%,0); }
|
|
156
|
+
.toast.error { background: #793f35; }
|
|
157
|
+
.spike-label { margin-left: 10px; padding: 3px 5px; border: 1px solid #d9ded7; border-radius: 4px; color: #899388; font: 8px DM Mono, monospace; letter-spacing: .7px; }
|
|
158
|
+
.filter-button { display: grid; place-items: center; cursor: default; }
|
|
159
|
+
.thread-card p { font-size: 11px; }
|
|
160
|
+
.thread-role, .thread-time, .thread-author { font-size: 10px; }
|
|
161
|
+
.tag { font-size: 9px; }
|
|
162
|
+
.room-meta, .room-action { font-size: 11px; }
|
|
163
|
+
.thread-summary { font-size: 13px; }
|
|
164
|
+
.question-heading h2 { font-size: 22px; }
|
|
165
|
+
.question-context, .response-body, .composer textarea { font-size: 12px; }
|
|
166
|
+
.status-chip { font-size: 9px; }
|
|
167
|
+
.presentation-eyebrow, .response-kind, .response-time { font-size: 9px; }
|
|
168
|
+
.option-copy strong { font-size: 15px; }
|
|
169
|
+
.option-copy small { font-size: 11px; }
|
|
170
|
+
.choice-pill, .mode-button, .submit-response, .selection-summary span { font-size: 11px; }
|
|
171
|
+
.draft-state { font-size: 10px; }
|
|
172
|
+
.dialog-card label, .dialog-card input, .dialog-card textarea, .dialog-copy { font-size: 12px; }
|
|
173
|
+
.toast { font-size: 12px; }
|
|
174
|
+
.workspace { grid-template-columns: 205px 365px minmax(0,1fr); }
|
|
175
|
+
.outline-guide { margin: 26px 11px; color: #9a9f97; font-size: 10px; line-height: 1.7; }
|
|
176
|
+
.outline-guide strong { color: #7f8b81; font-size: 11px; font-weight: 600; }
|
|
177
|
+
.outline-guide p { margin: 5px 0; }
|
|
178
|
+
.outline-crumbs { padding: 0 22px 11px; display: flex; gap: 4px; flex-wrap: wrap; color: #afb3a9; font-size: 10px; }
|
|
179
|
+
.outline-crumbs button, .room-breadcrumbs button { padding: 0; border: 0; background: none; color: #7d8a7f; font-size: inherit; }
|
|
180
|
+
.outline-crumbs button:hover, .room-breadcrumbs button:hover { color: #315d49; text-decoration: underline; }
|
|
181
|
+
.outline-list { padding: 6px 8px 20px; overflow: auto; }
|
|
182
|
+
.outline-row { display: grid; grid-template-columns: 14px 22px minmax(160px,1fr) 23px; align-items: start; min-width: calc(280px + var(--depth,0)*13px); padding: 7px 7px 7px calc(6px + var(--depth,0)*13px); gap: 3px; border-radius: 6px; }
|
|
183
|
+
.outline-row:hover { background: #f0f0ea; }
|
|
184
|
+
.outline-row.selected { background: #e8eee7; }
|
|
185
|
+
.outline-toggle, .outline-bullet, .outline-add { border: 0; padding: 0; width: 20px; height: 22px; display: grid; place-items: center; background: none; }
|
|
186
|
+
.outline-toggle { color: #a5afa1; font-size: 17px; width: 14px; }
|
|
187
|
+
.outline-toggle.no-children { visibility: hidden; }
|
|
188
|
+
.outline-bullet { color: #637a67; font-weight: 700; font-size: 16px; border-radius: 50%; }
|
|
189
|
+
.outline-bullet:hover { background: #d4e0d3; }
|
|
190
|
+
.outline-title { display: block; width: 100%; padding: 1px 3px; border: 0; background: none; text-align: left; font-size: 12px; line-height: 1.4; }
|
|
191
|
+
.outline-title span { display: block; }
|
|
192
|
+
.outline-title small { display: block; margin-top: 5px; color: #94a28f; font: 8px DM Mono,monospace; letter-spacing: .6px; }
|
|
193
|
+
.outline-title small.outstanding { color: #a57554; }
|
|
194
|
+
.outline-title small.waiting_on_team { color: #7c91a8; }
|
|
195
|
+
.outline-title small.deferred { color: #aa9570; }
|
|
196
|
+
.outline-add { opacity: 0; color: #82957e; }
|
|
197
|
+
.outline-row:hover .outline-add, .outline-add:focus { opacity: 1; }
|
|
198
|
+
.outline-context { margin: 12px 8px 2px; color: #98a091; font: 8px/1.5 DM Mono,monospace; }
|
|
199
|
+
.outline-bottom { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid #e0e1d8; background: #f3f4ed; }
|
|
200
|
+
.outline-bottom .button { min-height: 28px; font-size: 10px; }
|
|
201
|
+
.outline-bottom span { color: #939e8c; font-size: 9px; }
|
|
202
|
+
.room-header { align-items: start; gap: 13px; }
|
|
203
|
+
.room-title-row { align-items: center; min-width: 0; }
|
|
204
|
+
.room-header h1 { font-size: 22px; overflow-wrap: anywhere; }
|
|
205
|
+
.room-actions { flex: none; padding-top: 7px; }
|
|
206
|
+
.room-breadcrumbs { display: flex; gap: 5px; flex-wrap: wrap; color: #aaa; font-size: 9px; margin-bottom: 5px; }
|
|
207
|
+
.node-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 13px; }
|
|
208
|
+
.node-context { color: #8c9887; font: 9px DM Mono,monospace; text-transform: uppercase; letter-spacing: .8px; }
|
|
209
|
+
.node-body { margin: 0 0 22px; color: #59645b; font-size: 14px; line-height: 1.75; white-space: pre-wrap; }
|
|
210
|
+
.node-canvas { margin: 16px 0 27px; }
|
|
211
|
+
.node-canvas:empty { display: none; }
|
|
212
|
+
.authored-frame { display: block; width: 100%; height: 360px; max-height: 520px; border: 1px solid #d8dfd3; border-radius: 10px; background: white; }
|
|
213
|
+
.node-canvas .fallback-presentation { margin-top: 12px; font-size: 11px; line-height: 1.6; }
|
|
214
|
+
.child-thread { padding: 14px 16px; border: 1px solid #dde3d6; border-radius: 8px; background: white; margin-bottom: 10px; }
|
|
215
|
+
.child-top { display: flex; align-items: center; gap: 7px; margin-bottom: 9px; flex-wrap: wrap; }
|
|
216
|
+
.child-title { padding: 0; border: 0; background: none; text-align: left; font: 600 19px/1.3 Newsreader,serif; color: #364831; }
|
|
217
|
+
.child-title span { font: 12px Manrope,sans-serif; color: #8b9d81; }
|
|
218
|
+
.child-body { font-size: 12px; line-height: 1.65; color: #65765d; white-space: pre-wrap; margin: 7px 0 10px; }
|
|
219
|
+
.child-artifact { color: #899b81; font: 8px DM Mono,monospace; }
|
|
220
|
+
.child-actions { display: flex; flex-wrap: wrap; gap: 11px; margin-top: 9px; }
|
|
221
|
+
.child-actions button { padding: 0; border: 0; background: none; color: #668f57; font-size: 10px; }
|
|
222
|
+
.child-actions button:hover { text-decoration: underline; }
|
|
223
|
+
.children-empty { color: #a2ad9a; font-size: 11px; }
|
|
224
|
+
.trusted-response { margin-top: 28px; padding-top: 15px; border-top: 1px solid #d4dfcd; }
|
|
225
|
+
.trusted-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 8px; }
|
|
226
|
+
.trusted-heading span { color: #6b8161; font: 9px DM Mono,monospace; letter-spacing: .7px; }
|
|
227
|
+
.trusted-heading small { color: #9baa90; font-size: 9px; }
|
|
228
|
+
.trusted-response .composer { margin: 0; }
|
|
229
|
+
.trusted-response .composer textarea { font-size: 13px; min-height: 90px; }
|
|
230
|
+
.proposal-summary { padding: 10px 13px; color: #607c53; background: #edf5e6; font-size: 11px; }
|
|
231
|
+
.proposal-summary:empty { display: none; }
|
|
232
|
+
.proposal-summary strong, .proposal-summary span, .proposal-summary small { display: block; }
|
|
233
|
+
.proposal-summary small { margin-top: 3px; font-size: 9px; color: #92a780; }
|
|
234
|
+
.node-footnote { color: #a2ad98; font-size: 9px; margin-top: 17px; }
|
|
235
|
+
.saved-values { margin: 9px 0; color: #5d8050; font-size: 11px; }
|
|
236
|
+
.saved-generation { display: block; width: 240px; max-width: 100%; margin: 9px 0; border-radius: 7px; }
|
|
237
|
+
.saved-values pre { max-height: 180px; overflow: auto; padding: 8px; border-radius: 5px; background: #e9f1e2; color: #7a936b; font: 10px DM Mono,monospace; white-space: pre-wrap; }
|
|
238
|
+
.authoring-details { margin-top: 15px; padding: 10px 12px; border: 1px solid #e1e4d9; border-radius: 7px; font-size: 11px; color: #91a17e; }
|
|
239
|
+
.authoring-details summary { cursor: pointer; }
|
|
240
|
+
.checkbox-label { display: flex !important; align-items: center; gap: 7px; }
|
|
241
|
+
.checkbox-label input { width: auto; margin: 0; }
|
|
242
|
+
.response-authoring { border: 0; border-top: 1px solid #ecebe5; border-radius: 0; margin-top: 0; }
|
|
243
|
+
.response-authoring label { display: block; margin-top: 9px; font-size: 10px; }
|
|
244
|
+
.response-authoring textarea { margin-top: 5px; border: 1px solid #dfe4d7; border-radius: 5px; font: 10px DM Mono,monospace; }
|
|
245
|
+
.live-dot.disconnected { background: #be9970; box-shadow: 0 0 0 4px #f0e7da; }
|
|
246
|
+
@media (max-width: 1079px) {
|
|
247
|
+
.workspace { grid-template-columns: 180px minmax(260px,305px) minmax(0,1fr); }
|
|
248
|
+
.comparison-grid { grid-template-columns: 1fr; }
|
|
249
|
+
.option-card { display: grid; grid-template-columns: 150px 1fr; }
|
|
250
|
+
.option-image { height: 100%; aspect-ratio: auto; }
|
|
251
|
+
}
|
|
252
|
+
@media (max-width: 780px) {
|
|
253
|
+
body { overflow: auto; }
|
|
254
|
+
.app-shell { height: auto; min-height: 100dvh; }
|
|
255
|
+
.topbar { grid-template-columns: 1fr auto; }
|
|
256
|
+
.topbar-center, .sidebar { display: none; }
|
|
257
|
+
.workspace { display: block; }
|
|
258
|
+
.thread-index { max-height: 320px; border-bottom: 1px solid var(--line); }
|
|
259
|
+
.thread-list { display: flex; overflow-x: auto; }
|
|
260
|
+
.thread-card { min-width: 280px; }
|
|
261
|
+
.thread-room { overflow: visible; }
|
|
262
|
+
.room-header { top: 0; }
|
|
263
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// HTTP transport over committed store state. Notifications wake readers; the durable
|
|
2
|
+
// event log / node snapshot, rather than the notification payload, is authoritative.
|
|
3
|
+
const HUMAN_RESPONSES = new Set(['answer', 'reject', 'clarification', 'defer']);
|
|
4
|
+
const HEARTBEAT_MS = 15_000;
|
|
5
|
+
|
|
6
|
+
export function streamEvents(request, response, store, { after = 0, threadId } = {}) {
|
|
7
|
+
// EventSource reconnect cursors take precedence over the original URL cursor.
|
|
8
|
+
let cursor = sequence(request.headers['last-event-id'] || after);
|
|
9
|
+
let closed = false;
|
|
10
|
+
let draining = false;
|
|
11
|
+
let blocked = false;
|
|
12
|
+
let heartbeat;
|
|
13
|
+
|
|
14
|
+
const cleanup = () => {
|
|
15
|
+
if (closed) return;
|
|
16
|
+
closed = true;
|
|
17
|
+
clearInterval(heartbeat);
|
|
18
|
+
store.changes.off('change', onChange);
|
|
19
|
+
response.off('drain', onDrain);
|
|
20
|
+
response.off('close', cleanup);
|
|
21
|
+
response.off('finish', cleanup);
|
|
22
|
+
response.off('error', cleanup);
|
|
23
|
+
request.off('aborted', cleanup);
|
|
24
|
+
};
|
|
25
|
+
const drain = () => {
|
|
26
|
+
if (closed || draining || blocked) return;
|
|
27
|
+
draining = true;
|
|
28
|
+
try {
|
|
29
|
+
// A page is bounded, not the replay: reconnects may need more than 100 events.
|
|
30
|
+
while (!closed && !blocked) {
|
|
31
|
+
const events = store.listEvents(cursor, threadId);
|
|
32
|
+
if (events.length === 0) break;
|
|
33
|
+
for (const event of events) {
|
|
34
|
+
const next = sequence(event.sequence);
|
|
35
|
+
if (next <= cursor) throw new Error('Event log must advance its sequence');
|
|
36
|
+
const writable = response.write(`id: ${next}\nevent: change\ndata: ${JSON.stringify(event)}\n\n`);
|
|
37
|
+
cursor = next; // write(false) still accepted this event; resume after it.
|
|
38
|
+
if (!writable) {
|
|
39
|
+
blocked = true;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
if (closed) break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
draining = false;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const onChange = () => {
|
|
50
|
+
try { drain(); } catch (error) { cleanup(); response.destroy(error); }
|
|
51
|
+
};
|
|
52
|
+
const onDrain = () => { blocked = false; onChange(); };
|
|
53
|
+
|
|
54
|
+
// Subscribe before replay, so a write cannot fall between replay and listening.
|
|
55
|
+
store.changes.on('change', onChange);
|
|
56
|
+
request.on('aborted', cleanup);
|
|
57
|
+
response.on('close', cleanup);
|
|
58
|
+
response.on('finish', cleanup);
|
|
59
|
+
response.on('error', cleanup);
|
|
60
|
+
response.on('drain', onDrain);
|
|
61
|
+
if (request.aborted || response.destroyed) { cleanup(); return; }
|
|
62
|
+
try {
|
|
63
|
+
response.writeHead(200, {
|
|
64
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
65
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
66
|
+
'Connection': 'keep-alive',
|
|
67
|
+
'X-Accel-Buffering': 'no'
|
|
68
|
+
});
|
|
69
|
+
response.flushHeaders();
|
|
70
|
+
drain();
|
|
71
|
+
heartbeat = setInterval(() => {
|
|
72
|
+
// Keepalive only: it neither polls the store nor accumulates a slow-client queue.
|
|
73
|
+
if (!closed && !blocked) blocked = !response.write(': keepalive\n\n');
|
|
74
|
+
}, HEARTBEAT_MS);
|
|
75
|
+
heartbeat.unref?.();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
cleanup();
|
|
78
|
+
response.destroy(error);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function waitForResponse(request, response, store, nodeId, { timeoutMs = 60_000, published } = {}) {
|
|
83
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw inputError('timeoutMs must be a non-negative number');
|
|
84
|
+
timeoutMs = Math.min(timeoutMs, 120_000);
|
|
85
|
+
let closed = false;
|
|
86
|
+
let timer;
|
|
87
|
+
const cleanup = () => {
|
|
88
|
+
if (closed) return;
|
|
89
|
+
closed = true;
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
store.changes.off('change', onChange);
|
|
92
|
+
request.off('aborted', cleanup);
|
|
93
|
+
response.off('close', cleanup);
|
|
94
|
+
response.off('finish', cleanup);
|
|
95
|
+
response.off('error', cleanup);
|
|
96
|
+
};
|
|
97
|
+
const finish = (reply, timedOut) => {
|
|
98
|
+
if (closed) return;
|
|
99
|
+
cleanup();
|
|
100
|
+
json(response, 200, {
|
|
101
|
+
published,
|
|
102
|
+
outcome: reply ? reply.response.kind : 'pending',
|
|
103
|
+
response: reply,
|
|
104
|
+
timedOut
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
const currentResponse = () => {
|
|
108
|
+
const { node, children } = store.getNode(nodeId);
|
|
109
|
+
const responses = children.filter((child) => child.response);
|
|
110
|
+
const latest = responses.at(-1);
|
|
111
|
+
// A team reply reopens the question. An earlier human turn is not an answer
|
|
112
|
+
// to this wait, including when an idempotent publish is retried after reopening.
|
|
113
|
+
if (node.status === 'outstanding' && responses.some((child) => child.response?.kind === 'team_reply')) return null;
|
|
114
|
+
return HUMAN_RESPONSES.has(latest?.response?.kind) ? latest : null;
|
|
115
|
+
};
|
|
116
|
+
const check = () => {
|
|
117
|
+
if (closed) return;
|
|
118
|
+
const reply = currentResponse();
|
|
119
|
+
if (reply) finish(reply, false);
|
|
120
|
+
};
|
|
121
|
+
const fail = (error) => {
|
|
122
|
+
if (closed) return;
|
|
123
|
+
cleanup();
|
|
124
|
+
const status = error.statusCode || 500;
|
|
125
|
+
json(response, status, { error: status === 500 ? 'Internal server error' : error.message, published });
|
|
126
|
+
};
|
|
127
|
+
const onChange = () => {
|
|
128
|
+
try { check(); } catch (error) { fail(error); }
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Request 'close' also fires for a normally consumed POST body. Only aborted
|
|
132
|
+
// requests and closed responses represent cancellation of this waiter.
|
|
133
|
+
store.changes.on('change', onChange);
|
|
134
|
+
request.on('aborted', cleanup);
|
|
135
|
+
response.on('close', cleanup);
|
|
136
|
+
response.on('finish', cleanup);
|
|
137
|
+
response.on('error', cleanup);
|
|
138
|
+
if (request.aborted || response.destroyed) { cleanup(); return; }
|
|
139
|
+
try {
|
|
140
|
+
check();
|
|
141
|
+
if (!closed) {
|
|
142
|
+
timer = setTimeout(() => {
|
|
143
|
+
// Recheck committed state at the deadline before issuing a pending receipt.
|
|
144
|
+
try { check(); if (!closed) finish(null, true); } catch (error) { fail(error); }
|
|
145
|
+
}, timeoutMs);
|
|
146
|
+
timer.unref?.();
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
cleanup();
|
|
150
|
+
throw error; // The host can handle a synchronous missing-node / input error.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sequence(value) {
|
|
155
|
+
const result = Number(value);
|
|
156
|
+
if (!Number.isSafeInteger(result) || result < 0) throw inputError('Event cursor must be a non-negative safe integer');
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function inputError(message) {
|
|
161
|
+
const error = new Error(message);
|
|
162
|
+
error.statusCode = 400;
|
|
163
|
+
return error;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function json(response, status, value) {
|
|
167
|
+
if (response.destroyed || response.writableEnded) return;
|
|
168
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
169
|
+
response.end(JSON.stringify(value));
|
|
170
|
+
}
|