@marcomigozzi/trailhead 0.14.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 (92) hide show
  1. package/README.it.md +118 -30
  2. package/README.md +107 -29
  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 +1 -1
  8. package/bin/lib/codex-projection.test.js +41 -0
  9. package/bin/report-status-docs.test.js +195 -0
  10. package/bin/resolve-helpers-docs.test.js +36 -0
  11. package/bin/scope-confirm-skip-docs.test.js +70 -0
  12. package/bin/trailhead.js +3 -3
  13. package/bin/trailhead.test.js +341 -1
  14. package/bin/uat-setup-docs.test.js +65 -0
  15. package/package.json +1 -1
  16. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  17. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  18. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  19. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  20. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  21. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  22. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  23. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  24. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  25. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  26. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  27. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  28. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  29. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  30. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  31. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  32. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  33. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  34. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  35. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  36. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  37. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  38. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  39. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  40. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  41. package/plugins/trailhead/hooks/mod/README.md +38 -6
  42. package/plugins/trailhead/hooks/mod/map-pane-view.ts +168 -15
  43. package/plugins/trailhead/hooks/mod/map-pane.tsx +275 -106
  44. package/plugins/trailhead/hooks/mod/register.tsx +15 -2
  45. package/plugins/trailhead/hooks/mod/work-state-view.ts +17 -0
  46. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  47. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  48. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  49. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  50. package/plugins/trailhead/skills/_shared/choices.md +4 -2
  51. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  52. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  53. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  54. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  55. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  56. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  57. package/plugins/trailhead/skills/_shared/teamwork.md +16 -7
  58. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  59. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  60. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  61. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  62. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  63. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  64. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  65. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  66. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  67. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  68. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  69. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  70. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  71. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  73. package/plugins/trailhead/skills/trailhead-work/SKILL.md +14 -12
  74. package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -4
  75. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  76. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +26 -10
  77. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  78. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  79. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  80. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  81. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  82. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  83. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  84. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  85. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  86. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  87. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  88. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  89. package/plugins/trailhead/tests/map-pane-ui.test.ts +566 -19
  90. package/plugins/trailhead/tests/map-pane-view.test.ts +304 -16
  91. package/plugins/trailhead/tests/work-state-view.test.ts +25 -1
  92. package/plugins/trailhead/types/index.d.ts +37 -3
@@ -1,7 +1,7 @@
1
1
  // Pure view logic of the map pane: parse what `gh` printed, group the rows, word a
2
2
  // failure and an age, decide when a refresh may run. No engine calls here, so it is easy to test.
3
3
  import { fit } from './work-state-view'
4
- import type { BurndownHistory, BurndownIssue, MapInfo, MapSummary, PaneTicket, TicketState, TicketType } from '../../types'
4
+ import type { BurndownHistory, BurndownIssue, DashboardRef, MapCounts, MapInfo, MapsRead, MapSummary, PaneTicket, TicketState, TicketType, UatParent } from '../../types'
5
5
 
6
6
  export const TICKET_TYPES: readonly TicketType[] = ['decision', 'research', 'prototype', 'build', 'bug', 'task']
7
7
 
