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