rvlib-shipkit 2.2.0 → 2.3.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,779 @@
1
+ // a repo's review page as state (wiki build/shipkit/review.md, The review page): the cards on disk, the open one, the
2
+ // filters, and his draft per card (rectangles, reactions and comments not sent yet), kept in the browser until it is
3
+ // sent. The list is read while the page shows, every 2 s, answered 304 when the store did not move
4
+ import { makeAutoObservable, reaction, runInAction } from 'mobx'
5
+ import {
6
+ isGallery,
7
+ laneOf,
8
+ moveOf,
9
+ reworkOf,
10
+ stateOf,
11
+ unreadBy,
12
+ verdictOf,
13
+ type MediaView,
14
+ type Message,
15
+ type Move,
16
+ type Rect,
17
+ type ReviewItem,
18
+ type ReviewReaction,
19
+ type ReviewStatus,
20
+ type ShownStatus,
21
+ type Side,
22
+ type Spec,
23
+ } from '../../../review/model.ts'
24
+ import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive, membersOf, orderedThreads, statusOf, threadOfItem, waits, type Thread } from '../../../review/threads.ts'
25
+ import { reach } from '../reach.ts'
26
+ import type { RepoSt } from '../repo-st.ts'
27
+ import type { Role } from '../../../review/role.ts'
28
+ import { ClipSt } from './clip-st.ts'
29
+
30
+ interface Draft {
31
+ rects: Rect[]
32
+ comment: string
33
+ reactions?: ReviewReaction[]
34
+ }
35
+
36
+ /** a live `sk review watch`: its process, the session or lane it keeps, when it started */
37
+ export interface Watcher {
38
+ pid: number
39
+ session: string | null
40
+ lane: string | null
41
+ started: string
42
+ }
43
+
44
+ export interface Listing {
45
+ items: ReviewItem[]
46
+ broken: { id: string; error: string }[]
47
+ }
48
+
49
+ /** the zoom of a view: fit the column (and the window's height), or a share of the image's own pixels */
50
+ export const ZOOMS = ['fit', '1', '1.5', '2'] as const
51
+ export type Zoom = (typeof ZOOMS)[number]
52
+
53
+ /** how a pair of images shows: side by side, the curtain, one over the other, the after alone */
54
+ export type CompareMode = 'side' | 'swipe' | 'onion' | 'mod'
55
+ export const COMPARE_MODES: readonly CompareMode[] = ['side', 'swipe', 'onion', 'mod']
56
+ /** the side a rectangle lands on: the after alone draws on the after */
57
+ export const drawnSide = (mode: CompareMode, drawn: Side): Side => (mode === 'mod' ? 'after' : drawn)
58
+
59
+ /** below this width the page is the phone's: one column at a time, the verdicts in a bar under the thumb */
60
+ /** a repo's review unit as the page reads it: the words of its kinds and topics, its dispatcher and who holds the role */
61
+ export type RepoSpec = Spec & { role: Role | null; sources: string[]; auto: { id: string; every: string }[] }
62
+
63
+ export const PHONE_PX = 768
64
+ /** below this width of the review page itself (the project list and the sidebar already took theirs), `auto` stacks
65
+ * the card over the answer in one column beside the list */
66
+ export const STACK_PX = 900
67
+ /** where the card sits against its answer: on top of it, left of it, or by the page's width */
68
+ export const CARD_PLACES = ['top', 'left', 'auto'] as const
69
+ export type CardPlace = (typeof CARD_PLACES)[number]
70
+ export type PhonePane = 'list' | 'card' | 'answer'
71
+ export type PairSide = 'before' | 'after' | 'swipe'
72
+
73
+ const POLL_MS = 2000
74
+
75
+ const sameSpot = (r: ReviewReaction, view: number, tile: number | undefined): boolean => r.view === view && r.tile === tile
76
+
77
+ /** what a reload must not lose, under keys of this repo: a blocked storage only loses persistence */
78
+ const store = {
79
+ get(k: string): string | null {
80
+ try {
81
+ return localStorage.getItem(k)
82
+ } catch {
83
+ return null
84
+ }
85
+ },
86
+ put(k: string, v: string | null): void {
87
+ try {
88
+ if (v === null) localStorage.removeItem(k)
89
+ else localStorage.setItem(k, v)
90
+ } catch {
91
+ // as above
92
+ }
93
+ },
94
+ }
95
+
96
+ const draftOf = (s: string | null): Draft | null => {
97
+ try {
98
+ const d = s === null ? null : (JSON.parse(s) as Partial<Draft>)
99
+ if (!d || !Array.isArray(d.rects) || typeof d.comment !== 'string') return null
100
+ return { rects: d.rects, comment: d.comment, ...(Array.isArray(d.reactions) ? { reactions: d.reactions } : {}) }
101
+ } catch {
102
+ return null
103
+ }
104
+ }
105
+
106
+ /** a loader for a polled list: it sends the tag of its last answer, and on « not modified » gives that answer again */
107
+ function unchangedAware<T>(url: () => string): () => Promise<T> {
108
+ let last: { tag: string; value: T } | null = null
109
+ return async () => {
110
+ const res = await reach(url(), { cache: 'no-store', headers: last ? { 'If-None-Match': last.tag } : {} })
111
+ if (res.status === 304 && last) return last.value
112
+ if (!res.ok) throw new Error(`${url()}: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim())
113
+ const value = (await res.json()) as T
114
+ const tag = res.headers.get('ETag')
115
+ last = tag ? { tag, value } : null
116
+ return value
117
+ }
118
+ }
119
+
120
+ async function postJson<T>(url: string, body: unknown): Promise<T> {
121
+ const res = await reach(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
122
+ const text = await res.text()
123
+ if (!res.ok) {
124
+ let msg = text
125
+ try {
126
+ msg = (JSON.parse(text) as { error?: string }).error ?? text
127
+ } catch {
128
+ // a plain text answer says it already
129
+ }
130
+ throw new Error(msg || `HTTP ${res.status}`)
131
+ }
132
+ return JSON.parse(text) as T
133
+ }
134
+
135
+ /** the pair's compare mode, the curtain's line and the onion's opacity, shared by every pair of the page */
136
+ export class CompareSt {
137
+ mode: CompareMode = 'side'
138
+ split = 0.5
139
+ opacity = 0.5
140
+ constructor(private readonly key: string) {
141
+ const m = store.get(key)
142
+ if (m && (COMPARE_MODES as readonly string[]).includes(m)) this.mode = m as CompareMode
143
+ makeAutoObservable<this, 'key'>(this, { key: false })
144
+ }
145
+ setMode(m: CompareMode): void {
146
+ this.mode = m
147
+ store.put(this.key, m)
148
+ }
149
+ setSplit(v: number): void {
150
+ this.split = Math.max(0, Math.min(1, v))
151
+ }
152
+ setOpacity(v: number): void {
153
+ this.opacity = Math.max(0, Math.min(1, v))
154
+ }
155
+ }
156
+
157
+ /** the phone's state: which pane shows, « circle » drawing on, the side of a pair */
158
+ export class PhoneSt {
159
+ on = false
160
+ pane: PhonePane = 'list'
161
+ draw = false
162
+ pair: PairSide = 'after'
163
+ constructor() {
164
+ makeAutoObservable(this)
165
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
166
+ const mq = window.matchMedia(`(max-width: ${PHONE_PX - 1}px)`)
167
+ this.on = mq.matches
168
+ mq.addEventListener('change', (e) => runInAction(() => (this.on = e.matches)))
169
+ }
170
+ }
171
+ show(p: PhonePane): void {
172
+ this.pane = p
173
+ }
174
+ toggleDraw(): void {
175
+ this.draw = !this.draw
176
+ }
177
+ setPair(s: PairSide): void {
178
+ this.pair = s
179
+ }
180
+ }
181
+
182
+ export class ReviewSt {
183
+ list: Listing | null = null
184
+ listError: string | null = null
185
+ /** the repo's review unit in words (its kinds, topics, dispatcher and its holder), read when the page starts */
186
+ spec: RepoSpec | null = null
187
+ /** the dispatcher's board in place of the cards */
188
+ board = false
189
+ watchers: Watcher[] = []
190
+ /** each clip view's own clock on the open card, by view index, made on first use */
191
+ private readonly clips = new Map<number, ClipSt>()
192
+ private clipsCard = ''
193
+ selectedId: string | null = null
194
+ query = ''
195
+ subject: string | null = null
196
+ whose: Move | null = null
197
+ status: ShownStatus | null = null
198
+ drafts: Record<string, Draft> = {}
199
+ /** the rectangle of the open card picked on the image, its index */
200
+ rect: number | null = null
201
+ /** the subject rings over the image (M) */
202
+ marksOn = true
203
+ /** the open card's active view, its index: the one last drawn on or picked, whose clip the keys drive */
204
+ view = 0
205
+ zoom: Zoom = 'fit'
206
+ viewZoom: Record<number, Zoom> = {}
207
+ /** the gallery tile shown large across the column, `<view>/<tile>` */
208
+ bigTile: string | null = null
209
+ replies: Record<string, string> = {}
210
+ private reading = new Set<string>()
211
+ /** « hide handled »: only the active threads in the list */
212
+ hideHandled = true
213
+ /** « by lane »: the list grouped under one header per lane */
214
+ byLane = false
215
+ /** the threads whose older versions are unfolded, by head id */
216
+ unfolded: Record<string, boolean> = {}
217
+ busy = false
218
+ error: string | null = null
219
+ /** the card against its answer, his pick, kept with the prefs */
220
+ place: CardPlace = 'auto'
221
+ /** the page is narrower than STACK_PX */
222
+ narrow = false
223
+ readonly compare: CompareSt
224
+ readonly phone = new PhoneSt()
225
+ private timer: ReturnType<typeof setInterval> | null = null
226
+ private readonly loadList: () => Promise<Listing>
227
+ private readonly loadWatchers: () => Promise<Watcher[]>
228
+ private readonly prefix: string
229
+
230
+ /** the page measured its width */
231
+ fit(px: number): void {
232
+ const on = px > 0 && px < STACK_PX
233
+ if (this.narrow !== on) this.narrow = on
234
+ }
235
+ /** the card over its answer in one column: picked, or `auto` on a narrow page */
236
+ get stacked(): boolean {
237
+ return this.place === 'top' || (this.place === 'auto' && this.narrow)
238
+ }
239
+ setPlace(p: CardPlace): void {
240
+ this.place = p
241
+ }
242
+
243
+ constructor(private readonly st: RepoSt) {
244
+ this.prefix = `shipkit.review.${st.key}.`
245
+ this.loadList = unchangedAware(() => st.url('/api/review'))
246
+ this.loadWatchers = unchangedAware(() => st.url('/api/review/watchers'))
247
+ this.compare = new CompareSt(`${this.prefix}compare`)
248
+ try {
249
+ const p = JSON.parse(store.get(`${this.prefix}prefs`) ?? '{}') as { hideHandled?: boolean; byLane?: boolean; zoom?: string; marks?: boolean; place?: string }
250
+ if (p.hideHandled === false) this.hideHandled = false
251
+ if (p.byLane === true) this.byLane = true
252
+ if (p.marks === false) this.marksOn = false
253
+ if ((ZOOMS as readonly string[]).includes(p.zoom ?? '')) this.zoom = p.zoom as Zoom
254
+ if ((CARD_PLACES as readonly string[]).includes(p.place ?? '')) this.place = p.place as CardPlace
255
+ } catch {
256
+ // a broken pref is the default
257
+ }
258
+ makeAutoObservable<this, 'st' | 'clips' | 'clipsCard' | 'reading' | 'timer' | 'loadList' | 'loadWatchers' | 'prefix'>(this, {
259
+ st: false,
260
+ clips: false,
261
+ clipsCard: false,
262
+ reading: false,
263
+ timer: false,
264
+ loadList: false,
265
+ loadWatchers: false,
266
+ prefix: false,
267
+ compare: false,
268
+ phone: false,
269
+ })
270
+ // opening a card reads the agent's messages on its whole thread
271
+ reaction(
272
+ () => (this.openThread ? membersOf(this.openThread) : []).filter((x) => unreadBy(x, 'owner').length > 0).map((x) => x.item.id),
273
+ (ids) => {
274
+ for (const id of ids) void this.markRead(id)
275
+ },
276
+ )
277
+ reaction(
278
+ () => JSON.stringify({ hideHandled: this.hideHandled, byLane: this.byLane, zoom: this.zoom, marks: this.marksOn, place: this.place }),
279
+ (s) => store.put(`${this.prefix}prefs`, s),
280
+ )
281
+ }
282
+
283
+ /** the list and the watchers, read now and every 2 s while the page shows; the unit's words once a start */
284
+ start(): void {
285
+ void this.refresh()
286
+ void fetch(this.st.url('/api/review/spec'))
287
+ .then((r) => (r.ok ? (r.json() as Promise<RepoSpec>) : null))
288
+ .catch(() => null)
289
+ .then((spec) => runInAction(() => (this.spec = spec)))
290
+ if (this.timer === null) this.timer = setInterval(() => void this.refresh(), POLL_MS)
291
+ }
292
+
293
+ stop(): void {
294
+ if (this.timer !== null) clearInterval(this.timer)
295
+ this.timer = null
296
+ }
297
+
298
+ async refresh(): Promise<void> {
299
+ try {
300
+ const [list, watchers] = await Promise.all([this.loadList(), this.loadWatchers().catch(() => this.watchers)])
301
+ runInAction(() => {
302
+ if (list !== this.list) this.list = list
303
+ if (watchers !== this.watchers) this.watchers = watchers
304
+ this.listError = null
305
+ })
306
+ } catch (e) {
307
+ runInAction(() => (this.listError = (e as Error).message))
308
+ }
309
+ }
310
+
311
+ /** the card's folder, or one of its files, shown in the Finder of the machine the hub runs on */
312
+ async reveal(id: string, file?: string): Promise<void> {
313
+ try {
314
+ await postJson(this.st.url(`/api/review/${id}/reveal`), file ? { file } : {})
315
+ } catch (e) {
316
+ runInAction(() => (this.error = (e as Error).message))
317
+ }
318
+ }
319
+
320
+ setBoard(on: boolean): void {
321
+ this.board = on
322
+ }
323
+
324
+ fileUrl(id: string, name: string): string {
325
+ return this.st.url(`/api/review/${id}/${name}`)
326
+ }
327
+
328
+ thumbUrl(x: ReviewItem, name: string): string {
329
+ return /\.(png|jpe?g|webp)$/.test(name) ? this.st.url(`/api/review/${x.item.id}/thumb/${name}`) : this.fileUrl(x.item.id, name)
330
+ }
331
+
332
+ threadImageUrl(id: string, name: string): string {
333
+ return this.st.url(`/api/review/${id}/thread/${name}`)
334
+ }
335
+
336
+ get items(): ReviewItem[] {
337
+ return this.list?.items ?? []
338
+ }
339
+
340
+ get broken(): Listing['broken'] {
341
+ return this.list?.broken ?? []
342
+ }
343
+
344
+ statusOf(x: ReviewItem): ShownStatus {
345
+ return statusOf(x, this.items)
346
+ }
347
+
348
+ get followed(): Set<string> {
349
+ return followedIds(this.items)
350
+ }
351
+
352
+ reworkOf(x: ReviewItem): ReturnType<typeof reworkOf> {
353
+ return reworkOf(x, this.followed.has(x.item.id), new Date())
354
+ }
355
+
356
+ /** the live watches that read his answer on any of these cards */
357
+ listenersOf(cards: readonly ReviewItem[]): Watcher[] {
358
+ return this.watchers.filter((w) =>
359
+ cards.some((x) => (w.session === null || (x.item.origin.by === 'agent' && x.item.origin.session === w.session)) && (w.lane === null || laneOf(x) === w.lane)),
360
+ )
361
+ }
362
+
363
+ get threads(): Thread[] {
364
+ return orderedThreads(this.items)
365
+ }
366
+
367
+ /** what waits on him: the cards waiting for the owner's verdict, and what he raised that asks him */
368
+ get pendingCount(): number {
369
+ return this.threads.filter((t) => waits(t.head) || (t.head.item.origin.by !== 'agent' && this.moveOf(t.head) === 'owner')).length
370
+ }
371
+
372
+ get activeCount(): number {
373
+ return this.threads.filter((t) => isActive(t)).length
374
+ }
375
+
376
+ /** the subject kinds this store holds, for the filter */
377
+ get subjects(): string[] {
378
+ return [...new Set(this.items.map((x) => x.item.subject.kind))].sort()
379
+ }
380
+
381
+ /** the list as the switch, the filters and the search show it: one thread per head */
382
+ get visible(): Thread[] {
383
+ const q = this.query.trim().toLowerCase()
384
+ return this.threads.filter(
385
+ (t) =>
386
+ (!this.hideHandled || isActive(t)) &&
387
+ (this.subject === null || t.head.item.subject.kind === this.subject) &&
388
+ (this.whose === null || this.moveOf(t.head) === this.whose) &&
389
+ (this.status === null || this.statusOf(t.head) === this.status) &&
390
+ (q === '' || membersOf(t).some((x) => `${x.item.title} ${x.item.what ?? ''} ${x.item.id}`.toLowerCase().includes(q))),
391
+ )
392
+ }
393
+
394
+ /** the visible threads under one header per lane; null: the cards with no lane */
395
+ get laneGroups(): { lane: string | null; threads: Thread[] }[] {
396
+ const groups = new Map<string | null, Thread[]>()
397
+ for (const t of this.visible) {
398
+ const lane = laneOf(t.head) ?? null
399
+ groups.set(lane, [...(groups.get(lane) ?? []), t])
400
+ }
401
+ const all = [...groups].map(([lane, threads]) => ({ lane, threads }))
402
+ return [...all.filter((g) => g.lane !== null), ...all.filter((g) => g.lane === null)]
403
+ }
404
+
405
+ get open(): ReviewItem | null {
406
+ return this.items.find((x) => x.item.id === this.selectedId) ?? this.visible[0]?.head ?? this.threads[0]?.head ?? null
407
+ }
408
+
409
+ get openThread(): Thread | null {
410
+ const id = this.open?.item.id
411
+ return id === undefined ? null : threadOfItem(id, this.threads)
412
+ }
413
+
414
+ /** the reports and notes nested in the open card */
415
+ get answered(): ReviewItem[] {
416
+ const t = this.openThread
417
+ const id = this.open?.item.id
418
+ return t && t.versions.some((x) => x.item.id === id) ? t.answers : []
419
+ }
420
+
421
+ get answeredBy(): ReviewItem | null {
422
+ const x = this.open
423
+ return x && x.item.origin.by !== 'agent' ? answererOf(x.item.id, this.items) : null
424
+ }
425
+
426
+ /** the older versions of the open card, the newest of them first */
427
+ get history(): ReviewItem[] {
428
+ const x = this.open
429
+ return x ? chainOf(x, this.items).slice(1) : []
430
+ }
431
+
432
+ get newer(): ReviewItem[] {
433
+ const x = this.open
434
+ return x ? childrenOf(x.item.id, this.items) : []
435
+ }
436
+
437
+ showsVersions(t: Thread): boolean {
438
+ return this.unfolded[t.head.item.id] === true || t.versions.slice(1).some((x) => x.item.id === this.selectedId)
439
+ }
440
+
441
+ fold(headId: string): void {
442
+ this.unfolded = { ...this.unfolded, [headId]: !this.unfolded[headId] }
443
+ }
444
+
445
+ setHideHandled(on: boolean): void {
446
+ this.hideHandled = on
447
+ }
448
+
449
+ setByLane(on: boolean): void {
450
+ this.byLane = on
451
+ }
452
+
453
+ setQuery(q: string): void {
454
+ this.query = q
455
+ }
456
+
457
+ setFilter(f: { subject?: string | null; whose?: Move | null; status?: ShownStatus | null }): void {
458
+ if (f.subject !== undefined) this.subject = f.subject
459
+ if (f.whose !== undefined) this.whose = f.whose
460
+ if (f.status !== undefined) this.status = f.status
461
+ }
462
+
463
+ moveOf(x: ReviewItem): Move {
464
+ return moveOf(stateOf(x, this.followed.has(x.item.id)).state)
465
+ }
466
+
467
+ /** what the form edits: the draft, else the sent feedback, else nothing yet */
468
+ get draft(): Draft {
469
+ const x = this.open
470
+ if (!x) return { rects: [], comment: '' }
471
+ const v = verdictOf(x)
472
+ const sent = v?.reactions
473
+ return this.drafts[x.item.id] ?? { rects: v?.rects ?? [], comment: v?.comment ?? '', ...(sent && sent.length > 0 ? { reactions: sent } : {}) }
474
+ }
475
+
476
+ get dirty(): boolean {
477
+ const x = this.open
478
+ return x !== null && this.drafts[x.item.id] !== undefined
479
+ }
480
+
481
+ private edit(f: (d: Draft) => Draft): void {
482
+ const x = this.open
483
+ if (!x) return
484
+ const d = this.draft
485
+ const { reactions, ...rest } = f({ rects: [...d.rects], comment: d.comment, reactions: [...(d.reactions ?? [])] })
486
+ const next: Draft = reactions && reactions.length > 0 ? { ...rest, reactions } : rest
487
+ this.drafts = { ...this.drafts, [x.item.id]: next }
488
+ store.put(`${this.prefix}draft.${x.item.id}`, JSON.stringify(next))
489
+ }
490
+
491
+ /** opens a card from the page: its address follows */
492
+ pick(id: string): void {
493
+ this.st.showReview(id)
494
+ if (this.phone.on) this.phone.show('card')
495
+ }
496
+
497
+ /** the card the address names; its draft is read again from storage, the truth over every window */
498
+ select(id: string | null): void {
499
+ if (id !== this.selectedId) {
500
+ this.view = 0
501
+ this.viewZoom = {}
502
+ this.bigTile = null
503
+ }
504
+ this.selectedId = id
505
+ if (id !== null) {
506
+ const stored = draftOf(store.get(`${this.prefix}draft.${id}`))
507
+ if (stored) this.drafts = { ...this.drafts, [id]: stored }
508
+ else if (this.drafts[id]) {
509
+ const { [id]: _, ...rest } = this.drafts
510
+ this.drafts = rest
511
+ }
512
+ }
513
+ this.rect = null
514
+ this.error = null
515
+ }
516
+
517
+ /** ↑↓ on the page: the next thread head the list shows */
518
+ move(delta: 1 | -1): void {
519
+ const list = this.visible
520
+ const at = list.findIndex((t) => t.head.item.id === this.openThread?.head.item.id)
521
+ const next = list[Math.max(0, Math.min(list.length - 1, at + delta))]
522
+ if (next) this.pick(next.head.item.id)
523
+ }
524
+
525
+ /** N: the next pending thread head after the open one, wrapping */
526
+ nextPending(): void {
527
+ const pend = this.threads.filter((t) => waits(t.head))
528
+ if (pend.length === 0) return
529
+ const at = pend.findIndex((t) => t.head.item.id === this.open?.item.id)
530
+ const next = pend[(at + 1) % pend.length]
531
+ if (next) this.pick(next.head.item.id)
532
+ }
533
+
534
+ /** a rectangle on a view, which becomes the active one; on a clip it keeps the time of the frame shown */
535
+ addRect(r: { x: number; y: number; w: number; h: number }, side: Side | undefined, on: number = this.view): void {
536
+ if (on !== this.view) this.setView(on)
537
+ const view = this.view > 0 ? { view: this.view } : {}
538
+ const t = this.clipKey ? { t: Math.round(this.clip.time * 1000) / 1000 } : {}
539
+ this.edit((d) => ({ ...d, rects: [...d.rects, { ...r, comment: '', ...view, ...(side ? { side } : {}), ...t }] }))
540
+ this.rect = this.draft.rects.length - 1
541
+ }
542
+
543
+ setView(i: number): void {
544
+ this.view = i
545
+ this.rect = null
546
+ }
547
+
548
+ get openView(): MediaView | null {
549
+ const v = this.open?.item.views[this.view]
550
+ return v && !isGallery(v) ? v : null
551
+ }
552
+
553
+ zoomOf(i: number): Zoom {
554
+ return this.viewZoom[i] ?? this.zoom
555
+ }
556
+
557
+ setZoom(z: Zoom): void {
558
+ this.zoom = z
559
+ this.viewZoom = {}
560
+ }
561
+
562
+ setViewZoom(i: number, z: Zoom): void {
563
+ this.viewZoom = { ...this.viewZoom, [i]: z }
564
+ }
565
+
566
+ toggleTile(view: number, tile: number): void {
567
+ const k = `${view}/${tile}`
568
+ this.bigTile = this.bigTile === k ? null : k
569
+ }
570
+
571
+ reactionOf(view: number, tile?: number): ReviewReaction | null {
572
+ return (this.draft.reactions ?? []).find((r) => sameSpot(r, view, tile)) ?? null
573
+ }
574
+
575
+ /** a reaction's toggle: sets that status, or clears it when it is already the one */
576
+ react(view: number, tile: number | undefined, status: ReviewStatus): void {
577
+ const cur = this.reactionOf(view, tile)
578
+ this.putReaction(view, tile, { status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
579
+ }
580
+
581
+ setReactionComment(view: number, tile: number | undefined, comment: string): void {
582
+ this.putReaction(view, tile, { status: this.reactionOf(view, tile)?.status, comment })
583
+ }
584
+
585
+ removeReaction(view: number, tile: number | undefined): void {
586
+ this.putReaction(view, tile, { status: undefined, comment: '' })
587
+ }
588
+
589
+ /** the reactions that go to the agent with the verdict, in the card's order */
590
+ get outgoingReactions(): ReviewReaction[] {
591
+ return [...(this.draft.reactions ?? [])].sort((a, b) => a.view - b.view || (a.tile ?? -1) - (b.tile ?? -1))
592
+ }
593
+
594
+ private putReaction(view: number, tile: number | undefined, r: { status: ReviewStatus | undefined; comment: string }): void {
595
+ const next: ReviewReaction = { view, ...(tile !== undefined ? { tile } : {}), ...(r.status ? { status: r.status } : {}), comment: r.comment }
596
+ const empty = !r.status && r.comment === ''
597
+ this.edit((d) => {
598
+ const rest = (d.reactions ?? []).filter((q) => !sameSpot(q, view, tile))
599
+ return { ...d, reactions: empty ? rest : [...rest, next] }
600
+ })
601
+ }
602
+
603
+ /** the open view's clip identity, empty when it shows images */
604
+ get clipKey(): string {
605
+ const x = this.open
606
+ return x && this.openView?.clip ? `${x.item.id}#${this.view}` : ''
607
+ }
608
+
609
+ /** the clock of a clip view of the open card: every clip view plays and steps on its own */
610
+ clipOf(view: number): ClipSt {
611
+ const id = this.open?.item.id ?? ''
612
+ if (id !== this.clipsCard) {
613
+ this.clips.clear()
614
+ this.clipsCard = id
615
+ }
616
+ let c = this.clips.get(view)
617
+ if (!c) {
618
+ c = new ClipSt()
619
+ const v = this.open?.item.views[view]
620
+ const info = v && !isGallery(v) ? v.clip : undefined
621
+ c.load(`${id}#${view}`, info?.duration ?? 0, info?.fps ?? 20)
622
+ this.clips.set(view, c)
623
+ }
624
+ return c
625
+ }
626
+
627
+ get clip(): ClipSt {
628
+ return this.clipOf(this.view)
629
+ }
630
+
631
+ get conversation(): ReturnType<typeof conversationOf> {
632
+ const t = this.openThread
633
+ return t ? conversationOf(t) : []
634
+ }
635
+
636
+ /** a thread holds an agent message he has not read: its card shows a dot */
637
+ unread(t: Thread): boolean {
638
+ return membersOf(t).some((x) => unreadBy(x, 'owner').length > 0)
639
+ }
640
+
641
+ get reply(): string {
642
+ const id = this.open?.item.id
643
+ return id ? (this.replies[id] ?? store.get(`${this.prefix}reply.${id}`) ?? '') : ''
644
+ }
645
+
646
+ setReply(text: string): void {
647
+ const id = this.open?.item.id
648
+ if (!id) return
649
+ this.replies = { ...this.replies, [id]: text }
650
+ store.put(`${this.prefix}reply.${id}`, text || null)
651
+ }
652
+
653
+ /** ⌘Enter or « reply »: his message goes to the open card */
654
+ async sendReply(): Promise<void> {
655
+ const x = this.open
656
+ const text = this.reply.trim()
657
+ if (!x || !text || this.busy) return
658
+ const id = x.item.id
659
+ this.busy = true
660
+ this.error = null
661
+ try {
662
+ await postJson(this.st.url(`/api/review/${id}/reply`), { text })
663
+ runInAction(() => {
664
+ const { [id]: _, ...rest } = this.replies
665
+ this.replies = rest
666
+ store.put(`${this.prefix}reply.${id}`, null)
667
+ })
668
+ void this.refresh()
669
+ } catch (e) {
670
+ runInAction(() => (this.error = (e as Error).message))
671
+ } finally {
672
+ runInAction(() => (this.busy = false))
673
+ }
674
+ }
675
+
676
+ private async markRead(id: string): Promise<void> {
677
+ if (this.reading.has(id)) return
678
+ this.reading.add(id)
679
+ try {
680
+ await postJson(this.st.url(`/api/review/${id}/read`), {})
681
+ void this.refresh()
682
+ } catch (e) {
683
+ runInAction(() => (this.error = (e as Error).message))
684
+ } finally {
685
+ this.reading.delete(id)
686
+ }
687
+ }
688
+
689
+ toggleMarks(): void {
690
+ this.marksOn = !this.marksOn
691
+ }
692
+
693
+ /** a rectangle picked: its view comes on screen, and on a clip its frame, paused */
694
+ pickRect(i: number | null): void {
695
+ this.rect = i
696
+ if (i === null) return
697
+ const r = this.draft.rects[i]
698
+ this.view = r?.view ?? 0
699
+ if (r?.t === undefined || !this.clipKey) return
700
+ this.clip.seek(r.t)
701
+ }
702
+
703
+ setRectComment(i: number, comment: string): void {
704
+ this.edit((d) => ({ ...d, rects: d.rects.map((r, j) => (j === i ? { ...r, comment } : r)) }))
705
+ }
706
+
707
+ removeRect(i: number): void {
708
+ this.edit((d) => ({ ...d, rects: d.rects.filter((_, j) => j !== i) }))
709
+ this.rect = null
710
+ }
711
+
712
+ setComment(comment: string): void {
713
+ this.edit((d) => ({ ...d, comment }))
714
+ }
715
+
716
+ /** back to what was sent */
717
+ dropDraft(): void {
718
+ const x = this.open
719
+ if (!x) return
720
+ const { [x.item.id]: _, ...rest } = this.drafts
721
+ this.drafts = rest
722
+ store.put(`${this.prefix}draft.${x.item.id}`, null)
723
+ this.rect = null
724
+ }
725
+
726
+ /** V, F, R, A: the draft goes to the card's thread as the owner's verdict, through the server */
727
+ async submit(status: ReviewStatus): Promise<void> {
728
+ const x = this.open
729
+ if (!x || this.busy) return
730
+ if (x.item.origin.by !== 'agent') {
731
+ this.error = 'this item was raised, not shown by an agent: a word on it goes in a reply'
732
+ return
733
+ }
734
+ if (x.item.wip) {
735
+ this.error = 'a draft card: the verdict comes once its agent makes it ready; a word goes in a reply'
736
+ return
737
+ }
738
+ const id = x.item.id
739
+ const d = this.draft
740
+ this.busy = true
741
+ this.error = null
742
+ try {
743
+ const m = await postJson<Message>(this.st.url(`/api/review/${id}/feedback`), { status, comment: d.comment, rects: d.rects, reactions: d.reactions ?? [] })
744
+ runInAction(() => {
745
+ // the verdict shows at once: the list of every card comes back after it
746
+ const r = this.list
747
+ if (r) this.list = { ...r, items: r.items.map((y) => (y.item.id === id ? { ...y, thread: [...y.thread, m] } : y)) }
748
+ const { [id]: _, ...rest } = this.drafts
749
+ this.drafts = rest
750
+ store.put(`${this.prefix}draft.${id}`, null)
751
+ })
752
+ void this.refresh()
753
+ // the tree's count of what waits on him moves at once
754
+ void this.st.cols.poll(true)
755
+ } catch (e) {
756
+ runInAction(() => (this.error = (e as Error).message))
757
+ } finally {
758
+ runInAction(() => (this.busy = false))
759
+ }
760
+ }
761
+
762
+ /** the page's keys, as the atelier takes its own: true when it used the key. V approves, F ok with a fix, R rejects,
763
+ * A archives, N the next waiting card, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame */
764
+ key(e: Pick<KeyboardEvent, 'key' | 'shiftKey'>): boolean {
765
+ const k = e.key.toLowerCase()
766
+ if (this.clipKey && e.key === ' ') this.clip.toggle()
767
+ else if (this.clipKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.step(e.key === 'ArrowRight' ? 1 : -1)
768
+ else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') this.move(e.key === 'ArrowDown' ? 1 : -1)
769
+ else if (k === 'v') void this.submit('approved')
770
+ else if (k === 'f') void this.submit('approved-with-fix')
771
+ else if (k === 'r') void this.submit('rejected')
772
+ else if (k === 'a') void this.submit('archived')
773
+ else if (k === 'n') this.nextPending()
774
+ else if (k === 'm') this.toggleMarks()
775
+ else if ((e.key === 'Delete' || e.key === 'Backspace') && this.rect !== null) this.removeRect(this.rect)
776
+ else return false
777
+ return true
778
+ }
779
+ }