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,171 @@
1
+ // what the repo's review unit says of the open item, drawn from DATA the hub computed (review/project.ts viewOfItem): its
2
+ // kind's facts, links, a text or its change as a diff, and « live », the kind's url in a frame, off until he opens it;
3
+ // and a pair's animation to download. The page never runs the repo's code
4
+ import { makeAutoObservable, runInAction } from 'mobx'
5
+ import { observer } from 'mobx-react-lite'
6
+ import { useEffect } from 'react'
7
+ import { isGallery, isPair, viewName, type ReviewItem } from '../../../review/model.ts'
8
+ import type { Fact, Link, ViewText } from '../../../review/unit.ts'
9
+
10
+ /** where an item's files and routes answer under the repo's base: the review page's own url maker */
11
+ interface Urls {
12
+ fileUrl(id: string, name: string): string
13
+ }
14
+
15
+ export interface KindData {
16
+ facts: Fact[]
17
+ links: Link[]
18
+ live: string | null
19
+ text: ViewText | null
20
+ }
21
+
22
+ /** each item's data view, read once an item opens and again when its thread moves; « live » stays as he left it */
23
+ export class KindViewSt {
24
+ views = new Map<string, KindData | { error: string }>()
25
+ live = false
26
+ anim = { view: 0, mode: 'fade', format: 'mp4', labels: 'en' }
27
+
28
+ constructor(private readonly st: Urls) {
29
+ try {
30
+ this.live = localStorage.getItem('rv.kindLive') === '1'
31
+ const a = JSON.parse(localStorage.getItem('rv.anim') ?? 'null') as Partial<KindViewSt['anim']> | null
32
+ if (a) this.anim = { ...this.anim, ...a, view: 0 }
33
+ } catch {
34
+ // a private window: the defaults
35
+ }
36
+ makeAutoObservable<this, 'st'>(this, { st: false })
37
+ }
38
+
39
+ /** the view of item `x`, read when its stamp (its last message) moved */
40
+ read(x: ReviewItem): void {
41
+ const key = `${x.item.id}@${x.thread.at(-1)?.id ?? ''}`
42
+ if (this.views.has(key)) return
43
+ this.views.set(key, { facts: [], links: [], live: null, text: null })
44
+ void fetch(this.st.fileUrl(x.item.id, 'view'))
45
+ .then(async (r) => (r.ok ? ((await r.json()) as KindData) : { error: ((await r.json().catch(() => ({}))) as { error?: string }).error ?? `HTTP ${r.status}` }))
46
+ .catch((e: Error) => ({ error: e.message }))
47
+ .then((v) => runInAction(() => this.views.set(key, v)))
48
+ }
49
+
50
+ of(x: ReviewItem): KindData | { error: string } | undefined {
51
+ return this.views.get(`${x.item.id}@${x.thread.at(-1)?.id ?? ''}`)
52
+ }
53
+
54
+ toggleLive(): void {
55
+ this.live = !this.live
56
+ try {
57
+ localStorage.setItem('rv.kindLive', this.live ? '1' : '0')
58
+ } catch {
59
+ // kept for this page only
60
+ }
61
+ }
62
+
63
+ setAnim(k: 'view' | 'mode' | 'format' | 'labels', v: string): void {
64
+ this.anim = { ...this.anim, [k]: k === 'view' ? Number(v) : v }
65
+ try {
66
+ localStorage.setItem('rv.anim', JSON.stringify(this.anim))
67
+ } catch {
68
+ // kept for this page only
69
+ }
70
+ }
71
+
72
+ animUrl(x: ReviewItem, view: number): string {
73
+ const a = this.anim
74
+ return `${this.st.fileUrl(x.item.id, 'anim')}?view=${view}&mode=${a.mode}&format=${a.format}&labels=${a.labels}`
75
+ }
76
+ }
77
+
78
+ const held = new WeakMap<Urls, KindViewSt>()
79
+ const kindViewOf = (st: Urls): KindViewSt => {
80
+ let k = held.get(st)
81
+ if (!k) held.set(st, (k = new KindViewSt(st)))
82
+ return k
83
+ }
84
+
85
+ /** a line diff of two texts: each line kept, gone or new */
86
+ function diffLines(before: string, after: string): { t: ' ' | '-' | '+'; line: string }[] {
87
+ const a = before.split('\n')
88
+ const b = after.split('\n')
89
+ const n = a.length
90
+ const m = b.length
91
+ const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
92
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
93
+ const out: { t: ' ' | '-' | '+'; line: string }[] = []
94
+ let i = 0
95
+ let j = 0
96
+ while (i < n && j < m) {
97
+ if (a[i] === b[j]) out.push({ t: ' ', line: a[i++]! }), j++
98
+ else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) out.push({ t: '-', line: a[i++]! })
99
+ else out.push({ t: '+', line: b[j++]! })
100
+ }
101
+ while (i < n) out.push({ t: '-', line: a[i++]! })
102
+ while (j < m) out.push({ t: '+', line: b[j++]! })
103
+ return out
104
+ }
105
+
106
+ const TONE = { ' ': 'var(--dim)', '-': 'var(--red, #e5484d)', '+': 'var(--green, #46a758)' } as const
107
+
108
+ /** the kind's data view of the open item, under its facts */
109
+ export const KindView = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
110
+ const k = kindViewOf(app)
111
+ const last = x.thread.at(-1)?.id
112
+ useEffect(() => k.read(x), [k, x, last])
113
+ const v = k.of(x)
114
+ if (v === undefined) return null
115
+ if ('error' in v) return <div className="rv-err rv-pre">the repo's view of this item: {v.error}</div>
116
+ const t = v.text
117
+ return (
118
+ <div className="rv-kindview">
119
+ {v.facts.length > 0 ? (
120
+ <div className="rv-facts">
121
+ {v.facts.map((f, i) => (
122
+ <div key={i} className="rv-fact"><span className="rv-factl">{f.label}</span><span className="rv-factv">{f.value}</span></div>
123
+ ))}
124
+ </div>
125
+ ) : null}
126
+ {v.links.length > 0 ? (
127
+ <div className="rv-fact">
128
+ {v.links.map((l) => <a key={l.url} href={l.url} target="_blank" rel="noreferrer">{l.label}</a>)}
129
+ </div>
130
+ ) : null}
131
+ {t !== null && 'text' in t ? <pre className="rv-pre rv-kindtext">{t.text}</pre> : null}
132
+ {t !== null && 'before' in t ? (
133
+ <pre className="rv-kindtext">
134
+ {diffLines(t.before, t.after).map((d, i) => (
135
+ <div key={i} style={{ color: TONE[d.t] }}>{d.t} {d.line}</div>
136
+ ))}
137
+ </pre>
138
+ ) : null}
139
+ {v.live !== null ? (
140
+ <div>
141
+ <button className="ghost" onClick={() => k.toggleLive()}>{k.live ? 'hide live' : 'live'}</button>
142
+ {k.live ? <iframe title="live" src={v.live} style={{ width: '100%', aspectRatio: '16 / 10', border: '1px solid var(--line)', borderRadius: 3, marginTop: 6 }} /> : null}
143
+ </div>
144
+ ) : null}
145
+ </div>
146
+ )
147
+ })
148
+
149
+ /** a pair of images as a looping animation to download: the mode, the format, the corner labels */
150
+ export const AnimPanel = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
151
+ const pairs = x.item.views.flatMap((v, i) => (!isGallery(v) && isPair(v.files) && !v.clip && !v.audio ? [{ i, name: viewName(v, i) }] : []))
152
+ if (pairs.length === 0) return null
153
+ const k = kindViewOf(app)
154
+ const a = k.anim
155
+ const view = pairs.some((p) => p.i === a.view) ? a.view : (pairs[0]?.i ?? 0)
156
+ const pick = (key: 'view' | 'mode' | 'format' | 'labels', options: [string, string][], value: string) => (
157
+ <select value={value} onChange={(e) => k.setAnim(key, e.currentTarget.value)}>
158
+ {options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
159
+ </select>
160
+ )
161
+ return (
162
+ <div className="rv-fact rv-anim" style={{ flexWrap: 'wrap' }}>
163
+ <span className="rv-factl">animation</span>
164
+ {pairs.length > 1 ? pick('view', pairs.map((p) => [String(p.i), p.name]), String(view)) : null}
165
+ {pick('mode', [['cut', 'cut'], ['fade', 'fade'], ['wipe', 'wipe']], a.mode)}
166
+ {pick('format', [['mp4', 'MP4'], ['gif', 'GIF']], a.format)}
167
+ {pick('labels', [['en', 'EN'], ['fr', 'FR'], ['none', 'no labels']], a.labels)}
168
+ <a href={k.animUrl(x, view)} download>download</a>
169
+ </div>
170
+ )
171
+ })
@@ -0,0 +1,113 @@
1
+ // the review page's shared kit: a status's tone class, the verdicts and reactions as rows, the lane tag, the rework
2
+ // spinner, who listens, the thumbnail. Colours come from review.css, which reads hub.css tokens
3
+ import { observer } from 'mobx-react-lite'
4
+ import { useEffect, useState } from 'react'
5
+ import { firstView, thumbFileOf, type ReviewItem, type ReviewStatus, type ShownStatus, type Side } from '../../../review/model.ts'
6
+ import type { ReviewSt } from './review-st.ts'
7
+
8
+ /** a status's tone, as a class review.css colours: its tint, its line, its badge */
9
+ export const toneOf = (s: ShownStatus): string => `st-${s}`
10
+
11
+ /** the verdicts the owner gives, in the order of their buttons */
12
+ export const VERDICTS: { status: ReviewStatus; label: string; short: string; key: string; glyph: string }[] = [
13
+ { status: 'approved', label: 'Approve', short: 'Approve', key: 'V', glyph: '✓' },
14
+ { status: 'approved-with-fix', label: 'OK with a fix', short: 'Fix', key: 'F', glyph: '⚒' },
15
+ { status: 'rejected', label: 'Reject', short: 'Reject', key: 'R', glyph: '✕' },
16
+ { status: 'archived', label: 'Archive, I will answer later, another way', short: 'Later', key: 'A', glyph: '▤' },
17
+ ]
18
+
19
+ /** the owner's reaction to a view or a tile, in the order of the toggles */
20
+ export const REACTIONS: { status: ReviewStatus; label: string; tip: string; glyph: string }[] = [
21
+ { status: 'approved', label: 'OK', tip: 'this image is right', glyph: '✓' },
22
+ { status: 'approved-with-fix', label: 'Fix', tip: 'almost: a fix', glyph: '⚒' },
23
+ { status: 'rejected', label: 'No', tip: 'this image is wrong', glyph: '✕' },
24
+ ]
25
+
26
+ export const PAIR_NAMES = { base: 'before', mod: 'after' }
27
+ export const SIDE_LABEL: Record<Side, string> = { before: 'before', after: 'after' }
28
+
29
+ export const when = (iso: string): string => new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' })
30
+ /** `02/10 22:39` */
31
+ export const short = (iso: string): string => new Date(iso).toLocaleString(undefined, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
32
+ export const cap = (t: string): string => t.charAt(0).toUpperCase() + t.slice(1)
33
+ /** `1.25 s` */
34
+ export const secs = (t: number): string => `${t.toFixed(2)} s`
35
+
36
+ /** the room above and below a fitted image: a tall one stops there, so it shows whole */
37
+ export const FIT_ROOM = 190
38
+
39
+ /** keeps the focus where it was: a click on a bar never takes the keys V, R, N or Space */
40
+ export const keepFocus = (e: React.MouseEvent): void => e.preventDefault()
41
+
42
+ /** Escape leaves a text field, so V, R and N work again */
43
+ export const blurOnEscape = (e: React.KeyboardEvent<HTMLElement>): void => {
44
+ if (e.key === 'Escape') e.currentTarget.blur()
45
+ }
46
+
47
+ /** a lane's colour, hashed from its name: one lane always one colour, neighbours far apart on the wheel */
48
+ export function laneColor(lane: string): string {
49
+ let h = 2166136261
50
+ for (let i = 0; i < lane.length; i++) h = Math.imul(h ^ lane.charCodeAt(i), 16777619)
51
+ return `hsl(${Math.round(((h >>> 0) * 137.508) % 360)} 55% 58%)`
52
+ }
53
+
54
+ export const LaneTag = ({ lane }: { lane: string }) => (
55
+ <span className="rv-lane" title={`lane ${lane}`} style={{ ['--lane' as string]: laneColor(lane) }}>
56
+ {lane}
57
+ </span>
58
+ )
59
+
60
+ export const AutoTag = ({ rule }: { rule: string }) => (
61
+ <span className="rv-auto" title={`raised by the auto rule ${rule}`}>
62
+ auto
63
+ </span>
64
+ )
65
+
66
+ /** a lane reworking the card: a slow spinner and « in work · L147 » in the lane's colour; nothing when nobody does */
67
+ export const Rework = observer(({ rv, x }: { rv: ReviewSt; x: ReviewItem }) => {
68
+ const r = rv.reworkOf(x)
69
+ const [, redraw] = useState(0)
70
+ const until = r?.until
71
+ useEffect(() => {
72
+ if (until === undefined) return
73
+ const t = setTimeout(() => redraw((n) => n + 1), Math.max(0, until - Date.now()) + 1000)
74
+ return () => clearTimeout(t)
75
+ }, [until])
76
+ if (!r) return null
77
+ return (
78
+ <div className="rv-reworkrow">
79
+ <span className="rv-rework" style={{ ['--lane' as string]: laneColor(r.lane) }} title={`${r.lane} reworks this card since ${when(r.since)}`}>
80
+ <span className="rv-spin" />
81
+ in work · {r.lane}
82
+ </span>
83
+ </div>
84
+ )
85
+ })
86
+
87
+ /** who listens: the pid of each live `sk review watch` that reads his answer on these cards, or « nobody listens » */
88
+ export const Listeners = observer(({ rv, cards }: { rv: ReviewSt; cards: readonly ReviewItem[] }) => {
89
+ const ws = rv.listenersOf(cards)
90
+ const on = ws.length > 0
91
+ return (
92
+ <span
93
+ className={`rv-listen${on ? ' on' : ''}`}
94
+ title={
95
+ on
96
+ ? ws.map((w) => `sk review watch, pid ${w.pid}, ${w.lane ? `lane ${w.lane}` : w.session ? `session ${w.session.slice(0, 8)}` : 'every card'}, since ${when(w.started)}`).join('\n')
97
+ : 'no sk review watch reads these cards: your answer will wait'
98
+ }
99
+ >
100
+ ◉ {on ? ws.map((w) => w.pid).join(' · ') : 'nobody listens'}
101
+ </span>
102
+ )
103
+ })
104
+
105
+ /** an item's thumbnail: its first view's image, else a speech mark (a note with no image) */
106
+ export const ThumbOf = ({ rv, x }: { rv: ReviewSt; x: ReviewItem }) => {
107
+ const first = firstView(x.item)
108
+ if (!first) return <div className="rv-thumbnone">✎</div>
109
+ return <img className="rv-thumbimg" src={rv.thumbUrl(x, thumbFileOf(first))} alt="" loading="lazy" />
110
+ }
111
+
112
+ /** the page's empty pane: one line of dim words */
113
+ export const Empty = ({ text }: { text: string }) => <div className="rv-empty">{text}</div>
@@ -0,0 +1,239 @@
1
+ // the review page's right column: the status line, the card's facts, the linked cards, what goes to the agent, the
2
+ // verdicts (the phone's in a bar under the thumb), then the thread's conversation and his reply box
3
+ import { observer } from 'mobx-react-lite'
4
+ import { aboutText, placeText, STATUS_LABEL, stateOf, verdictOf, viewName, withdrawnOf, type ReviewItem, type ReviewStatus, type ShownStatus } from '../../../review/model.ts'
5
+ import { renderMd } from '../md.ts'
6
+ import { AutoTag, blurOnEscape, cap, Empty, LaneTag, Listeners, REACTIONS, Rework, secs, short, SIDE_LABEL, ThumbOf, toneOf, VERDICTS, when } from './kit.tsx'
7
+ import { AnimPanel, KindView } from './kind-view.tsx'
8
+ import type { ReviewSt } from './review-st.ts'
9
+
10
+ export const ReviewForm = observer(({ rv }: { rv: ReviewSt }) => {
11
+ const x = rv.open
12
+ if (!x) return <Empty text="The open card, its comment and its answer." />
13
+ const r = x.item
14
+ const status = rv.statusOf(x)
15
+ const draft = rv.draft
16
+ const sent = verdictOf(x)
17
+ return (
18
+ <div className="rv-form">
19
+ <StatusLine rv={rv} x={x} status={status} />
20
+ <h3 className="rv-title">{r.title}</h3>
21
+ {r.views.length > 0 && r.what ? <div className="md rv-what" dangerouslySetInnerHTML={{ __html: renderMd(r.what) }} /> : null}
22
+ {r.keeps ? <div className="rv-keeps"><b>keeps:</b> {r.keeps}</div> : null}
23
+ {r.why ? <div className="rv-dim rv-pre">another subject: {r.why}</div> : null}
24
+ <div className="rv-facts">
25
+ <Fact label="subject">{aboutText(r, rv.spec ?? undefined)}</Fact>
26
+ {r.commit ? <Fact label="commit"><span className="rv-mono">{r.commit}</span></Fact> : null}
27
+ <Fact label="id"><span className="rv-mono rv-dim">{r.id}</span></Fact>
28
+ </div>
29
+ <KindView app={rv} x={x} />
30
+ <AnimPanel app={rv} x={x} />
31
+ {rv.newer.map((y) => <Linked key={y.item.id} rv={rv} label="replaced by" id={y.item.id} item={y} />)}
32
+ {r.supersedes && rv.history.length === 0 ? <Linked rv={rv} label="replaces" id={r.supersedes} item={null} /> : null}
33
+ {rv.answered.map((y) => <Linked key={y.item.id} rv={rv} label="answers" id={y.item.id} item={y} />)}
34
+ {rv.answeredBy ? <Linked rv={rv} label="answered by" id={rv.answeredBy.item.id} item={rv.answeredBy} /> : null}
35
+ {rv.history.length > 0 ? <div className="rv-divider">earlier versions</div> : null}
36
+ {rv.history.map((y, i) => <Linked key={y.item.id} rv={rv} label={`v${rv.history.length - i}`} id={y.item.id} item={y} />)}
37
+ <Outgoing rv={rv} />
38
+ <label className="rv-label">Comment</label>
39
+ <textarea className="rv-comment" rows={3} placeholder="the overall word" value={draft.comment} onKeyDown={blurOnEscape} onChange={(e) => rv.setComment(e.currentTarget.value)} />
40
+ <div className="rv-listenrow"><span className="rv-dim">who reads your answer</span><Listeners rv={rv} cards={[x]} /></div>
41
+ {!rv.phone.on && !r.wip && r.origin.by === 'agent' ? (
42
+ <div className="rv-verdicts">
43
+ {VERDICTS.map((v) => <VerdictButton key={v.status} v={v} sent={sent?.status} busy={rv.busy} send={() => void rv.submit(v.status)} />)}
44
+ </div>
45
+ ) : null}
46
+ <Messages rv={rv} />
47
+ <div className="rv-formfoot">
48
+ <button className="ghost" onClick={() => rv.nextPending()}>next waiting (N)</button>
49
+ {rv.dirty && sent ? <button className="ghost" onClick={() => rv.dropDraft()}>back to the answer sent</button> : null}
50
+ </div>
51
+ {sent && rv.dirty ? <div className="rv-dim">The changes are not sent yet: a verdict sends them.</div> : null}
52
+ {!sent && rv.dirty ? <div className="rv-dim">Draft kept in this browser until it is sent.</div> : null}
53
+ {rv.error ? <div className="rv-err rv-pre">{rv.error}</div> : null}
54
+ </div>
55
+ )
56
+ })
57
+
58
+ const Fact = ({ label, children }: { label: string; children: React.ReactNode }) => (
59
+ <div className="rv-fact"><span className="rv-factl">{label}</span><span className="rv-factv">{children}</span></div>
60
+ )
61
+
62
+ const RAISED_NOW: Record<string, string> = {
63
+ new: 'waiting for a dispatcher',
64
+ question: 'a question for you',
65
+ blocked: 'blocked: it waits on your decision',
66
+ }
67
+
68
+ /** the answer at a glance, in its status colour: the verdict sent and its time, or that it waits */
69
+ const StatusLine = observer(({ rv, x, status }: { rv: ReviewSt; x: ReviewItem; status: ShownStatus }) => {
70
+ const fb = verdictOf(x)
71
+ const withdrawn = withdrawnOf(x)
72
+ const o = x.item.origin
73
+ if (o.by !== 'agent') {
74
+ const st = stateOf(x, false)
75
+ const by = o.by === 'player' ? `by ${o.player}` : o.by === 'auto' ? `by the auto rule ${o.rule}` : 'by you'
76
+ const now =
77
+ st.state === 'open' ? `sent to ${st.topic ?? 'a topic'}, waiting for an agent`
78
+ : st.state === 'claimed' ? `taken by ${st.worker ?? 'an agent'}`
79
+ : st.state === 'closed' ? (st.why === 'wontfix' ? 'closed, not done' : st.why === 'superseded' ? 'replaced by a card' : st.why === 'rated' ? 'rated' : 'done')
80
+ : RAISED_NOW[st.state] ?? st.state
81
+ return (
82
+ <div className={`rv-status ${toneOf(status)}`}>
83
+ <div className="rv-statushead">{cap(`raised ${by} · ${now}`)} {o.by === 'auto' ? <><LaneTag lane={o.lane} /><AutoTag rule={o.rule} /></> : null}</div>
84
+ <div className="rv-dim">on {when(x.item.created)}</div>
85
+ <Rework rv={rv} x={x} />
86
+ </div>
87
+ )
88
+ }
89
+ const head = fb
90
+ ? `Your answer: ${cap(STATUS_LABEL[fb.status])}, ${short(fb.at)}`
91
+ : status === 'superseded'
92
+ ? 'Replaced by a newer version'
93
+ : withdrawn !== undefined
94
+ ? `Withdrawn by the agent: ${withdrawn}`
95
+ : x.item.wip
96
+ ? 'In progress: the agent works on it'
97
+ : 'Waiting for your answer'
98
+ const sub = fb ? (fb.seen ? 'read by the agent' : 'not read by the agent yet') : `sent on ${when(x.item.created)}`
99
+ return (
100
+ <div className={`rv-status ${toneOf(status)}`}>
101
+ <div className="rv-statushead">{head}</div>
102
+ <div className="rv-dim">{sub}</div>
103
+ <Rework rv={rv} x={x} />
104
+ </div>
105
+ )
106
+ })
107
+
108
+ /** one verdict: plain until one is sent, then the chosen one filled and the others dimmed */
109
+ const VerdictButton = ({ v, sent, busy, send }: { v: (typeof VERDICTS)[number]; sent: ReviewStatus | undefined; busy: boolean; send: () => void }) => (
110
+ <button className={`rv-verdict ${toneOf(v.status)}${sent === v.status ? ' chosen' : ''}${sent && sent !== v.status ? ' off' : ''}`} disabled={busy} onClick={send}>
111
+ <span className="rv-vglyph">{sent === v.status ? '✓' : v.glyph}</span> {v.label} ({v.key}){sent === v.status ? ' · chosen' : ''}
112
+ </button>
113
+ )
114
+
115
+ /** the phone's verdicts: one row stuck to the bottom of the open card, under the thumb */
116
+ export const VerdictBar = observer(({ rv }: { rv: ReviewSt }) => {
117
+ const x = rv.open
118
+ if (!x || x.item.wip || x.item.origin.by !== 'agent') return null
119
+ const sent = verdictOf(x)?.status
120
+ return (
121
+ <div className="rv-verdictbar">
122
+ {VERDICTS.map((v) => (
123
+ <button key={v.status} className={`rv-verdict ${toneOf(v.status)}${sent === v.status ? ' chosen' : ''}${sent && sent !== v.status ? ' off' : ''}`} disabled={rv.busy}
124
+ onClick={() => void rv.submit(v.status)}>
125
+ {sent === v.status ? '✓' : v.glyph} {v.short}
126
+ </button>
127
+ ))}
128
+ </div>
129
+ )
130
+ })
131
+
132
+ /** the card this one replaces, or the one replacing it: a click opens it, its answer shown here */
133
+ const Linked = observer(({ rv, label, id, item }: { rv: ReviewSt; label: string; id: string; item: ReviewItem | null }) => {
134
+ if (!item) return <div className="rv-err">{label} {id}: not found</div>
135
+ const status = rv.statusOf(item)
136
+ const fb = verdictOf(item)
137
+ return (
138
+ <div className={`rv-linked ${toneOf(status)}`}>
139
+ <div className="rv-linkedrow">
140
+ <div className="rv-thumb xs" onClick={() => rv.pick(item.item.id)}><ThumbOf rv={rv} x={item} /></div>
141
+ <span className="rv-dim">{label}</span>
142
+ <a className="rv-linkedtitle" onClick={() => rv.pick(item.item.id)}>{item.item.title}</a>
143
+ <span className="rv-badge">{STATUS_LABEL[status]}</span>
144
+ </div>
145
+ {fb?.comment ? <div className="rv-pre rv-small">{fb.comment}</div> : null}
146
+ {fb?.rects.map((q, i) => (q.comment ? (
147
+ <div key={i} className="rv-dim rv-small">{i + 1}{q.side ? ` ${SIDE_LABEL[q.side]}` : ''}{q.t !== undefined ? ` · ${secs(q.t)}` : ''}: {q.comment}</div>
148
+ ) : null))}
149
+ </div>
150
+ )
151
+ })
152
+
153
+ /** scrolls the middle column to a view, or to one tile of a gallery */
154
+ function jumpTo(view: number, tile?: number): void {
155
+ document.getElementById(tile === undefined ? `review-view-${view}` : `review-tile-${view}-${tile}`)?.scrollIntoView({ behavior: 'smooth', block: tile === undefined ? 'start' : 'center' })
156
+ }
157
+
158
+ /** « what goes to the agent »: every reaction to a view or a tile, then every rectangle, each by its view's label; a
159
+ * click on its badge scrolls to it, its comment edits in place, the cross removes it */
160
+ const Outgoing = observer(({ rv }: { rv: ReviewSt }) => {
161
+ const x = rv.open
162
+ if (!x) return null
163
+ const views = x.item.views
164
+ const draft = rv.draft
165
+ const reactions = rv.outgoingReactions
166
+ const empty = reactions.length === 0 && draft.rects.length === 0
167
+ return (
168
+ <div className="rv-outgoing">
169
+ <div className="rv-divider">what goes to the agent</div>
170
+ {empty ? (
171
+ <div className="rv-dim rv-small">
172
+ {rv.clipKey
173
+ ? 'React to a view (OK, fix, no, a word), or drag on the clip to circle a detail: it pauses, the rectangle keeps its moment.'
174
+ : rv.phone.on
175
+ ? 'React to a view (OK, fix, no, a word), or turn « Circle » on above the image, then drag to circle a detail.'
176
+ : 'React to a view (OK, fix, no, a word), or drag on the image to circle a detail.'}
177
+ </div>
178
+ ) : null}
179
+ {reactions.map((q) => {
180
+ const o = REACTIONS.find((r) => r.status === q.status)
181
+ return (
182
+ <div key={`${q.view}/${q.tile ?? ''}`} className="rv-outrow">
183
+ <button className={`rv-outbadge${q.status ? ` re-${q.status} on` : ''}`} title="see this image" onClick={() => { rv.setView(q.view); jumpTo(q.view, q.tile) }}>
184
+ {o?.glyph ?? '·'} {placeText(q, views).slice(1, -1)}
185
+ </button>
186
+ <textarea rows={1} placeholder={o ? `${o.label}, with no word` : 'a word'} value={q.comment} onKeyDown={blurOnEscape}
187
+ onChange={(e) => rv.setReactionComment(q.view, q.tile, e.currentTarget.value)} />
188
+ <button className="ghost rv-x" aria-label="remove this reaction" onClick={() => rv.removeReaction(q.view, q.tile)}>✕</button>
189
+ </div>
190
+ )
191
+ })}
192
+ {draft.rects.map((q, i) => (
193
+ <div key={`rect${i}`} className="rv-outrow">
194
+ <button className={`rv-outbadge rect${rv.rect === i ? ' on' : ''}`} title="see this rectangle" onClick={() => { rv.pickRect(i); jumpTo(q.view ?? 0) }}>
195
+ ⬚ {i + 1}{views.length > 1 ? ` · ${viewName(views[q.view ?? 0], q.view ?? 0)}` : ''}{q.side ? ` ${SIDE_LABEL[q.side]}` : ''}{q.t !== undefined ? ` · ${secs(q.t)}` : ''}
196
+ </button>
197
+ <textarea rows={1} placeholder="what is wrong here" value={q.comment} onFocus={() => rv.pickRect(i)} onKeyDown={blurOnEscape}
198
+ onChange={(e) => rv.setRectComment(i, e.currentTarget.value)} />
199
+ <button className="ghost rv-x" aria-label="delete this rectangle" onClick={() => rv.removeRect(i)}>✕</button>
200
+ </div>
201
+ ))}
202
+ </div>
203
+ )
204
+ })
205
+
206
+ /** the thread's conversation, every version's and every answered item's messages in time order, then his reply box
207
+ * (⌘Enter sends) */
208
+ const Messages = observer(({ rv }: { rv: ReviewSt }) => {
209
+ const conv = rv.conversation
210
+ const versions = rv.openThread?.versions ?? []
211
+ const vOf = (id: string): number => versions.length - versions.findIndex((x) => x.item.id === id)
212
+ const multi = new Set(conv.map((m) => m.on)).size > 1
213
+ return (
214
+ <div className="rv-messages">
215
+ <div className="rv-divider">talk</div>
216
+ {conv.length === 0 ? <div className="rv-dim rv-small">No message. An agent answers with sk review reply.</div> : null}
217
+ {conv.map((m) => {
218
+ const mine = m.who === 'owner'
219
+ return (
220
+ <div key={`${m.on}/${m.id}`} className={`rv-msg${mine ? ' mine' : ''}`}>
221
+ <div className="rv-dim rv-small">{mine ? 'you' : m.who} · {short(m.at)}{multi ? ` · v${vOf(m.on)}` : ''}</div>
222
+ <div className="rv-pre">{m.text}</div>
223
+ {m.image ? (
224
+ <a href={rv.threadImageUrl(m.on, m.image)} target="_blank" rel="noreferrer"><img className="rv-msgimg" src={rv.threadImageUrl(m.on, m.image)} alt="" /></a>
225
+ ) : null}
226
+ </div>
227
+ )
228
+ })}
229
+ <textarea className="rv-reply" rows={2} placeholder="Answer the agent (⌘Enter sends)" value={rv.reply} onChange={(e) => rv.setReply(e.currentTarget.value)}
230
+ onKeyDown={(e) => {
231
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
232
+ e.preventDefault()
233
+ void rv.sendReply()
234
+ } else blurOnEscape(e)
235
+ }} />
236
+ <div className="rv-replyrow"><button className="primary" disabled={!rv.reply.trim() || rv.busy} onClick={() => void rv.sendReply()}>Reply</button></div>
237
+ </div>
238
+ )
239
+ })
@@ -0,0 +1,46 @@
1
+ // the dispatcher's board (wiki build/shipkit/review.md, Raised items): what the owner, a player or a rule raised, by state,
2
+ // each with its topic and its worker, a stale claim marked; who holds the dispatcher role. A row opens its item
3
+ import { observer } from 'mobx-react-lite'
4
+ import { isMarkItem, isStale, stateOf, topicLabel, type State } from '../../../review/model.ts'
5
+ import type { ReviewSt } from './review-st.ts'
6
+
7
+ const STATES: { state: State; label: string }[] = [
8
+ { state: 'new', label: 'new: waits on the dispatcher' },
9
+ { state: 'question', label: 'a question for you' },
10
+ { state: 'blocked', label: 'blocked on you' },
11
+ { state: 'open', label: 'open: waits on a worker' },
12
+ { state: 'claimed', label: 'claimed' },
13
+ ]
14
+
15
+ export const ReviewBoard = observer(({ rv }: { rv: ReviewSt }) => {
16
+ const spec = rv.spec
17
+ const now = new Date()
18
+ const rows = rv.items.filter((x) => x.item.origin.by !== 'agent' && !isMarkItem(x)).map((x) => ({ x, st: stateOf(x, rv.followed.has(x.item.id)) }))
19
+ const role = spec?.role
20
+ return (
21
+ <div className="rv-listscroll rv-board">
22
+ <div className="rv-count">{role ? `dispatcher: ${role.name}, since ${role.since.slice(0, 16).replace('T', ' ')}` : 'dispatcher: nobody (sk review dispatcher --as <name>)'}</div>
23
+ {STATES.map(({ state, label }) => {
24
+ const of = rows.filter((r) => r.st.state === state)
25
+ if (of.length === 0) return null
26
+ return (
27
+ <div key={state}>
28
+ <div className="rv-divider">{label} · {of.length}</div>
29
+ {of.map(({ x, st }) => (
30
+ <div key={x.item.id} className={`rv-boardrow${rv.open?.item.id === x.item.id ? ' on' : ''}`} onClick={() => rv.pick(x.item.id)}>
31
+ <div className="rv-linkedtitle">{x.item.title}</div>
32
+ <div className="rv-dim">
33
+ {st.topic ? topicLabel(st.topic, spec ?? undefined) : 'no topic'}
34
+ {st.worker ? ` · ${st.worker}` : ''}
35
+ {isStale(x, now) ? ' · stale claim' : ''}
36
+ {x.item.origin.by === 'player' ? ` · ${x.item.origin.server}#${x.item.origin.serverId}` : x.item.origin.by === 'auto' ? ` · auto ${x.item.origin.rule}` : ''}
37
+ </div>
38
+ </div>
39
+ ))}
40
+ </div>
41
+ )
42
+ })}
43
+ {rows.every((r) => r.st.state === 'closed') ? <div className="rv-empty">Nothing raised is open.</div> : null}
44
+ </div>
45
+ )
46
+ })