@marcomigozzi/trailhead 0.13.0 → 0.14.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/README.it.md +28 -3
- package/README.md +38 -3
- package/bin/lib/codex-projection.js +16 -9
- package/bin/lib/codex-projection.test.js +39 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/trailhead.js +37 -25
- package/bin/trailhead.test.js +162 -5
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
- package/plugins/trailhead/hooks/mod/README.md +187 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
- package/plugins/trailhead/hooks/mod/register.tsx +280 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
- package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +3 -1
- package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
- package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
- package/plugins/trailhead/types/index.d.ts +69 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
// The map pane (#208): a docked Pane listing the active map's open tickets by state.
|
|
2
|
+
// It reads the active map from `.trailhead/active-map` and the tickets through `gh`, only
|
|
3
|
+
// while the pane is open, and never faster than MIN_REFRESH_MS. Claude Code 2.1.287+ only.
|
|
4
|
+
import { atom, read, update } from 'claude-code'
|
|
5
|
+
import type { EngineInterface, On } from 'claude-code'
|
|
6
|
+
|
|
7
|
+
import type { BurndownHistory, MapPaneData, MapSummary, PaneTicket, WorkState } from '../../types'
|
|
8
|
+
import {
|
|
9
|
+
ageLabel,
|
|
10
|
+
axisStart,
|
|
11
|
+
type BurndownSeries,
|
|
12
|
+
burndownEnd,
|
|
13
|
+
burndownSeries,
|
|
14
|
+
failureReason,
|
|
15
|
+
footerStatus,
|
|
16
|
+
groupTickets,
|
|
17
|
+
HISTORY_LIMIT,
|
|
18
|
+
LIST_LIMIT,
|
|
19
|
+
mapRowLayout,
|
|
20
|
+
nextViewed,
|
|
21
|
+
parseActiveMap,
|
|
22
|
+
parseHistory,
|
|
23
|
+
parseMap,
|
|
24
|
+
parseMaps,
|
|
25
|
+
parseTickets,
|
|
26
|
+
rasterCells,
|
|
27
|
+
refreshDecision,
|
|
28
|
+
rowLayout,
|
|
29
|
+
sparkline,
|
|
30
|
+
viewTarget,
|
|
31
|
+
} from './map-pane-view'
|
|
32
|
+
import { fit, isMissing, sameValue } from './work-state-view'
|
|
33
|
+
|
|
34
|
+
export const PANE_ID = 'trailhead-map'
|
|
35
|
+
export const PANE_TITLE = 'Map'
|
|
36
|
+
export const PANEL_COMMAND = 'trailhead:panel'
|
|
37
|
+
export const MIN_REFRESH_MS = 15_000
|
|
38
|
+
export const TIMER_MS = 180_000
|
|
39
|
+
export const GH_TIMEOUT_MS = 20_000
|
|
40
|
+
// A list unchanged for this long is still written, so its age label moves on.
|
|
41
|
+
const AGE_REFRESH_MS = 60_000
|
|
42
|
+
const ACTIVE_MAP_PATH = '.trailhead/active-map'
|
|
43
|
+
const TICKET_FIELDS = 'number,title,labels,assignees,url'
|
|
44
|
+
const MAP_FIELDS = 'title,body,url'
|
|
45
|
+
const HISTORY_FIELDS = 'number,labels,createdAt,closedAt'
|
|
46
|
+
const MAPS_FIELDS = 'number,title,url'
|
|
47
|
+
const MAPS_LIMIT = 100
|
|
48
|
+
// Same colours as the band.
|
|
49
|
+
const ACCENT = '#7aa2f7'
|
|
50
|
+
// The accent as a Raster colour (0x00RRGGBB), and the chart height in terminal rows.
|
|
51
|
+
const ACCENT_RGB = 0x7aa2f7
|
|
52
|
+
const CHART_ROWS = 3
|
|
53
|
+
// Space between ticket rows on the desktop, whose native buttons would touch; the terminal stays compact.
|
|
54
|
+
const DESKTOP_ROW_GAP = 0.5
|
|
55
|
+
const AMBER = '#e0af68'
|
|
56
|
+
|
|
57
|
+
const EMPTY: MapPaneData = {
|
|
58
|
+
map: null,
|
|
59
|
+
tickets: [],
|
|
60
|
+
maps: [],
|
|
61
|
+
unverifiedCount: 0,
|
|
62
|
+
isCapped: false,
|
|
63
|
+
history: null,
|
|
64
|
+
historyAt: null,
|
|
65
|
+
fetchedAt: null,
|
|
66
|
+
error: null,
|
|
67
|
+
isLoading: false,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const mapPane = atom({ plugin: 'trailhead', key: 'mapPane' } as const, EMPTY)
|
|
71
|
+
export const activeMap = atom({ plugin: 'trailhead', key: 'activeMap' } as const, null as number | null)
|
|
72
|
+
// The map shown when it is not the active one (a click on the maps list); null follows the active map.
|
|
73
|
+
// The pane only reads `.trailhead/active-map`; the work verb is what changes it.
|
|
74
|
+
export const viewedMap = atom({ plugin: 'trailhead', key: 'viewedMap' } as const, null as number | null)
|
|
75
|
+
// The ticket whose `work` press is queued; one at a time.
|
|
76
|
+
export const pendingWork = atom({ plugin: 'trailhead', key: 'pendingWork' } as const, null as number | null)
|
|
77
|
+
// Set once the pane was opened by the command, by itself or closed by the person: never auto-opened after.
|
|
78
|
+
export const paneAutoOpened = atom({ plugin: 'trailhead', key: 'paneAutoOpened' } as const, false)
|
|
79
|
+
const PENDING = { plugin: 'trailhead', key: 'pendingWork' } as const
|
|
80
|
+
const WORK_COMMAND = 'trailhead:work'
|
|
81
|
+
// The built-in that empties the conversation; the work verb starts in a clean one.
|
|
82
|
+
const CLEAR_COMMAND = 'clear'
|
|
83
|
+
// The same refs the band's atoms are written under; read here to mark the ticket in play.
|
|
84
|
+
const workState = atom({ plugin: 'trailhead', key: 'workState' } as const, null as WorkState | null)
|
|
85
|
+
const isOrphaned = atom({ plugin: 'trailhead', key: 'isOrphaned' } as const, false)
|
|
86
|
+
|
|
87
|
+
type Dollar = EngineInterface
|
|
88
|
+
type Timer = { cancel: () => void }
|
|
89
|
+
|
|
90
|
+
const debug = ($: Dollar, message: string) => {
|
|
91
|
+
try {
|
|
92
|
+
$.ui.log(message, { to: 'debug' })
|
|
93
|
+
} catch {
|
|
94
|
+
// No log sink: nothing to tell.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Module state, only for the machinery (queue, timers); everything drawn lives in atoms.
|
|
99
|
+
// A hot reload resets it, which costs at most one early refresh.
|
|
100
|
+
let queue: Promise<void> = Promise.resolve()
|
|
101
|
+
let generation = 0
|
|
102
|
+
let lastStartedAt: number | null = null
|
|
103
|
+
let trailing: Timer | null = null
|
|
104
|
+
let ticker: Timer | null = null
|
|
105
|
+
// What /clear would take from the pane, kept across it (module variables survive it, atoms do not).
|
|
106
|
+
type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null }
|
|
107
|
+
let carried: Carried | null = null
|
|
108
|
+
// The ticket whose `work` press is running: /clear empties the pendingWork atom meanwhile.
|
|
109
|
+
let workInFlight: number | null = null
|
|
110
|
+
|
|
111
|
+
async function readActiveMap($: Dollar): Promise<number | null> {
|
|
112
|
+
let next: number | null
|
|
113
|
+
try {
|
|
114
|
+
next = parseActiveMap((await $.fs.read(ACTIVE_MAP_PATH)) as string)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// A missing marker means no active map; any other failure is transient: keep the last one.
|
|
117
|
+
if (!isMissing(err)) return read($, activeMap)
|
|
118
|
+
next = null
|
|
119
|
+
}
|
|
120
|
+
if ((await read($, activeMap)) !== next) {
|
|
121
|
+
await update($, activeMap, () => next)
|
|
122
|
+
// The active map moved on (the work verb switched it): the view follows it again.
|
|
123
|
+
await update($, viewedMap, () => null)
|
|
124
|
+
}
|
|
125
|
+
return next
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function setPane($: Dollar, next: MapPaneData) {
|
|
129
|
+
if (!sameValue(await read($, mapPane), next)) await update($, mapPane, () => next)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type GhResult = { ok: true; stdout: string } | { ok: false; reason: string }
|
|
133
|
+
|
|
134
|
+
async function gh($: Dollar, args: readonly string[]): Promise<GhResult> {
|
|
135
|
+
try {
|
|
136
|
+
const out = await $.process.run(['gh', ...args], { timeoutMs: GH_TIMEOUT_MS })
|
|
137
|
+
return out.exitCode === 0 ? { ok: true, stdout: out.stdout } : { ok: false, reason: failureReason(out) }
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return { ok: false, reason: failureReason({ error }) }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const readList = ($: Dollar, target: number) =>
|
|
144
|
+
Promise.all([
|
|
145
|
+
gh($, ['issue', 'view', String(target), '--json', MAP_FIELDS]),
|
|
146
|
+
gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS]),
|
|
147
|
+
gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'all', '--limit', String(HISTORY_LIMIT), '--json', HISTORY_FIELDS]),
|
|
148
|
+
readMaps($),
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
// The open maps of the repo. A failed or unreadable read is null: the caller keeps the last good list.
|
|
152
|
+
const readMaps = async ($: Dollar): Promise<readonly MapSummary[] | null> => {
|
|
153
|
+
const out = await gh($, ['issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', String(MAPS_LIMIT), '--json', MAPS_FIELDS])
|
|
154
|
+
return out.ok ? parseMaps(out.stdout) : null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// What the pane compares to skip a write: times aside, but whether the history is as fresh as the list.
|
|
158
|
+
const shape = (d: MapPaneData) => ({ ...d, fetchedAt: 0, historyAt: d.historyAt === d.fetchedAt })
|
|
159
|
+
|
|
160
|
+
// The map a fetch reads: the viewed one, else the active one.
|
|
161
|
+
const readTarget = async ($: Dollar): Promise<number | null> => {
|
|
162
|
+
const active = await readActiveMap($)
|
|
163
|
+
return viewTarget(active, await read($, viewedMap))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A viewed map that left the freshly read open list (it closed meanwhile) is no longer viewed.
|
|
167
|
+
async function dropClosedView($: Dollar, maps: readonly MapSummary[] | null) {
|
|
168
|
+
const viewed = await read($, viewedMap)
|
|
169
|
+
if (nextViewed(viewed, { activeChanged: false, maps }) !== viewed) await update($, viewedMap, () => null)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// One read of the viewed (else active) map. The result is dropped when the pane closed meanwhile
|
|
173
|
+
// (generation) or the target moved on, checked before every write; a moved target is read again.
|
|
174
|
+
async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
|
|
175
|
+
const target = await readTarget($)
|
|
176
|
+
if (token !== generation) return
|
|
177
|
+
const isStale = async () => {
|
|
178
|
+
if (token !== generation) return true
|
|
179
|
+
if ((await readTarget($)) === target) return false
|
|
180
|
+
wantsAnother()
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
const before = await read($, mapPane)
|
|
184
|
+
|
|
185
|
+
if (target === null) {
|
|
186
|
+
// No map to show: the open maps alone, so one can be picked.
|
|
187
|
+
const maps = (await readMaps($)) ?? before.maps
|
|
188
|
+
if (await isStale()) return
|
|
189
|
+
return setPane($, { ...EMPTY, maps })
|
|
190
|
+
}
|
|
191
|
+
const isSameMap = before.map?.number === target
|
|
192
|
+
if (!isSameMap) {
|
|
193
|
+
if (await isStale()) return
|
|
194
|
+
await setPane($, { ...EMPTY, maps: before.maps, isLoading: true })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const [view, list, historyRead, mapsRead] = await readList($, target)
|
|
198
|
+
const maps = mapsRead ?? before.maps
|
|
199
|
+
await dropClosedView($, mapsRead)
|
|
200
|
+
const now = await $.clock.now()
|
|
201
|
+
if (await isStale()) return
|
|
202
|
+
|
|
203
|
+
const kept = isSameMap ? before : EMPTY
|
|
204
|
+
const failure = !view.ok ? view.reason : !list.ok ? list.reason : null
|
|
205
|
+
if (failure !== null) return setPane($, { ...kept, maps, error: failure, isLoading: false })
|
|
206
|
+
const map = view.ok ? parseMap(view.stdout, target) : null
|
|
207
|
+
const tickets = list.ok ? parseTickets(list.stdout) : null
|
|
208
|
+
if (map === null || tickets === null) return setPane($, { ...kept, maps, error: 'gh returned unreadable output', isLoading: false })
|
|
209
|
+
|
|
210
|
+
// A history that failed alone keeps the last good one of this map (with its own time), never another map's.
|
|
211
|
+
const history = historyRead.ok ? parseHistory(historyRead.stdout) : null
|
|
212
|
+
const historyPart = history !== null ? { history, historyAt: now } : { history: kept.history, historyAt: kept.historyAt }
|
|
213
|
+
const next: MapPaneData = { map, ...tickets, maps, ...historyPart, fetchedAt: now, error: null, isLoading: false }
|
|
214
|
+
const isFresh = before.fetchedAt !== null && now - before.fetchedAt < AGE_REFRESH_MS
|
|
215
|
+
if (isFresh && isSameMap && sameValue(shape(before), shape(next))) return
|
|
216
|
+
await setPane($, next)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function isPaneUp($: Dollar): Promise<boolean> {
|
|
220
|
+
return (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function stopTimers() {
|
|
224
|
+
generation += 1
|
|
225
|
+
// A fill timer dropped with the pane must not leave the next pane unfillable.
|
|
226
|
+
isFilling = false
|
|
227
|
+
trailing?.cancel()
|
|
228
|
+
trailing = null
|
|
229
|
+
ticker?.cancel()
|
|
230
|
+
ticker = null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// `bypassFloor`: the caller's last result was dropped unseen, so reading again is not a repeat.
|
|
234
|
+
type Request = { bypassFloor?: boolean }
|
|
235
|
+
|
|
236
|
+
// Throttled: inside the floor it keeps ONE trailing refresh for the rest of it, so an event
|
|
237
|
+
// just after a refresh still shows.
|
|
238
|
+
async function decide($: Dollar, request: Request) {
|
|
239
|
+
if (!(await isPaneUp($))) return
|
|
240
|
+
ticker ??= $.clock.every(TIMER_MS, () => void requestRefresh($))
|
|
241
|
+
const now = await $.clock.now()
|
|
242
|
+
const decision = request.bypassFloor === true ? { run: true as const } : refreshDecision(now, lastStartedAt, MIN_REFRESH_MS)
|
|
243
|
+
if (!decision.run) {
|
|
244
|
+
if (trailing === null) {
|
|
245
|
+
trailing = $.clock.after(decision.waitMs, () => {
|
|
246
|
+
trailing = null
|
|
247
|
+
void requestRefresh($)
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
lastStartedAt = now
|
|
253
|
+
trailing?.cancel()
|
|
254
|
+
trailing = null
|
|
255
|
+
await fetchMap($, generation, () => void requestRefresh($, { bypassFloor: true }))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Does nothing while the pane is closed: the data exists only for the pane.
|
|
259
|
+
export function requestRefresh($: Dollar, request: Request = {}): Promise<void> {
|
|
260
|
+
queue = queue.then(() => decide($, request)).catch(err => debug($, `trailhead mod: map pane refresh failed: ${String(err)}`))
|
|
261
|
+
return queue
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Local file read, no `gh`: the active map is known whether or not the pane is open.
|
|
265
|
+
const safeReadActiveMap = ($: Dollar) =>
|
|
266
|
+
readActiveMap($).catch(err => debug($, `trailhead mod: active map read failed: ${String(err)}`))
|
|
267
|
+
|
|
268
|
+
async function carryOverClear($: Dollar) {
|
|
269
|
+
carried = {
|
|
270
|
+
isAutoOpened: await read($, paneAutoOpened),
|
|
271
|
+
viewed: await read($, viewedMap),
|
|
272
|
+
active: await read($, activeMap),
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The atoms are empty again: put back what the person had set, then fill the pane without waiting
|
|
277
|
+
// for a turn. The active map is read first (its change from null would reset the view), and a view
|
|
278
|
+
// comes back only when the active map is the one it was set against.
|
|
279
|
+
async function restoreAfterClear($: Dollar) {
|
|
280
|
+
const kept = carried
|
|
281
|
+
carried = null
|
|
282
|
+
try {
|
|
283
|
+
if (kept?.isAutoOpened === true) await update($, paneAutoOpened, () => true)
|
|
284
|
+
const active = await readActiveMap($)
|
|
285
|
+
const viewed = kept?.viewed ?? null
|
|
286
|
+
if (viewed !== null && viewed !== active && active === kept?.active) await update($, viewedMap, () => viewed)
|
|
287
|
+
} catch (err) {
|
|
288
|
+
debug($, `trailhead mod: map pane restore after clear failed: ${String(err)}`)
|
|
289
|
+
}
|
|
290
|
+
void requestRefresh($, { bypassFloor: true })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Edits of the tracker through the gh CLI: the narrow set worth a refresh right away.
|
|
294
|
+
const GH_ISSUE_WRITE = /\bgh\b[^\n;&|]*\bissue\s+(edit|close|create|comment|reopen)\b/
|
|
295
|
+
|
|
296
|
+
// Clears the conversation, then runs `/trailhead:work <n>` in the new one; the host queues both
|
|
297
|
+
// behind a running turn. The claim is taken with the version it read, so two presses never both
|
|
298
|
+
// pass, and `workInFlight` holds the next ones off while the clear has emptied the atom.
|
|
299
|
+
// Cleared once the calls settle.
|
|
300
|
+
async function startWork($: Dollar, number: number) {
|
|
301
|
+
if (workInFlight !== null) return
|
|
302
|
+
const { value, version } = await $.state.get(PENDING)
|
|
303
|
+
if (value != null) return
|
|
304
|
+
if (!(await $.state.set(PENDING, number, { ifVersion: version })).isSet) return
|
|
305
|
+
workInFlight = number
|
|
306
|
+
try {
|
|
307
|
+
const before = await $.session.id()
|
|
308
|
+
await $.command.run({ command: CLEAR_COMMAND, args: '' })
|
|
309
|
+
// A clear another hook answered without running resolves too: the verb must not start in the old conversation.
|
|
310
|
+
if ((await $.session.id()) === before) return debug($, `trailhead mod: work ${number} skipped, the conversation was not cleared`)
|
|
311
|
+
// The clear emptied the atom: the row is still queued in the new session.
|
|
312
|
+
await update($, pendingWork, () => number)
|
|
313
|
+
await $.command.run({ command: WORK_COMMAND, args: String(number) })
|
|
314
|
+
} catch (err) {
|
|
315
|
+
debug($, `trailhead mod: work ${number} did not start: ${String(err)}`)
|
|
316
|
+
} finally {
|
|
317
|
+
workInFlight = null
|
|
318
|
+
await update($, pendingWork, () => null)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// A click on a map of the list: show it, or (the active map, or null) go back to following the active one.
|
|
323
|
+
// A view change is not a repeat read, so the floor does not apply.
|
|
324
|
+
async function viewMap($: Dollar, number: number | null) {
|
|
325
|
+
const active = await read($, activeMap)
|
|
326
|
+
await update($, viewedMap, () => (number === active ? null : number))
|
|
327
|
+
void requestRefresh($, { bypassFloor: true })
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The press handler of a map click or the back button: a failed view change is logged, never thrown.
|
|
331
|
+
const pressView = ($: Dollar, number: number | null): void => {
|
|
332
|
+
void viewMap($, number).catch(err => debug($, `trailhead mod: map view failed: ${String(err)}`))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Opening always refreshes. A render cannot write state, not even from work it left running,
|
|
336
|
+
// so the auto-open runs from a zero-delay timer, a dispatch of its own; `isOpening` keeps two
|
|
337
|
+
// renders from opening twice before the flag is written.
|
|
338
|
+
let isOpening = false
|
|
339
|
+
// Same for the fill an empty pane asks for from its render.
|
|
340
|
+
let isFilling = false
|
|
341
|
+
|
|
342
|
+
async function openPane($: Dollar) {
|
|
343
|
+
await update($, paneAutoOpened, () => true)
|
|
344
|
+
const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
|
|
345
|
+
if (opened.isPlaced) void requestRefresh($)
|
|
346
|
+
return opened
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function autoOpen($: Dollar) {
|
|
350
|
+
if (isOpening) return
|
|
351
|
+
isOpening = true
|
|
352
|
+
try {
|
|
353
|
+
if (!(await $.ui.panes()).some(pane => pane.id === PANE_ID)) await openPane($)
|
|
354
|
+
// Found up already (a reload with the pane showing): nothing to open, and renders stop asking.
|
|
355
|
+
else await update($, paneAutoOpened, () => true)
|
|
356
|
+
} catch (err) {
|
|
357
|
+
debug($, `trailhead mod: map pane auto-open failed: ${String(err)}`)
|
|
358
|
+
} finally {
|
|
359
|
+
isOpening = false
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function togglePanel($: Dollar): Promise<string> {
|
|
364
|
+
if ((await $.ui.panes()).some(pane => pane.id === PANE_ID)) {
|
|
365
|
+
await $.ui.close({ id: PANE_ID })
|
|
366
|
+
return 'Map panel closed.'
|
|
367
|
+
}
|
|
368
|
+
const opened = await openPane($)
|
|
369
|
+
return opened.isPlaced ? 'Map panel opened.' : `Map panel not shown: ${opened.reason}`
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// The close button: the pane goes (its `ui.close` hook stops the timers). The press counts as the
|
|
373
|
+
// person closing it, so the auto-open never fires again this session.
|
|
374
|
+
async function closePane($: Dollar) {
|
|
375
|
+
await $.ui.close({ id: PANE_ID })
|
|
376
|
+
await update($, paneAutoOpened, () => true)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const dim = { dimColor: true } as const
|
|
380
|
+
const bold = { bold: true } as const
|
|
381
|
+
const accent = { bold: true, color: ACCENT } as const
|
|
382
|
+
|
|
383
|
+
const unverifiedLine = (n: number) =>
|
|
384
|
+
n === 1 ? '1 unverified ticket hidden (quarantined)' : `${n} unverified tickets hidden (quarantined)`
|
|
385
|
+
|
|
386
|
+
export function registerMapPane(on: On) {
|
|
387
|
+
// The matchers below always hold: the validator refuses a second hook on an event
|
|
388
|
+
// when neither has one, and register.tsx already hooks these events bare.
|
|
389
|
+
on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
|
|
390
|
+
const result = await next(e)
|
|
391
|
+
await safeReadActiveMap($)
|
|
392
|
+
void requestRefresh($)
|
|
393
|
+
return result
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
on('turn.complete', { reason: ['answer', 'aborted', 'refusal', 'error'] }, async ($, e, next) => {
|
|
397
|
+
const result = await next(e)
|
|
398
|
+
await safeReadActiveMap($)
|
|
399
|
+
void requestRefresh($)
|
|
400
|
+
return result
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
// A /clear empties the atoms: note what the pane must get back, then restore it in the new session.
|
|
404
|
+
on('session.end', { reason: 'clear' }, async ($, e, next) => {
|
|
405
|
+
await carryOverClear($).catch(err => debug($, `trailhead mod: map pane carry-over failed: ${String(err)}`))
|
|
406
|
+
return next(e)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
on('classic.SessionStart', { source: 'clear' }, async ($, e, next) => {
|
|
410
|
+
const result = await next(e)
|
|
411
|
+
await restoreAfterClear($)
|
|
412
|
+
return result
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
// After the call ran: a Bash `gh issue` write changed the tracker.
|
|
416
|
+
on('tool.call', { tool: 'Bash', command: GH_ISSUE_WRITE }, async ($, e, next) => {
|
|
417
|
+
const result = await next(e)
|
|
418
|
+
void requestRefresh($)
|
|
419
|
+
return result
|
|
420
|
+
// This hook never judges the call: if it fails, the call goes on untouched.
|
|
421
|
+
}).catch((_$, e, next) => next(e))
|
|
422
|
+
|
|
423
|
+
// /trailhead:panel is a plugin command file; this answers it, so the model never runs.
|
|
424
|
+
on('command.run', { command: PANEL_COMMAND }, async $ => ({ text: await togglePanel($) }))
|
|
425
|
+
|
|
426
|
+
// Only in fullscreen, where a pane docks beside the transcript; this bare matcher is
|
|
427
|
+
// narrower than the band's, and the band's own hook (in register.tsx) draws after it.
|
|
428
|
+
on('ui.render', { component: 'AbovePrompt', viewport: { isFullscreen: true } }, async ($, e, next) => {
|
|
429
|
+
if ((await read($, activeMap)) !== null && !(await read($, paneAutoOpened))) $.clock.after(0, () => void autoOpen($))
|
|
430
|
+
return next(e)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
on('ui.close', { id: PANE_ID }, async ($, e, next) => {
|
|
434
|
+
const result = await next(e)
|
|
435
|
+
stopTimers()
|
|
436
|
+
// The person closing it by hand ends the auto-open for good.
|
|
437
|
+
if (e.origin.kind === 'person') await update($, paneAutoOpened, () => true)
|
|
438
|
+
return result
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
|
|
442
|
+
const ui = $.ui.resolve(e)
|
|
443
|
+
const { Box, Text, Button, Link } = ui
|
|
444
|
+
// Raster is the terminal surface's only; elsewhere the constructor is not a real element.
|
|
445
|
+
const Raster = e.surface === 'terminal' && 'Raster' in ui ? ui.Raster : undefined
|
|
446
|
+
const data = await read($, mapPane)
|
|
447
|
+
const active = await read($, activeMap)
|
|
448
|
+
const viewed = await read($, viewedMap)
|
|
449
|
+
const target = viewTarget(active, viewed)
|
|
450
|
+
const play = await read($, workState)
|
|
451
|
+
const orphaned = await read($, isOrphaned)
|
|
452
|
+
const pending = await read($, pendingWork)
|
|
453
|
+
const now = await $.clock.now()
|
|
454
|
+
const cols = e.props.bodyColumns
|
|
455
|
+
const isDock = e.props.placement === 'dock'
|
|
456
|
+
const gap = isDock ? 1 : 0
|
|
457
|
+
|
|
458
|
+
const text = (key: string, props: object, children: string) => (
|
|
459
|
+
<Box key={key}>
|
|
460
|
+
<Text {...props}>{children}</Text>
|
|
461
|
+
</Box>
|
|
462
|
+
)
|
|
463
|
+
const link = (url: string, label: string) => (url === '' ? label : <Link href={url} label={label} />)
|
|
464
|
+
|
|
465
|
+
// The open maps, one row each; shown with several maps open (or with no active map, to pick one).
|
|
466
|
+
const mapRow = (m: MapSummary) => {
|
|
467
|
+
const r = mapRowLayout(m, { active, viewed, columns: cols })
|
|
468
|
+
return (
|
|
469
|
+
<Box key={`map-row-${m.number}`}>
|
|
470
|
+
{text(`map-mark-${m.number}`, r.isActive ? accent : {}, r.mark)}
|
|
471
|
+
{r.isPressable ? (
|
|
472
|
+
<Button
|
|
473
|
+
key={`map-${m.number}`}
|
|
474
|
+
label={r.label}
|
|
475
|
+
onPress={() => pressView($, m.number)}
|
|
476
|
+
/>
|
|
477
|
+
) : (
|
|
478
|
+
text(`map-label-${m.number}`, bold, r.label)
|
|
479
|
+
)}
|
|
480
|
+
{r.aside === null ? null : text(`map-aside-${m.number}`, dim, r.aside)}
|
|
481
|
+
</Box>
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
const mapsGroup =
|
|
485
|
+
data.maps.length > 1 || (active === null && data.maps.length > 0) ? (
|
|
486
|
+
<Box key="group-maps" flexDirection="column" marginTop={gap}>
|
|
487
|
+
{text('head-maps', bold, `Maps · ${data.maps.length}`)}
|
|
488
|
+
{data.maps.map(mapRow)}
|
|
489
|
+
</Box>
|
|
490
|
+
) : null
|
|
491
|
+
// Viewing a map other than the active one: say so, with the way back.
|
|
492
|
+
const viewing =
|
|
493
|
+
viewed === null || viewed === active ? null : (
|
|
494
|
+
<Box key="viewing" gap={1}>
|
|
495
|
+
{text('viewing-text', dim, active === null ? `viewing #${viewed}` : `viewing #${viewed} · active #${active}`)}
|
|
496
|
+
<Button
|
|
497
|
+
key="back"
|
|
498
|
+
label={active === null ? 'back' : 'back to active'}
|
|
499
|
+
onPress={() => pressView($, null)}
|
|
500
|
+
/>
|
|
501
|
+
</Box>
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if (target === null) {
|
|
505
|
+
return (
|
|
506
|
+
<Box flexDirection="column" width={cols}>
|
|
507
|
+
{text('empty', dim, 'No active map. Run /trailhead:map to pick one.')}
|
|
508
|
+
{mapsGroup}
|
|
509
|
+
</Box>
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
const isCurrent = data.map?.number === target
|
|
513
|
+
const isLoading = !isCurrent && (data.isLoading || data.error === null)
|
|
514
|
+
// A pane the person opened (or that waited undrawn) has no data yet: fill it, once. Like the
|
|
515
|
+
// auto-open it leaves the render through a zero-delay timer; inside the floor it waits for
|
|
516
|
+
// the trailing refresh rather than the slow timer.
|
|
517
|
+
if (!isCurrent && !data.isLoading && data.error === null && !isFilling) {
|
|
518
|
+
isFilling = true
|
|
519
|
+
$.clock.after(0, () => {
|
|
520
|
+
isFilling = false
|
|
521
|
+
void requestRefresh($)
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const map = isCurrent ? data.map : null
|
|
526
|
+
const heading = map === null ? `map #${target}` : fit(`#${map.number} ${map.title}`, cols)
|
|
527
|
+
const header = (
|
|
528
|
+
<Box key="header" flexDirection="column">
|
|
529
|
+
<Box key="map">
|
|
530
|
+
<Text {...accent}>{map === null ? heading : link(map.url, heading)}</Text>
|
|
531
|
+
</Box>
|
|
532
|
+
{map?.destination == null ? null : text('destination', dim, fit(map.destination, cols))}
|
|
533
|
+
</Box>
|
|
534
|
+
)
|
|
535
|
+
if (isLoading) {
|
|
536
|
+
return (
|
|
537
|
+
<Box flexDirection="column">
|
|
538
|
+
{header}
|
|
539
|
+
{viewing}
|
|
540
|
+
{text('loading', dim, 'Loading tickets…')}
|
|
541
|
+
{mapsGroup}
|
|
542
|
+
</Box>
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const tickets = isCurrent ? data.tickets : []
|
|
547
|
+
const groups = groupTickets(tickets)
|
|
548
|
+
const isInPlay = (t: PaneTicket) => play !== null && !orphaned && play.ticket === t.number
|
|
549
|
+
|
|
550
|
+
const row = (t: PaneTicket, hasMarkColumn: boolean) => {
|
|
551
|
+
const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, columns: cols })
|
|
552
|
+
const style = r.isInPlay ? accent : r.isDim ? dim : {}
|
|
553
|
+
const holder = r.isInPlay ? { color: ACCENT } : r.isDim ? dim : {}
|
|
554
|
+
return (
|
|
555
|
+
<Box key={`row-${t.number}`} width={cols} justifyContent="space-between">
|
|
556
|
+
<Box>
|
|
557
|
+
{r.mark === null ? null : text(`mark-${t.number}`, r.isInPlay ? accent : {}, r.mark)}
|
|
558
|
+
<Box key={`ticket-${t.number}`}>
|
|
559
|
+
<Text {...style}>{link(t.url, r.label)}</Text>
|
|
560
|
+
</Box>
|
|
561
|
+
{r.type === null ? null : text(`type-${t.number}`, dim, r.type)}
|
|
562
|
+
</Box>
|
|
563
|
+
{r.aside?.kind === 'holder' ? text(`holder-${t.number}`, holder, r.aside.text) : null}
|
|
564
|
+
{r.aside?.kind === 'queued' ? text(`queued-${t.number}`, { color: AMBER }, r.aside.text) : null}
|
|
565
|
+
{r.aside?.kind === 'work' ? (
|
|
566
|
+
<Button
|
|
567
|
+
key={`work-${t.number}`}
|
|
568
|
+
label="work"
|
|
569
|
+
variant="primary"
|
|
570
|
+
onPress={() => void startWork($, t.number).catch(err => debug($, `trailhead mod: work press failed: ${String(err)}`))}
|
|
571
|
+
/>
|
|
572
|
+
) : null}
|
|
573
|
+
</Box>
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const group = (key: string, name: string, rows: readonly PaneTicket[]) => {
|
|
578
|
+
if (rows.length === 0) return null
|
|
579
|
+
const hasMarkColumn = rows.some(isInPlay)
|
|
580
|
+
return (
|
|
581
|
+
<Box
|
|
582
|
+
key={`group-${key}`}
|
|
583
|
+
flexDirection="column"
|
|
584
|
+
marginTop={gap}
|
|
585
|
+
{...(e.surface === 'desktop' ? { rowGap: DESKTOP_ROW_GAP } : {})}
|
|
586
|
+
>
|
|
587
|
+
{text(`head-${key}`, bold, `${name} · ${rows.length}`)}
|
|
588
|
+
{rows.map(t => row(t, hasMarkColumn))}
|
|
589
|
+
</Box>
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const status =
|
|
594
|
+
data.error === null
|
|
595
|
+
? `updated ${ageLabel(now - (data.fetchedAt ?? now))}`
|
|
596
|
+
: data.fetchedAt === null
|
|
597
|
+
? `⚠ ${data.error}`
|
|
598
|
+
: `⚠ ${data.error} · list from ${ageLabel(now - data.fetchedAt)}`
|
|
599
|
+
const footer = footerStatus(status, cols)
|
|
600
|
+
|
|
601
|
+
const end = burndownEnd(data, now)
|
|
602
|
+
const series = isCurrent && data.history !== null ? burndownSeries(data.history, end.at, cols) : null
|
|
603
|
+
const burndown = (s: BurndownSeries, history: BurndownHistory) => (
|
|
604
|
+
<Box key="burndown-section" flexDirection="column" marginTop={gap}>
|
|
605
|
+
{text('burndown-head', bold, 'Burn-down')}
|
|
606
|
+
{text('burndown-counts', dim, fit(`open ${s.open} · done ${s.done} · removed ${s.removed} · peak ${s.peak}`, cols))}
|
|
607
|
+
{Raster !== undefined ? (
|
|
608
|
+
<Raster key="burndown" columns={cols} rows={CHART_ROWS} cells={rasterCells(s.values, s.peak, CHART_ROWS, ACCENT_RGB)} />
|
|
609
|
+
) : (
|
|
610
|
+
text('burndown-spark', { color: ACCENT }, sparkline(s.values, s.peak))
|
|
611
|
+
)}
|
|
612
|
+
<Box key="burndown-axis" width={cols} justifyContent="space-between">
|
|
613
|
+
{text('burndown-axis-start', dim, axisStart(s.startedAt, end.at))}
|
|
614
|
+
{text('burndown-axis-end', dim, end.label)}
|
|
615
|
+
</Box>
|
|
616
|
+
{history.isCapped ? text('burndown-capped', dim, `history capped at ${HISTORY_LIMIT}: starts at the oldest ticket read`) : null}
|
|
617
|
+
</Box>
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
return (
|
|
621
|
+
<Box flexDirection="column" width={cols} {...(isDock ? { minHeight: e.props.scroll.bodyRows } : {})}>
|
|
622
|
+
{header}
|
|
623
|
+
{viewing}
|
|
624
|
+
{mapsGroup}
|
|
625
|
+
{group('frontier', 'Frontier', groups.frontier)}
|
|
626
|
+
{group('in-progress', 'In progress', groups.inProgress)}
|
|
627
|
+
{group('blocked', 'Blocked', groups.blocked)}
|
|
628
|
+
{isCurrent && data.error === null && tickets.length === 0 ? (
|
|
629
|
+
<Box key="no-tickets" marginTop={gap}>
|
|
630
|
+
<Text {...dim}>No open tickets. Fog may remain: /trailhead:map</Text>
|
|
631
|
+
</Box>
|
|
632
|
+
) : null}
|
|
633
|
+
{!isCurrent || data.unverifiedCount === 0 ? null : (
|
|
634
|
+
<Box key="unverified" marginTop={gap}>
|
|
635
|
+
<Text {...dim}>{unverifiedLine(data.unverifiedCount)}</Text>
|
|
636
|
+
</Box>
|
|
637
|
+
)}
|
|
638
|
+
{!isCurrent || !data.isCapped ? null : (
|
|
639
|
+
<Box key="capped" marginTop={gap}>
|
|
640
|
+
<Text {...dim}>{`list capped at ${LIST_LIMIT}`}</Text>
|
|
641
|
+
</Box>
|
|
642
|
+
)}
|
|
643
|
+
{series === null || data.history === null ? null : burndown(series, data.history)}
|
|
644
|
+
<Box key="spacer" flexGrow={1} />
|
|
645
|
+
<Box key="footer" flexDirection="column">
|
|
646
|
+
{text('rule', dim, '─'.repeat(cols))}
|
|
647
|
+
<Box key="footer-row" width={cols} justifyContent="space-between">
|
|
648
|
+
<Box gap={1}>
|
|
649
|
+
<Button key="refresh" label="refresh" onPress={() => void requestRefresh($)} />
|
|
650
|
+
{footer.isInline ? text('age', dim, footer.text) : null}
|
|
651
|
+
</Box>
|
|
652
|
+
<Button
|
|
653
|
+
key="close"
|
|
654
|
+
label="close"
|
|
655
|
+
onPress={() => void closePane($).catch(err => debug($, `trailhead mod: close press failed: ${String(err)}`))}
|
|
656
|
+
/>
|
|
657
|
+
</Box>
|
|
658
|
+
{footer.isInline ? null : <Box key="age-row">{text('age', dim, footer.text)}</Box>}
|
|
659
|
+
</Box>
|
|
660
|
+
</Box>
|
|
661
|
+
)
|
|
662
|
+
})
|
|
663
|
+
}
|