@@ -10,6 +10,8 @@ export type ParsedTickets = { tickets: readonly PaneTicket[]; unverifiedCount: n
10
10
  export type TicketGroups = {
11
11
  frontier: readonly PaneTicket[]
12
12
  inProgress: readonly PaneTicket[]
13
+ uat: readonly PaneTicket[]
14
+ paused: readonly PaneTicket[]
13
15
  blocked: readonly PaneTicket[]
14
16
  }
15
17
  export type Failure = { error: unknown } | { exitCode: number; stderr: string }
@@ -53,15 +55,23 @@ const typeOf = (names: readonly string[]): TicketType | null => {
53
55
  return found === undefined ? null : (found.slice(LABEL_PREFIX.length) as TicketType)
54
56
  }
55
57
 
58
+ // A paused label wins over blocked and in-progress: the ticket shows once, under Paused.
56
59
  const stateOf = (names: readonly string[], assignees: readonly string[]): TicketState =>
57
- names.includes(`${LABEL_PREFIX}blocked`) ? 'blocked' : assignees.length > 0 ? 'in-progress' : 'frontier'
60
+ names.includes(`${LABEL_PREFIX}paused`)
61
+ ? 'paused'
62
+ : names.includes(`${LABEL_PREFIX}blocked`)
63
+ ? 'blocked'
64
+ : assignees.length > 0
65
+ ? 'in-progress'
66
+ : 'frontier'
58
67
 
59
68
  // Only an https link is kept; anything else would be drawn as plain text.
60
69
  const safeUrl = (raw: unknown): string => (typeof raw === 'string' && /^https:\/\//.test(raw) ? raw : '')
61
70
 
62
71
  // `gh issue list --json number,title,labels,assignees,url`. Unverified rows are
63
- // quarantined (counted, never listed); whiteboard rows do not belong to a map.
64
- export const parseTickets = (stdout: string): ParsedTickets | null => {
72
+ // quarantined (counted, never listed); whiteboard rows do not belong to a map, and on the
73
+ // whiteboard (`isWhiteboard`) only they are kept.
74
+ export const parseTickets = (stdout: string, opts: { isWhiteboard?: boolean } = {}): ParsedTickets | null => {
65
75
  let raw: unknown
66
76
  try {
67
77
  raw = JSON.parse(stdout)
@@ -73,7 +83,8 @@ export const parseTickets = (stdout: string): ParsedTickets | null => {
73
83
  const named = rows.map(r => ({ r, names: labelNames(r.labels) }))
74
84
  const unverifiedCount = named.filter(({ names }) => names.includes(`${LABEL_PREFIX}unverified`)).length
75
85
  const tickets = named
76
- .filter(({ names }) => !names.includes(`${LABEL_PREFIX}unverified`) && !names.includes(`${LABEL_PREFIX}whiteboard`))
86
+ .filter(({ names }) => !names.includes(`${LABEL_PREFIX}unverified`))
87
+ .filter(({ names }) => names.includes(`${LABEL_PREFIX}whiteboard`) === (opts.isWhiteboard === true))
77
88
  .map(({ r, names }): PaneTicket => {
78
89
  const assignees = logins(r.assignees)
79
90
  return {
@@ -83,11 +94,52 @@ export const parseTickets = (stdout: string): ParsedTickets | null => {
83
94
  state: stateOf(names, assignees),
84
95
  assignees,
85
96
  url: safeUrl(r.url),
97
+ isUat: names.includes(`${LABEL_PREFIX}uat`),
98
+ uatOf: null,
86
99
  }
87
100
  })
88
101
  return { tickets, unverifiedCount, isCapped: raw.length >= LIST_LIMIT }
89
102
  }
90
103
 
104
+ // `gh issue list --label trailhead:uat ... --json number,body`: ticket number -> the parent its first
105
+ // `UAT of:` line names, `[<name>](<url>/issues/<n>)` (or a bare `#<n>`, with an empty title). A row with
106
+ // no such line, or an unreadable one, is skipped; not a list is unreadable (null).
107
+ export const parseUatParents = (stdout: string): ReadonlyMap<number, UatParent> | null => {
108
+ let raw: unknown
109
+ try {
110
+ raw = JSON.parse(stdout)
111
+ } catch {
112
+ return null
113
+ }
114
+ if (!Array.isArray(raw)) return null
115
+ const parents = new Map<number, UatParent>()
116
+ for (const r of raw.filter(isRecord)) {
117
+ if (!isPosInt(r.number) || typeof r.body !== 'string') continue
118
+ const line = r.body.split(/\r?\n/).find(l => l.startsWith('UAT of:'))
119
+ if (line === undefined) continue
120
+ const pointer = line.slice('UAT of:'.length).trim()
121
+ const link = /^\[([^\]]*)\]\(https:\/\/[^)\s]*\/issues\/(\d+)[^)]*\)/.exec(pointer)
122
+ const bare = /^#(\d+)\b/.exec(pointer)
123
+ const found = link !== null ? { number: Number(link[2]), title: flatten(link[1] as string) } : bare !== null ? { number: Number(bare[1]), title: '' } : null
124
+ if (found !== null && isPosInt(found.number)) parents.set(r.number, found)
125
+ }
126
+ return parents
127
+ }
128
+
129
+ // The tickets with the parent of each UAT one; a missing read leaves them as they are.
130
+ export const withUatParents = (tickets: readonly PaneTicket[], parents: ReadonlyMap<number, UatParent> | null): readonly PaneTicket[] =>
131
+ parents === null ? tickets : tickets.map(t => (t.isUat ? { ...t, uatOf: parents.get(t.number) ?? null } : t))
132
+
133
+ // The dim line under a UAT row, `tests <parent title> #<n>`, cut to the pane width; null without a parent.
134
+ export const uatParentLine = (t: PaneTicket, columns: number): string | null => {
135
+ if (t.uatOf === null) return null
136
+ const suffix = ` #${t.uatOf.number}`
137
+ const prefix = 'tests'
138
+ if (t.uatOf.title === '') return fit(`${prefix}${suffix}`, columns)
139
+ const title = fit(t.uatOf.title, columns - prefix.length - 1 - suffix.length)
140
+ return `${prefix} ${title}${suffix}`
141
+ }
142
+
91
143
  // Markdown emphasis, code ticks and links reduced to their text.
92
144
  const stripMarkdown = (line: string): string =>
93
145
  line
@@ -136,10 +188,73 @@ export const parseMaps = (stdout: string): readonly MapSummary[] | null => {
136
188
  return raw
137
189
  .filter(isRecord)
138
190
  .filter(r => isPosInt(r.number))
139
- .map((r): MapSummary => ({ number: r.number as number, title: typeof r.title === 'string' ? flatten(r.title) : '' }))
191
+ .map((r): MapSummary => ({ number: r.number as number, title: typeof r.title === 'string' ? flatten(r.title) : '', counts: null }))
140
192
  .sort((a, b) => a.number - b.number)
141
193
  }
142
194
 
195
+ const countsOf = (raw: unknown): MapCounts | null => {
196
+ if (!isRecord(raw)) return null
197
+ const { total, completed } = raw
198
+ if (!Number.isInteger(total) || !Number.isInteger(completed) || (total as number) < 0 || (completed as number) < 0) return null
199
+ return { open: Math.max(0, (total as number) - (completed as number)), total: total as number }
200
+ }
201
+
202
+ // A GraphQL `totalCount` as a count, else null.
203
+ const totalOf = (raw: unknown): number | null =>
204
+ isRecord(raw) && Number.isInteger(raw.totalCount) && (raw.totalCount as number) >= 0 ? (raw.totalCount as number) : null
205
+
206
+ // The whiteboard's open and total tickets; unusable (missing, negative, open above total) is unknown.
207
+ const whiteboardOf = (repo: Record<string, unknown>): MapCounts | null => {
208
+ const open = totalOf(repo.whiteboardOpen)
209
+ const total = totalOf(repo.whiteboardAll)
210
+ return open === null || total === null || open > total ? null : { open, total }
211
+ }
212
+
213
+ // The GraphQL reply of the maps read: the open maps with their sub-issue counts, same row rules as
214
+ // `parseMaps`, and the whiteboard's counts. A malformed summary keeps the row name-only; unusable
215
+ // whiteboard fields leave only `whiteboard` null. Not that shape, or any `errors` (even next to
216
+ // partial `data`), is unreadable: the caller falls back to the name-only list.
217
+ export const parseMapsRead = (stdout: string): MapsRead | null => {
218
+ let raw: unknown
219
+ try {
220
+ raw = JSON.parse(stdout)
221
+ } catch {
222
+ return null
223
+ }
224
+ if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data)) return null
225
+ const repo = raw.data.repository
226
+ const issues = isRecord(repo) ? repo.issues : null
227
+ const nodes = isRecord(issues) ? issues.nodes : null
228
+ if (!isRecord(repo) || !Array.isArray(nodes)) return null
229
+ const maps = nodes
230
+ .filter(isRecord)
231
+ .filter(r => isPosInt(r.number))
232
+ .map(
233
+ (r): MapSummary => ({
234
+ number: r.number as number,
235
+ title: typeof r.title === 'string' ? flatten(r.title) : '',
236
+ counts: countsOf(r.subIssuesSummary),
237
+ }),
238
+ )
239
+ .sort((a, b) => a.number - b.number)
240
+ return { maps, whiteboard: whiteboardOf(repo) }
241
+ }
242
+
243
+ // `gh issue list --label trailhead:dashboard --json number,url`: the pinned index, the lowest number
244
+ // when there are several. `null` when unreadable; `{ ref: null }` when the repo has none yet.
245
+ export const parseDashboard = (stdout: string): { ref: DashboardRef | null } | null => {
246
+ let raw: unknown
247
+ try {
248
+ raw = JSON.parse(stdout)
249
+ } catch {
250
+ return null
251
+ }
252
+ if (!Array.isArray(raw)) return null
253
+ const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
254
+ const first = rows.sort((a, b) => (a.number as number) - (b.number as number))[0]
255
+ return { ref: first === undefined ? null : { number: first.number as number, url: safeUrl(first.url) } }
256
+ }
257
+
143
258
  // The map the pane draws: the one the person is viewing, else the active one.
144
259
  export const viewTarget = (active: number | null, viewed: number | null): number | null => viewed ?? active
145
260
 
@@ -161,17 +276,24 @@ export type MapRowLayout = {
161
276
  // Every map but the viewed one: a press shows it (the active one: back to active).
162
277
  isPressable: boolean
163
278
  }
164
- export type MapRowContext = { active: number | null; viewed: number | null; columns: number }
279
+ // `isDashboard`: the dashboard is shown, so no map is viewed and every row is pressable.
280
+ export type MapRowContext = { active: number | null; viewed: number | null; columns: number; isDashboard?: boolean }
165
281
 
166
282
  const ACTIVE_MARK = '● '
167
- const ACTIVE_ASIDE = ' active'
283
+ const ACTIVE_LABEL = 'active'
284
+ const ASIDE_SEP = ' · '
285
+
286
+ // `<open> open / <total>`: the counts beside a map or the whiteboard.
287
+ export const countsLabel = (c: MapCounts): string => `${c.open} open / ${c.total}`
168
288
 
169
289
  // What one row of the maps list draws, cut to the pane width (a pressable row is a Button).
170
290
  export const mapRowLayout = (m: MapSummary, ctx: MapRowContext): MapRowLayout => {
171
291
  const isActive = m.number === ctx.active
172
- const isViewed = m.number === viewTarget(ctx.active, ctx.viewed)
292
+ const isViewed = ctx.isDashboard !== true && m.number === viewTarget(ctx.active, ctx.viewed)
173
293
  const mark = isActive ? ACTIVE_MARK : ' '
174
- const aside = isActive ? ACTIVE_ASIDE : null
294
+ const parts = [m.counts === null ? null : countsLabel(m.counts), isActive ? ACTIVE_LABEL : null]
295
+ const shown = parts.filter((p): p is string => p !== null)
296
+ const aside = shown.length === 0 ? null : ` ${shown.join(ASIDE_SEP)}`
175
297
  const prefix = `#${m.number}`
176
298
  const isPressable = !isViewed
177
299
  const room = ctx.columns - mark.length - (aside?.length ?? 0) - (isPressable ? BUTTON_CHROME : 0) - prefix.length - 1
@@ -179,11 +301,34 @@ export const mapRowLayout = (m: MapSummary, ctx: MapRowContext): MapRowLayout =>
179
301
  return { mark, label: title === '' ? prefix : `${prefix} ${title}`, aside, isActive, isViewed, isPressable }
180
302
  }
181
303
 
304
+ export type WhiteboardRowLayout = { mark: string; label: string; aside: string | null }
305
+ const WHITEBOARD_LABEL = 'Whiteboard'
306
+
307
+ // The Whiteboard row above the maps list: a Button with its counts beside it, name-only when they are
308
+ // unknown or would not fit (the name is kept whole).
309
+ export const whiteboardRowLayout = (counts: MapCounts | null, columns: number): WhiteboardRowLayout => {
310
+ const mark = ' '
311
+ const aside = counts === null ? null : ` ${countsLabel(counts)}`
312
+ const fits = aside !== null && mark.length + BUTTON_CHROME + WHITEBOARD_LABEL.length + aside.length <= columns
313
+ return { mark, label: WHITEBOARD_LABEL, aside: fits ? aside : null }
314
+ }
315
+
316
+ // The whiteboard heading with its counts over the listed (trusted) tickets; bare before the first good read.
317
+ export const whiteboardHeading = (tickets: readonly PaneTicket[] | null, isCapped: boolean): string => {
318
+ if (tickets === null) return 'Whiteboard'
319
+ const open = isCapped ? `${LIST_LIMIT}+` : String(tickets.length)
320
+ return `Whiteboard · ${open} open · ${tickets.filter(t => t.assignees.length > 0).length} assigned`
321
+ }
322
+
182
323
  const byNumber = (a: PaneTicket, b: PaneTicket) => a.number - b.number
183
324
 
325
+ // Paused wins, then a UAT ticket (whatever its state), then the state: each ticket shows once.
326
+ type Place = TicketState | 'uat'
327
+ const placeOf = (t: PaneTicket): Place => (t.state === 'paused' ? 'paused' : t.isUat ? 'uat' : t.state)
328
+
184
329
  export const groupTickets = (tickets: readonly PaneTicket[]): TicketGroups => {
185
- const of = (state: TicketState) => tickets.filter(t => t.state === state).sort(byNumber)
186
- return { frontier: of('frontier'), inProgress: of('in-progress'), blocked: of('blocked') }
330
+ const of = (place: Place) => tickets.filter(t => placeOf(t) === place).sort(byNumber)
331
+ return { frontier: of('frontier'), inProgress: of('in-progress'), uat: of('uat'), paused: of('paused'), blocked: of('blocked') }
187
332
  }
188
333
 
189
334
  const errorText = (err: unknown): string => {
@@ -220,7 +365,7 @@ export const ageLabel = (ms: number): string => {
220
365
  return `${Math.floor(ms / 3_600_000)}h ago`
221
366
  }
222
367
 
223
- export type RowAside = { kind: 'work' | 'queued' | 'holder'; text: string }
368
+ export type RowAside = { kind: 'work' | 'queued' | 'holder' | 'self'; text: string }
224
369
  export type RowLayout = {
225
370
  mark: string | null
226
371
  label: string
@@ -242,6 +387,10 @@ const MARK_PAD = ' '
242
387
  // A terminal Button draws `[ label ]`.
243
388
  const BUTTON_CHROME = 4
244
389
 
390
+ // The dashboard header's button back to the map; the title before it gets the rest of the row.
391
+ export const MAP_LABEL = 'map'
392
+ export const headingRoom = (columns: number, label: string): number => Math.max(0, columns - label.length - BUTTON_CHROME - 1)
393
+
245
394
  const REFRESH_LABEL = 'refresh'
246
395
  const CLOSE_LABEL = 'close'
247
396
  // The gap between the refresh button and the status, and the one kept before the close button.
@@ -257,7 +406,11 @@ export const footerStatus = (status: string, columns: number): FooterStatus => {
257
406
  return Array.from(status).length <= room ? { text: status, isInline: true } : { text: fit(status, columns), isInline: false }
258
407
  }
259
408
 
260
- const asideOf = (t: PaneTicket, pendingWork: number | null): RowAside | null => {
409
+ const SELF_ASIDE: RowAside = { kind: 'self', text: 'this session' }
410
+
411
+ // The ticket in play says `this session`; otherwise a frontier row has its button and the rest their holder.
412
+ const asideOf = (t: PaneTicket, pendingWork: number | null, isInPlay: boolean): RowAside | null => {
413
+ if (isInPlay) return SELF_ASIDE
261
414
  if (t.state === 'frontier') return pendingWork === t.number ? { kind: 'queued', text: 'queued' } : { kind: 'work', text: 'work' }
262
415
  return t.assignees.length === 0 ? null : { kind: 'holder', text: t.assignees.map(a => `@${a}`).join(' ') }
263
416
  }
@@ -268,7 +421,7 @@ const asideWidth = (a: RowAside | null): number =>
268
421
  // What one row draws, cut to the pane width: mark column, `#n title`, type, right-hand part.
269
422
  export const rowLayout = (t: PaneTicket, ctx: RowContext): RowLayout => {
270
423
  const mark = ctx.isInPlay ? MARK : ctx.hasMarkColumn ? MARK_PAD : null
271
- const aside = asideOf(t, ctx.pendingWork)
424
+ const aside = asideOf(t, ctx.pendingWork, ctx.isInPlay)
272
425
  const type = t.type === null ? null : ` ${t.type}`
273
426
  const prefix = `#${t.number}`
274
427
  const room = ctx.columns - (mark?.length ?? 0) - asideWidth(aside) - (type?.length ?? 0) - prefix.length - 1