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.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/cli/cmd_Commands.ts +5 -0
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +259 -6
- package/src/cli/kit/verb.ts +14 -4
- package/src/cli.ts +6 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +9 -5
- package/src/trailer/adapter.ts +58 -3
- package/src/trailer/build.ts +300 -41
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/footage.ts +109 -40
- package/src/trailer/grammar.ts +444 -69
- package/src/trailer/graph.ts +199 -14
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +17 -3
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +80 -21
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +123 -18
- package/src/trailer/vocab.ts +23 -4
- 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
|
+
})
|