@happyvertical/smrt-ingestion 0.57.20 → 0.57.22

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.
Files changed (115) hide show
  1. package/FEEDBACK.md +137 -0
  2. package/PROPOSALS.md +146 -5
  3. package/README.md +168 -2
  4. package/REVIEW.md +611 -7
  5. package/agents/execution.md +87 -0
  6. package/dist/chunks/{execution-NoDh3pjs.js → execution-BzVQax0n.js} +528 -42
  7. package/dist/chunks/execution-BzVQax0n.js.map +1 -0
  8. package/dist/chunks/{feedback-CJB900iG.js → feedback-D3UTF8fm.js} +175 -36
  9. package/dist/chunks/feedback-D3UTF8fm.js.map +1 -0
  10. package/dist/chunks/{proposal-errors-VxcTU6Db.js → proposal-contracts-77Uo1B1j.js} +38 -44
  11. package/dist/chunks/proposal-contracts-77Uo1B1j.js.map +1 -0
  12. package/dist/chunks/reviewer-instruction-CEc3AOuU.js +33 -0
  13. package/dist/chunks/reviewer-instruction-CEc3AOuU.js.map +1 -0
  14. package/dist/dto.d.ts +75 -0
  15. package/dist/dto.d.ts.map +1 -1
  16. package/dist/execution-contracts.d.ts +4 -1
  17. package/dist/execution-contracts.d.ts.map +1 -1
  18. package/dist/execution-dto.d.ts +16 -0
  19. package/dist/execution-dto.d.ts.map +1 -1
  20. package/dist/execution-internal.d.ts +2 -0
  21. package/dist/execution-internal.d.ts.map +1 -1
  22. package/dist/execution.d.ts +101 -4
  23. package/dist/execution.d.ts.map +1 -1
  24. package/dist/execution.js +2 -2
  25. package/dist/feedback-dto.d.ts +18 -4
  26. package/dist/feedback-dto.d.ts.map +1 -1
  27. package/dist/feedback-internal.d.ts +9 -0
  28. package/dist/feedback-internal.d.ts.map +1 -1
  29. package/dist/feedback.d.ts +10 -0
  30. package/dist/feedback.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/manifest.json +339 -3
  33. package/dist/models.d.ts +15 -0
  34. package/dist/models.d.ts.map +1 -1
  35. package/dist/models.js +82 -2
  36. package/dist/models.js.map +1 -1
  37. package/dist/policy.d.ts +3 -0
  38. package/dist/policy.d.ts.map +1 -1
  39. package/dist/presented-filename.d.ts +11 -0
  40. package/dist/presented-filename.d.ts.map +1 -0
  41. package/dist/proposal-contracts.d.ts +31 -6
  42. package/dist/proposal-contracts.d.ts.map +1 -1
  43. package/dist/proposal-dto.d.ts +17 -0
  44. package/dist/proposal-dto.d.ts.map +1 -1
  45. package/dist/proposal-errors.d.ts +20 -0
  46. package/dist/proposal-errors.d.ts.map +1 -1
  47. package/dist/proposal-validation.d.ts +26 -0
  48. package/dist/proposal-validation.d.ts.map +1 -1
  49. package/dist/proposals.d.ts +6 -1
  50. package/dist/proposals.d.ts.map +1 -1
  51. package/dist/proposals.js +268 -71
  52. package/dist/proposals.js.map +1 -1
  53. package/dist/redaction.d.ts +9 -0
  54. package/dist/redaction.d.ts.map +1 -0
  55. package/dist/review-dto.d.ts +108 -5
  56. package/dist/review-dto.d.ts.map +1 -1
  57. package/dist/review-host.d.ts +52 -3
  58. package/dist/review-host.d.ts.map +1 -1
  59. package/dist/review-instruction-view.d.ts +12 -0
  60. package/dist/review-instruction-view.d.ts.map +1 -0
  61. package/dist/review-summary.d.ts.map +1 -1
  62. package/dist/reviewer-instruction.d.ts +20 -0
  63. package/dist/reviewer-instruction.d.ts.map +1 -0
  64. package/dist/server.d.ts +252 -13
  65. package/dist/server.d.ts.map +1 -1
  66. package/dist/server.js +1190 -85
  67. package/dist/server.js.map +1 -1
  68. package/dist/smrt-knowledge.json +137 -23
  69. package/dist/sources/common.d.ts +11 -0
  70. package/dist/sources/common.d.ts.map +1 -1
  71. package/dist/sources/email.d.ts.map +1 -1
  72. package/dist/sources/http.d.ts.map +1 -1
  73. package/dist/sources/watch-folder.d.ts.map +1 -1
  74. package/dist/svelte/components/ActionReview.svelte +54 -55
  75. package/dist/svelte/components/ActionReview.svelte.d.ts +8 -1
  76. package/dist/svelte/components/ActionReview.svelte.d.ts.map +1 -1
  77. package/dist/svelte/components/CandidatePicker.svelte +72 -0
  78. package/dist/svelte/components/CandidatePicker.svelte.d.ts +15 -0
  79. package/dist/svelte/components/CandidatePicker.svelte.d.ts.map +1 -0
  80. package/dist/svelte/components/EvidenceViewer.svelte +60 -4
  81. package/dist/svelte/components/EvidenceViewer.svelte.d.ts +12 -1
  82. package/dist/svelte/components/EvidenceViewer.svelte.d.ts.map +1 -1
  83. package/dist/svelte/components/InboxView.svelte +172 -19
  84. package/dist/svelte/components/InboxView.svelte.d.ts +36 -2
  85. package/dist/svelte/components/InboxView.svelte.d.ts.map +1 -1
  86. package/dist/svelte/components/IntakeInbox.svelte +6 -3
  87. package/dist/svelte/components/IntakeInbox.svelte.d.ts +4 -1
  88. package/dist/svelte/components/IntakeInbox.svelte.d.ts.map +1 -1
  89. package/dist/svelte/components/IntakeReview.svelte +4 -2
  90. package/dist/svelte/components/IntakeReview.svelte.d.ts +2 -0
  91. package/dist/svelte/components/IntakeReview.svelte.d.ts.map +1 -1
  92. package/dist/svelte/components/IntakeWorkspace.svelte +27 -0
  93. package/dist/svelte/components/IntakeWorkspace.svelte.d.ts +19 -0
  94. package/dist/svelte/components/IntakeWorkspace.svelte.d.ts.map +1 -0
  95. package/dist/svelte/components/ReviewView.svelte +653 -71
  96. package/dist/svelte/components/ReviewView.svelte.d.ts +20 -1
  97. package/dist/svelte/components/ReviewView.svelte.d.ts.map +1 -1
  98. package/dist/svelte/components/TriageView.svelte +669 -0
  99. package/dist/svelte/components/TriageView.svelte.d.ts +16 -0
  100. package/dist/svelte/components/TriageView.svelte.d.ts.map +1 -0
  101. package/dist/svelte/i18n.d.ts +56 -0
  102. package/dist/svelte/i18n.d.ts.map +1 -1
  103. package/dist/svelte/i18n.js +56 -0
  104. package/dist/svelte/index.d.ts +2 -1
  105. package/dist/svelte/index.d.ts.map +1 -1
  106. package/dist/svelte/index.js +1 -0
  107. package/dist/svelte/view-utils.d.ts +67 -0
  108. package/dist/svelte/view-utils.d.ts.map +1 -1
  109. package/dist/svelte/view-utils.js +157 -0
  110. package/package.json +13 -13
  111. package/dist/chunks/execution-NoDh3pjs.js.map +0 -1
  112. package/dist/chunks/execution-internal-DWteDHM2.js +0 -7
  113. package/dist/chunks/execution-internal-DWteDHM2.js.map +0 -1
  114. package/dist/chunks/feedback-CJB900iG.js.map +0 -1
  115. package/dist/chunks/proposal-errors-VxcTU6Db.js.map +0 -1
@@ -0,0 +1,669 @@
1
+ <script lang="ts">
2
+ import { useI18n } from '@happyvertical/smrt-ui/i18n';
3
+ import { Button } from '@happyvertical/smrt-ui/ui';
4
+ import { onDestroy, type Snippet, tick, untrack } from 'svelte';
5
+ import type {
6
+ InboxEntry,
7
+ IntakeReviewHost,
8
+ ItemReviewView,
9
+ } from '../../review-dto.js';
10
+ import { M } from '../i18n.js';
11
+ import {
12
+ entryStatus,
13
+ reviewInFlight,
14
+ type TriageStatus,
15
+ triageSettled,
16
+ triageTerminal,
17
+ viewStatus,
18
+ } from '../view-utils.js';
19
+ import type { ReviewOutcome } from './ActionReview.svelte';
20
+ import InboxView from './InboxView.svelte';
21
+ import ReviewView from './ReviewView.svelte';
22
+ export interface Props {
23
+ host: IntakeReviewHost;
24
+ /** Item to open first; afterwards the workspace owns the selection. */
25
+ itemId?: string;
26
+ /** Reload interval while an item's background stage is in flight. */
27
+ pollMs?: number;
28
+ /** Told about every selection change, e.g. to keep a URL in step; never navigates. */
29
+ onselect?: (itemId: string | undefined) => void;
30
+ rowAside?: Snippet<[InboxEntry]>;
31
+ }
32
+ let { host, itemId, pollMs = 2000, onselect, rowAside }: Props = $props();
33
+ const { t } = useI18n();
34
+ /**
35
+ * Most followed items read per watch tick. Followed items are read in
36
+ * rotation, so every followed row is reached; an item stops being followed
37
+ * when it settles, is denied, fails three reads in a row, leaves the
38
+ * displayed list, or is the longest-followed item beyond WATCH_CAP.
39
+ */
40
+ const WATCH_LIMIT = 5;
41
+ /**
42
+ * Most items followed at once. Beyond it the longest-followed item stops
43
+ * following and its row returns to the entry its page listed.
44
+ */
45
+ const WATCH_CAP = 20;
46
+ /** Most page appends one automatic advance makes while looking for an open row. */
47
+ const ADVANCE_PAGES = 5;
48
+ /** Consecutive thrown watch reads tolerated before a row stops following. */
49
+ const MAX_WATCH_FAILURES = 3;
50
+ let selected = $state<string | undefined>(untrack(() => itemId)),
51
+ rows = $state.raw<InboxEntry[]>([]),
52
+ more = $state(false),
53
+ done = $state(false),
54
+ announcement = $state(''),
55
+ // A selection the reviewer asked for while the pane held unsent input, and
56
+ // the control they were in, which "Stay" returns to.
57
+ leaving = $state<{ id: string; from?: HTMLElement }>(),
58
+ // The open item finished but did not advance: `unsent` input remains on
59
+ // it, the next open item was not reached (`retry`: Next still works), or
60
+ // a page append failed and the list failed closed (`failed`).
61
+ held = $state<'unsent' | 'retry' | 'failed'>(),
62
+ // Richer status from a loaded view, valid while the row still shows the
63
+ // same entry (revision, state, processing, disposition) it came from.
64
+ statuses = $state<Record<string, { key: string; status: TriageStatus }>>({});
65
+ let inbox = $state<ReturnType<typeof InboxView>>();
66
+ let pane = $state<HTMLElement>();
67
+ let root = $state<HTMLElement>();
68
+ let alive = true,
69
+ // The selected item's latest available view, as its pane displayed it.
70
+ paneView: ItemReviewView | undefined,
71
+ // The pane is between clearing a displayed view and showing the next one.
72
+ paneBusy = false,
73
+ // The pane holds reviewer input that has not been sent.
74
+ paneDirty = false,
75
+ // Bumped on every selection change; a navigation that awaited a page
76
+ // append gives up if the reviewer chose something meanwhile.
77
+ navigation = 0,
78
+ // Followed items in read-rotation order, and in the order they started.
79
+ watched: string[] = [],
80
+ started: string[] = [],
81
+ watchTimer: ReturnType<typeof setTimeout> | undefined,
82
+ watchTicks = 0,
83
+ // Only one watch tick runs at a time.
84
+ following = false;
85
+ let stay = $state<HTMLElement>();
86
+ // The last form field the reviewer used in the item pane.
87
+ let lastField: HTMLElement | undefined;
88
+ function noteField(event: FocusEvent) {
89
+ const target = event.target;
90
+ if (
91
+ target instanceof HTMLElement &&
92
+ target.matches('input,textarea,select,[contenteditable]')
93
+ )
94
+ lastField = target;
95
+ }
96
+ const failures = new Map<string, number>();
97
+ // Followed items seen with a stage in flight; only those announce settling.
98
+ const staged = new Set<string>();
99
+ onDestroy(() => {
100
+ alive = false;
101
+ clearTimeout(watchTimer);
102
+ watched = [];
103
+ });
104
+ const position = $derived(
105
+ selected ? rows.findIndex((row) => row.item.id === selected) : -1,
106
+ );
107
+ const label = $derived(position >= 0 ? rows[position].label : undefined);
108
+ function fingerprint(entry: InboxEntry) {
109
+ return [
110
+ entry.item.analysisRevision,
111
+ entry.state,
112
+ entry.item.processingState,
113
+ entry.item.disposition?.at ?? '',
114
+ ].join(':');
115
+ }
116
+ function statusOf(entry: InboxEntry): TriageStatus {
117
+ const cached = statuses[entry.item.id];
118
+ return cached && cached.key === fingerprint(entry)
119
+ ? cached.status
120
+ : entryStatus(entry);
121
+ }
122
+ function statusText(status: TriageStatus) {
123
+ const base =
124
+ status.kind === 'state'
125
+ ? t(M[`ingestion.${status.state}`])
126
+ : status.kind === 'resolved'
127
+ ? t(M['ingestion.resolved'])
128
+ : t(M[`ingestion.triage.${status.kind}`]);
129
+ const reason =
130
+ status.kind === 'needs_review' && status.missing?.length
131
+ ? t(M['ingestion.triage.missing'], { fields: status.missing.join(', ') })
132
+ : 'reason' in status
133
+ ? status.reason
134
+ : undefined;
135
+ return reason
136
+ ? t(M['ingestion.statusReason'], { status: base, reason })
137
+ : base;
138
+ }
139
+ const rowStatus = (entry: InboxEntry) => statusText(statusOf(entry));
140
+ const displayed = (id: string) => rows.some((row) => row.item.id === id);
141
+ /**
142
+ * Read sequence: every read (a page, a pane view, a follow read) takes the
143
+ * next number when its request STARTS, and every entry held for a row keeps
144
+ * the number of the read that produced it, so a newer read always wins over
145
+ * an older one, whatever its source.
146
+ */
147
+ let sequence = 0;
148
+ const stamp = () => ++sequence;
149
+ type Held = { entry: InboxEntry; read: number };
150
+ // Each row's entry exactly as a page read returned it (never a patched
151
+ // entry): the state a row falls back to.
152
+ let listedEntries = new Map<string, Held>();
153
+ // The newest entry known for each row, from any read.
154
+ let newest = new Map<string, Held>();
155
+ // Per-item follow generation, bumped whenever a follow stops or starts: a
156
+ // follow read answering for an earlier generation is dropped.
157
+ const generation = new Map<string, number>();
158
+ // The read that produced the pane view.
159
+ let paneRead = 0;
160
+ const bump = (id: string) => generation.set(id, (generation.get(id) ?? 0) + 1);
161
+ let listEpoch = 0;
162
+ function readStarted() {
163
+ listEpoch++;
164
+ }
165
+ /**
166
+ * Extra settled reads still owed to a reviewer operation the pane had not
167
+ * answered when the item was left (an Apply): the item keeps following while
168
+ * a read still shows an action authorized or executing, at most
169
+ * OPERATION_READS more reads. The debt belongs to the operation, so it
170
+ * survives reopening and leaving the item again, until a view shows the
171
+ * operation answered or the reads run out. Leaving mid-operation again (a
172
+ * new operation the pane has not answered) resets the debt to the full
173
+ * OPERATION_READS, whatever was left of the earlier one.
174
+ */
175
+ const OPERATION_READS = 3;
176
+ const operationReads = new Map<string, number>();
177
+ const operationPending = (view: ItemReviewView) =>
178
+ view.reviews.actions.some((action) =>
179
+ ['authorized', 'executing'].includes(action.review.state),
180
+ );
181
+
182
+ // Row bookkeeping, one function per lifecycle event. Each keeps `statuses`,
183
+ // `newest`, the follow sets (`watched`, `started`, `staged`, `failures`,
184
+ // `generation`, `operationReads`), the announcement and the row together.
185
+
186
+ /** Stop following an item; what its row shows is left to the caller. */
187
+ function stopFollowing(id: string) {
188
+ watched = watched.filter((entry) => entry !== id);
189
+ started = started.filter((entry) => entry !== id);
190
+ failures.delete(id);
191
+ staged.delete(id);
192
+ bump(id);
193
+ }
194
+ /**
195
+ * Forget everything loaded views said about an item: it is no longer
196
+ * followed, has no cached status or owed operation reads, and (`restore`)
197
+ * its row returns to the entry exactly as its page read returned it.
198
+ */
199
+ function forget(id: string, restore: boolean) {
200
+ stopFollowing(id);
201
+ operationReads.delete(id);
202
+ delete statuses[id];
203
+ newest.delete(id);
204
+ const listed = listedEntries.get(id);
205
+ if (restore && listed && inbox) {
206
+ newest.set(id, listed);
207
+ rows = inbox.patch(listed.entry, true);
208
+ }
209
+ }
210
+ /**
211
+ * Show a read's entry on its row unless a newer read already did; a row the
212
+ * filter removes is forgotten. Returns whether the entry was applied.
213
+ */
214
+ function show(entry: InboxEntry, read: number, keep: boolean) {
215
+ const id = entry.item.id;
216
+ if (read < (newest.get(id)?.read ?? 0) || !inbox) return false;
217
+ newest.set(id, { entry, read });
218
+ rows = inbox.patch(entry, keep);
219
+ if (!displayed(id)) rowRemoved(id);
220
+ return true;
221
+ }
222
+ /** Cache a view's status (valid only while the row shows its entry) and show it. */
223
+ function showView(view: ItemReviewView, read: number, keep: boolean) {
224
+ const id = view.entry.item.id;
225
+ if (read < (newest.get(id)?.read ?? 0)) return;
226
+ statuses[id] = { key: fingerprint(view.entry), status: viewStatus(view) };
227
+ show(view.entry, read, keep);
228
+ }
229
+ /** Event: a row left the displayed list (filter, refresh, failed list). */
230
+ function rowRemoved(id: string) {
231
+ forget(id, false);
232
+ }
233
+ /**
234
+ * Event: an item opens in the pane. It is no longer followed and its row
235
+ * shows its entry's own state until the pane's first view; a debt of
236
+ * operation reads stays with the operation.
237
+ */
238
+ function opened(id: string) {
239
+ stopFollowing(id);
240
+ delete statuses[id];
241
+ }
242
+ /**
243
+ * Event: the reviewer left an item. Its row takes the listed filter; an item
244
+ * left mid-stage, before the pane showed the result of its latest operation,
245
+ * or with operation reads still owed, keeps following while its row is
246
+ * displayed.
247
+ */
248
+ function left(id: string, view: ItemReviewView | undefined, busy: boolean) {
249
+ if (view) show(view.entry, paneRead, false);
250
+ if (!displayed(id)) {
251
+ rowRemoved(id);
252
+ return;
253
+ }
254
+ const running = reviewInFlight(view);
255
+ // A new unanswered operation owes its own full set of reads.
256
+ if (busy) operationReads.set(id, OPERATION_READS);
257
+ if (!busy && !running && !operationReads.has(id)) return;
258
+ if (watched.includes(id)) return;
259
+ bump(id);
260
+ if (running) staged.add(id);
261
+ watched = [...watched, id];
262
+ started = [...started, id];
263
+ // Beyond the cap the longest-followed item (the earliest started, whatever
264
+ // the read rotation) stops and returns to its listed entry.
265
+ while (started.length > WATCH_CAP) forget(started[0], true);
266
+ // The backoff carries on; only an idle follower starts a tick (a running
267
+ // tick, or one already scheduled, picks the new item up).
268
+ if (!following && watchTimer === undefined) schedule();
269
+ }
270
+ /** Event: the open pane, or a follow read, was denied (returned or thrown). */
271
+ function denied(id: string) {
272
+ if (id === selected) {
273
+ paneView = undefined;
274
+ paneBusy = false;
275
+ }
276
+ forget(id, true);
277
+ announcement = '';
278
+ }
279
+ /** Event: a current-generation follow read answered for a followed item. */
280
+ function followed(view: ItemReviewView, read: number) {
281
+ const id = view.entry.item.id;
282
+ failures.delete(id);
283
+ // A read older than what the row already shows (a page listed it after
284
+ // this read started) decides nothing: no stop, no status, no announcement.
285
+ // Following continues and a newer read settles it.
286
+ if (read < (newest.get(id)?.read ?? 0)) return;
287
+ const running = reviewInFlight(view);
288
+ if (running) staged.add(id);
289
+ // A reviewer operation the pane had not answered yet (left mid-Apply) is
290
+ // waited for while an action is still authorized or executing.
291
+ const owed = operationReads.get(id) ?? 0;
292
+ const waiting = owed > 0 && operationPending(view);
293
+ if (waiting) operationReads.set(id, owed - 1);
294
+ else operationReads.delete(id);
295
+ const finished = !running && !waiting;
296
+ const announce = finished && staged.has(id);
297
+ if (finished) stopFollowing(id);
298
+ showView(view, read, false);
299
+ // Announce only a background stage that was seen running and has now
300
+ // settled, on a row still displayed, under its listed label.
301
+ if (announce && displayed(id))
302
+ announcement = t(M['ingestion.backgroundUpdate'], {
303
+ label: rows.find((row) => row.item.id === id)?.label ?? view.entry.label,
304
+ status: statusText(viewStatus(view)),
305
+ });
306
+ }
307
+ /** Event: a follow read threw; three in a row stop following. */
308
+ function followFailed(id: string) {
309
+ const count = (failures.get(id) ?? 0) + 1;
310
+ failures.set(id, count);
311
+ if (count >= MAX_WATCH_FAILURES) forget(id, true);
312
+ }
313
+ /** Event: a page read landed (a refresh replaces, Load more appends). */
314
+ function pageLanded(page: {
315
+ entries: InboxEntry[];
316
+ appended: boolean;
317
+ issued?: number;
318
+ }) {
319
+ const read = page.issued ?? 0;
320
+ if (!page.appended) {
321
+ listedEntries = new Map();
322
+ announcement = '';
323
+ }
324
+ for (const entry of page.entries)
325
+ listedEntries.set(entry.item.id, { entry, read });
326
+ if (!inbox) return;
327
+ // Each listed row shows whichever is newer: this page's entry, or an entry
328
+ // a later-started read (the pane, a follow read) already produced.
329
+ for (const entry of page.entries) {
330
+ const id = entry.item.id;
331
+ const known = newest.get(id);
332
+ if (known && known.read > read) {
333
+ if (displayed(id))
334
+ rows = inbox.patch(known.entry, id === paneView?.entry.item.id);
335
+ } else {
336
+ newest.set(id, { entry, read });
337
+ delete statuses[id];
338
+ }
339
+ }
340
+ for (const id of [...newest.keys()]) if (!displayed(id)) newest.delete(id);
341
+ for (const id of [...watched]) if (!displayed(id)) rowRemoved(id);
342
+ }
343
+ /** Event: a page read failed and the list failed closed (no rows). */
344
+ function listFailed() {
345
+ for (const id of [...watched]) forget(id, false);
346
+ newest = new Map();
347
+ operationReads.clear();
348
+ announcement = '';
349
+ }
350
+ function listed(
351
+ items: InboxEntry[],
352
+ hasMore: boolean,
353
+ page?: { entries: InboxEntry[]; appended: boolean; issued?: number },
354
+ ) {
355
+ rows = items;
356
+ more = hasMore;
357
+ if (page) pageLanded(page);
358
+ else listFailed();
359
+ }
360
+ function paneChanged(
361
+ view: ItemReviewView | undefined,
362
+ refused = false,
363
+ issued = 0,
364
+ ) {
365
+ if (!view) {
366
+ // A failed callback (or a first load that failed) cleared the pane: it
367
+ // fails closed, so nothing it showed stays on the row or is followed.
368
+ if (refused) {
369
+ if (selected) denied(selected);
370
+ return;
371
+ }
372
+ // The pane cleared its view for a reload or an operation (an instruction,
373
+ // say) whose result it has not shown yet.
374
+ paneBusy = true;
375
+ return;
376
+ }
377
+ if (view.entry.item.id !== selected) return;
378
+ paneBusy = false;
379
+ const snapshot = $state.snapshot(view) as ItemReviewView;
380
+ if (snapshot.availability !== 'available') {
381
+ denied(snapshot.entry.item.id);
382
+ return;
383
+ }
384
+ paneView = snapshot;
385
+ paneRead = issued;
386
+ // The pane itself shows whether an operation left owed reads has answered.
387
+ if (!operationPending(snapshot))
388
+ operationReads.delete(snapshot.entry.item.id);
389
+ // The open item stays listed while the reviewer works on it, even if its
390
+ // state no longer matches the filter; it leaves when they move on.
391
+ showView(snapshot, issued, true);
392
+ }
393
+ function schedule() {
394
+ clearTimeout(watchTimer);
395
+ watchTimer = undefined;
396
+ if (!alive || !watched.length) {
397
+ watchTicks = 0;
398
+ return;
399
+ }
400
+ const delay = Math.min(pollMs * 1.5 ** watchTicks, pollMs * 5);
401
+ watchTicks++;
402
+ watchTimer = setTimeout(() => {
403
+ watchTimer = undefined;
404
+ void follow();
405
+ }, delay);
406
+ }
407
+ /**
408
+ * One watch tick: re-read up to WATCH_LIMIT followed items through the same
409
+ * authenticated `load` the item pane uses (first review page only), one
410
+ * after another, least recently read first, on the pane's backoff.
411
+ */
412
+ async function follow() {
413
+ if (following || !alive) return;
414
+ following = true;
415
+ try {
416
+ await followBatch();
417
+ } finally {
418
+ following = false;
419
+ }
420
+ schedule();
421
+ }
422
+ async function followBatch() {
423
+ const batch = watched.slice(0, WATCH_LIMIT);
424
+ // Rotate: the rest are read first next tick.
425
+ watched = [...watched.slice(WATCH_LIMIT), ...batch];
426
+ for (const id of batch) {
427
+ if (!alive) return;
428
+ if (!watched.includes(id)) continue;
429
+ // An answer is used only by the follow that asked for it.
430
+ const asked = generation.get(id) ?? 0;
431
+ const current = () =>
432
+ watched.includes(id) && (generation.get(id) ?? 0) === asked;
433
+ const read = stamp();
434
+ try {
435
+ const requestedAt = listEpoch;
436
+ const view = await host.load(id);
437
+ if (!alive) return;
438
+ if (!current()) continue;
439
+ if (view.availability !== 'available') denied(id);
440
+ else if (requestedAt === listEpoch) followed(view, read);
441
+ } catch {
442
+ if (!alive) return;
443
+ if (current()) followFailed(id);
444
+ }
445
+ }
446
+ }
447
+ /** Open an item in the pane; the previous one keeps following its stage if in flight. */
448
+ async function choose(id: string | undefined, focus: boolean) {
449
+ if (id !== selected) {
450
+ const previous = paneView;
451
+ const leaving_ = selected;
452
+ const busy = paneBusy;
453
+ paneView = undefined;
454
+ paneBusy = false;
455
+ if (leaving_)
456
+ left(
457
+ leaving_,
458
+ previous?.entry.item.id === leaving_ ? previous : undefined,
459
+ busy,
460
+ );
461
+ if (id) opened(id);
462
+ selected = id;
463
+ navigation++;
464
+ paneDirty = false;
465
+ leaving = undefined;
466
+ held = undefined;
467
+ onselect?.(id);
468
+ }
469
+ done = !id;
470
+ if (focus) {
471
+ await tick();
472
+ pane?.focus();
473
+ }
474
+ }
475
+ /**
476
+ * A selection the reviewer asked for (row, keyboard, buttons). Unsent input
477
+ * in the pane would be lost with it, so that asks first, inline.
478
+ */
479
+ async function request(id: string) {
480
+ if (id === selected) return;
481
+ if (paneDirty) {
482
+ const from = lastField?.isConnected ? lastField : undefined;
483
+ leaving = { id, ...(from ? { from } : {}) };
484
+ await tick();
485
+ stay
486
+ ?.querySelector<HTMLElement>('[data-stay] button, button[data-stay]')
487
+ ?.focus();
488
+ return;
489
+ }
490
+ await choose(id, true);
491
+ }
492
+ async function discardAndLeave() {
493
+ const target = leaving?.id;
494
+ leaving = undefined;
495
+ if (target) await choose(target, true);
496
+ }
497
+ async function keepEditing() {
498
+ const from = leaving?.from;
499
+ leaving = undefined;
500
+ await tick();
501
+ // Back to the control the reviewer was in, when it is still there.
502
+ if (from?.isConnected) from.focus();
503
+ else pane?.focus();
504
+ }
505
+ /**
506
+ * Where to continue after `current` in a list that may have changed while a
507
+ * page loaded: just after it when it is still listed; otherwise at the first
508
+ * row that followed it before and is still listed; otherwise at the first
509
+ * row that is new; otherwise at the end. Positions are found by id, never by
510
+ * an index taken before the change.
511
+ */
512
+ function resume(
513
+ before: readonly InboxEntry[],
514
+ current: string | undefined,
515
+ list: readonly InboxEntry[],
516
+ ) {
517
+ const ids = list.map((row) => row.item.id);
518
+ const at = current ? ids.indexOf(current) : -1;
519
+ if (at >= 0) return at + 1;
520
+ const old = before.map((row) => row.item.id);
521
+ const from = current ? old.indexOf(current) : -1;
522
+ for (const id of old.slice(from + 1)) {
523
+ const index = ids.indexOf(id);
524
+ if (index >= 0) return index;
525
+ }
526
+ const known = new Set(old);
527
+ const fresh = ids.findIndex((id) => !known.has(id));
528
+ return fresh >= 0 ? fresh : ids.length;
529
+ }
530
+ async function move(offset: 1 | -1) {
531
+ const ticket = navigation;
532
+ const current = selected;
533
+ const before = rows;
534
+ const index = before.findIndex((row) => row.item.id === current);
535
+ let target =
536
+ index < 0
537
+ ? offset > 0
538
+ ? before[0]
539
+ : before.at(-1)
540
+ : before[index + offset];
541
+ if (!target && offset > 0 && more && inbox) {
542
+ const page = await inbox.more();
543
+ // The reviewer chose something else while the page loaded.
544
+ if (ticket !== navigation) return;
545
+ target = page.items[index < 0 ? 0 : resume(before, current, page.items)];
546
+ }
547
+ if (target) await request(target.item.id);
548
+ }
549
+ /** After an item is finished: drop it under the filter and open the next open item. */
550
+ async function advance() {
551
+ const ticket = navigation;
552
+ const current = selected;
553
+ const before = rows;
554
+ let list = paneView && inbox ? inbox.patch(paneView.entry, true) : rows;
555
+ rows = list;
556
+ let start = resume(before, current, list);
557
+ const open = (row: InboxEntry) =>
558
+ row.item.id !== current && !triageTerminal(statusOf(row));
559
+ let next = list.slice(start).find(open);
560
+ let unknown: 'retry' | 'failed' | undefined;
561
+ // Pages of finished rows are skipped, up to ADVANCE_PAGES appends; past
562
+ // that bound, or when an append fails or cannot start, whether anything
563
+ // follows is unknown and the queue is never reported empty.
564
+ for (let pages = 0; !next && more && inbox; pages++) {
565
+ if (pages === ADVANCE_PAGES) {
566
+ unknown = 'retry';
567
+ break;
568
+ }
569
+ const count = list.length;
570
+ const page = await inbox.more();
571
+ if (!alive || ticket !== navigation) return;
572
+ // Input typed while the page loaded is not discarded either.
573
+ if (paneDirty) {
574
+ held = 'unsent';
575
+ return;
576
+ }
577
+ list = page.items;
578
+ if (page.result === 'failed' || page.result === 'pending') {
579
+ // A failed append failed the list closed (no rows, no Next); a
580
+ // pending one leaves Next working.
581
+ unknown = page.result === 'failed' ? 'failed' : 'retry';
582
+ break;
583
+ }
584
+ // Rows may have left while the page loaded: continue by id.
585
+ start = resume(before, current, list);
586
+ next = list.slice(start).find(open);
587
+ // No progress (for example a denied page) must not spin on a host callback.
588
+ if (list.length <= count) {
589
+ unknown = more ? 'retry' : undefined;
590
+ break;
591
+ }
592
+ }
593
+ next ??= list.slice(0, start).find(open);
594
+ if (!next && unknown) {
595
+ held = unknown;
596
+ return;
597
+ }
598
+ if (paneDirty) {
599
+ held = 'unsent';
600
+ return;
601
+ }
602
+ await choose(next?.item.id, true);
603
+ }
604
+ function outcome(_kind: ReviewOutcome, view: ItemReviewView) {
605
+ if (view.entry.item.id !== selected) return;
606
+ paneChanged(view, false, paneRead);
607
+ if (!triageSettled(view)) return;
608
+ // Unsent input on the same item is never discarded by an automatic
609
+ // move: the reviewer moves on explicitly (through the confirmation).
610
+ if (paneDirty) {
611
+ held = 'unsent';
612
+ return;
613
+ }
614
+ void advance();
615
+ }
616
+ function keydown(event: KeyboardEvent) {
617
+ if (event.defaultPrevented || event.ctrlKey || event.metaKey) return;
618
+ const target = event.target;
619
+ if (target instanceof Element) {
620
+ if (target !== document.body && !root?.contains(target)) return;
621
+ if (
622
+ target.closest(
623
+ 'input,textarea,select,[contenteditable]:not([contenteditable="false"])',
624
+ )
625
+ )
626
+ return;
627
+ }
628
+ const key = event.key;
629
+ const plain = !event.altKey && !event.shiftKey;
630
+ const offset =
631
+ (plain && (key === 'j' || key === 'J')) ||
632
+ (event.altKey && key === 'ArrowDown')
633
+ ? 1
634
+ : (plain && (key === 'k' || key === 'K')) ||
635
+ (event.altKey && key === 'ArrowUp')
636
+ ? -1
637
+ : 0;
638
+ if (!offset) return;
639
+ event.preventDefault();
640
+ void move(offset);
641
+ }
642
+ </script>
643
+ <svelte:window onkeydown={keydown} />
644
+ <div class="workspace" bind:this={root} data-testid="intake-workspace">
645
+ <div class="list-pane">
646
+ <InboxView bind:this={inbox} {host} {rowAside} selectedId={selected} onselect={(id) => request(id)} onitems={listed} onreadstart={readStarted} {stamp} status={rowStatus} resolveInRow={false} />
647
+ </div>
648
+ <section class="item-pane" bind:this={pane} tabindex="-1" aria-label={label ?? t(M['ingestion.selectedItem'])} data-testid="workspace-item" onfocusin={noteField}>
649
+ <div class="queue-nav" role="group" aria-label={t(M['ingestion.workspace'])}>
650
+ <Button disabled={position === 0 || !rows.length} aria-keyshortcuts="K Alt+ArrowUp" onclick={() => move(-1)}>{t(M['ingestion.previousItem'])}</Button>
651
+ {#if position >= 0}<p data-testid="queue-position">{t(M['ingestion.itemOf'], { index: position + 1, count: rows.length })}</p>{/if}
652
+ <Button disabled={(position >= rows.length - 1 && !more) || !rows.length} aria-keyshortcuts="J Alt+ArrowDown" onclick={() => move(1)}>{t(M['ingestion.nextItem'])}</Button>
653
+ </div>
654
+ <p class="hint">{t(M['ingestion.keyboardHelp'])}</p>
655
+ <p role="status" data-testid="background-update">{announcement}</p>
656
+ <!-- One persistent live region whose text changes, so the notes are announced reliably. -->
657
+ <p role="status" class="pane-note" data-testid="pane-note">{#if held}<span data-testid="advance-held">{t(M[held === 'unsent' ? 'ingestion.finishedUnsent' : held === 'failed' ? 'ingestion.advanceFailed' : 'ingestion.advanceRetry'])}</span>{:else if done && !selected}<span data-testid="queue-done">{t(M['ingestion.queueDone'])}</span>{/if}</p>
658
+ {#if leaving}<div class="leave" role="group" aria-labelledby="unsaved-leave-note" bind:this={stay} data-testid="unsaved-leave"><p id="unsaved-leave-note">{t(M['ingestion.unsavedLeave'])}</p><Button data-stay onclick={keepEditing}>{t(M['ingestion.stay'])}</Button><Button onclick={discardAndLeave}>{t(M['ingestion.discardLeave'])}</Button></div>{/if}
659
+ {#if selected}
660
+ {#key selected}<ReviewView {host} itemId={selected} {pollMs} layout="stacked" {stamp} onview={paneChanged} ondirty={(dirty: boolean) => { paneDirty = dirty; }} onoutcome={outcome} />{/key}
661
+ {:else if !done}<p>{t(M['ingestion.selectItem'])}</p>{/if}
662
+ </section>
663
+ </div>
664
+ <style>
665
+ @layer smrt.reset, smrt.structure, smrt.theme;
666
+ @layer smrt.theme {
667
+ .workspace{display:grid;grid-template-columns:minmax(0,1fr);gap:1rem;color:var(--smrt-color-on-surface)}.list-pane,.item-pane{min-width:0}.item-pane{outline-offset:.25rem}.queue-nav{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;padding-inline:1rem}.queue-nav p,.hint{margin:0}.leave{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;padding:.75rem 1rem;border:var(--smrt-hairline)}.leave p{margin:0;flex-basis:100%}.hint{padding-inline:1rem;font-size:.875rem}[data-testid='background-update'],.pane-note{padding-inline:1rem;margin:0}@media(min-width:64rem){.workspace{grid-template-columns:minmax(18rem,26rem) minmax(0,1fr)}.list-pane{position:sticky;inset-block-start:0;align-self:start;max-block-size:100vh;overflow:auto}}
668
+ }
669
+ </style>
@@ -0,0 +1,16 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { InboxEntry, IntakeReviewHost } from '../../review-dto.js';
3
+ export interface Props {
4
+ host: IntakeReviewHost;
5
+ /** Item to open first; afterwards the workspace owns the selection. */
6
+ itemId?: string;
7
+ /** Reload interval while an item's background stage is in flight. */
8
+ pollMs?: number;
9
+ /** Told about every selection change, e.g. to keep a URL in step; never navigates. */
10
+ onselect?: (itemId: string | undefined) => void;
11
+ rowAside?: Snippet<[InboxEntry]>;
12
+ }
13
+ declare const TriageView: import("svelte").Component<Props, {}, "">;
14
+ type TriageView = ReturnType<typeof TriageView>;
15
+ export default TriageView;
16
+ //# sourceMappingURL=TriageView.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TriageView.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TriageView.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,EAAa,KAAK,OAAO,EAAiB,MAAM,QAAQ,CAAC;AAChE,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAEjB,MAAM,qBAAqB,CAAC;AAc7B,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAChD,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;CAClC;AAyoBD,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}