@marcomigozzi/trailhead 0.15.0 → 0.16.1

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 (116) hide show
  1. package/README.it.md +52 -14
  2. package/README.md +53 -15
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/config-dir-templates-docs.test.js +52 -0
  12. package/bin/decision-builds-docs.test.js +75 -0
  13. package/bin/fog-vs-ticket-docs.test.js +56 -0
  14. package/bin/grilling-holistic-review-docs.test.js +90 -0
  15. package/bin/lib/codex-projection.js +13 -6
  16. package/bin/lib/codex-projection.test.js +25 -2
  17. package/bin/lib/host-descriptor.js +14 -1
  18. package/bin/lib/host-descriptor.test.js +18 -0
  19. package/bin/lib/runtime-command-literals.test.js +184 -0
  20. package/bin/needs-you-docs.test.js +41 -0
  21. package/bin/pick-stress-test-docs.test.js +105 -0
  22. package/bin/report-status-docs.test.js +1 -1
  23. package/bin/trailhead.js +3 -3
  24. package/bin/trailhead.test.js +17 -18
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  28. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  29. package/plugins/trailhead/commands/auto.md +2 -2
  30. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  31. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  32. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  33. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  34. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  36. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  38. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  41. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  42. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  43. package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
  44. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  45. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  46. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  47. package/plugins/trailhead/hooks/mod/README.md +185 -39
  48. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  49. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  50. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  51. package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
  52. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  53. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  54. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  55. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  56. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  57. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  58. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  59. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  60. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  61. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  62. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  63. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  64. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  65. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
  66. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
  67. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  68. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
  69. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  70. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  71. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  72. package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
  73. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  74. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  75. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  76. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  77. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  78. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  79. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  80. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  81. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  82. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  83. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  84. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  85. package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
  86. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  87. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
  88. package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
  89. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  90. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  91. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  92. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  93. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  94. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  95. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  96. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  97. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  98. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  99. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  100. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  101. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  102. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  103. package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
  104. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  105. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  106. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  107. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  108. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  109. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  110. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  111. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  112. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  113. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  114. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  115. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  116. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -11,6 +11,17 @@ const FLOOR = 15_000
11
11
  const TIMER = 180_000
12
12
  const AMBER = '#e0af68'
13
13
  const URL = 'https://github.com/o/r/issues'
14
+ const ROOT = '/repo'
15
+ const ROOT_OTHER = '/other'
16
+ // A linked worktree of ROOT: the repo's root is the main tree's, the checkout's own is this.
17
+ const ROOT_TREE = '/repo-t5'
18
+ // The checkout handed to a verb: base64url of the root, computed here by hand so the pane's own encoder is checked.
19
+ // The helper's decodeIn cannot be imported here (the CommonJS template does not load in this suite); the node suite
20
+ // checks decodeIn against Buffer's base64url, and these tests pin the same alphabet.
21
+ const IN = (root: string) => {
22
+ const b64 = btoa(Array.from(new TextEncoder().encode(root), byte => String.fromCharCode(byte)).join(''))
23
+ return `--in=${b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`
24
+ }
14
25
 
15
26
  const MAP = {
16
27
  title: 'trailhead mod',
@@ -63,12 +74,41 @@ const MAPS = [
63
74
  ]
64
75
  // The maps read: one GraphQL call that carries each map's sub-issue counts and the whiteboard's.
65
76
  const MAPS_QUERY =
66
- 'query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){issues(first:100,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}}}'
77
+ 'query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){issues(first:100,states:OPEN,labels:["trailhead:map"]){nodes{number title url subIssuesSummary{total completed}}totalCount}whiteboardOpen:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}whiteboardAll:issues(labels:["trailhead:whiteboard"]){totalCount}}}'
67
78
  const MAPS_CALL = ['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MAPS_QUERY}`]
68
79
  const MAPS_LIST_CALL = ['gh', 'issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', '100', '--json', 'number,title,url']
69
- // The pending-UAT read of one scope (a map label, or the whiteboard): the tickets with their bodies.
70
- const uatCall = (scope: string) => ['gh', 'issue', 'list', '--label', 'trailhead:uat', '--label', scope, '--state', 'open', '--limit', '200', '--json', 'number,body']
71
- const isGraphql = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql'
80
+ // The pending-UAT read: every open `trailhead:uat` ticket with its body and labels, one GraphQL page per call;
81
+ // the scope (a map label, or the whiteboard) is filtered by the pane.
82
+ const UAT_QUERY =
83
+ 'query($owner:String!,$repo:String!,$after:String){repository(owner:$owner,name:$repo){issues(first:100,after:$after,states:OPEN,labels:["trailhead:uat"],orderBy:{field:CREATED_AT,direction:DESC}){nodes{number body labels(first:50){nodes{name}}}pageInfo{hasNextPage endCursor}}}}'
84
+ const uatCall = (after?: string) => [
85
+ 'gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${UAT_QUERY}`,
86
+ ...(after === undefined ? [] : ['-f', `after=${after}`]),
87
+ ]
88
+ const isUatRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.includes(`query=${UAT_QUERY}`)
89
+ // The retired search read of the UAT parents: the pane must never run it.
90
+ const isUatSearch = (argv: readonly string[]) => argv[2] === 'list' && argv.includes('trailhead:uat')
91
+ const UAT_MAX_PAGES = 10
92
+ // One open UAT ticket as the read returns it: its body and the labels it carries (the scope among them).
93
+ const uatRow = (number: number, body: string, scope = 'trailhead:map-198') => ({ number, body, labels: [{ name: 'trailhead:uat' }, { name: scope }] })
94
+ // The open-ticket read: one GraphQL page per call (`after=` carries the cursor of the next one).
95
+ const TICKETS_QUERY =
96
+ 'query($owner:String!,$repo:String!,$label:String!,$after:String){repository(owner:$owner,name:$repo){issues(first:100,after:$after,states:OPEN,labels:[$label],orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url labels(first:50){nodes{name}}assignees(first:10){nodes{login}}}pageInfo{hasNextPage endCursor}}}}'
97
+ const ticketsCall = (label: string, after?: string) => [
98
+ 'gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${TICKETS_QUERY}`, '-f', `label=${label}`,
99
+ ...(after === undefined ? [] : ['-f', `after=${after}`]),
100
+ ]
101
+ const ticketsListCall = (label: string) => ['gh', 'issue', 'list', '--label', label, '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url']
102
+ const isTicketsRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.includes(`query=${TICKETS_QUERY}`)
103
+ // The maps read only: the ticket reads are GraphQL too, but answered and failed on their own.
104
+ const isGraphql = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && !isTicketsRead(argv) && !isUatRead(argv) && !isCountRead(argv)
105
+ // The open-ticket total: one aliased `totalCount` per open map (`m<n>`) plus the whiteboard (`wb`), in one call.
106
+ const countQuery = (maps: number[]) =>
107
+ `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){${[...maps.map(n => `m${n}:issues(states:OPEN,labels:["trailhead:map-${n}"]){totalCount}`), 'wb:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}'].join('')}}}`
108
+ const countCall = (...maps: number[]) => ['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${countQuery(maps)}`]
109
+ const isCountRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.some(a => a.startsWith('query=') && a.includes('wb:issues('))
110
+ const flatNode = (t: any) => ({ ...t, labels: { nodes: t.labels }, assignees: { nodes: t.assignees } })
111
+ const PAGE = 100
72
112
  const isMapsList = (argv: readonly string[]) => argv[2] === 'list' && argv.includes('trailhead:map')
73
113
  const OTHER_TICKETS = [issue(140, 'Old frontier ticket', ['trailhead:build']), issue(141, 'Old held ticket', ['trailhead:task'], ['carol'])]
74
114
  const OTHER_HISTORY = OTHER_TICKETS.map(t => hrow(t.number, t.labels.map(l => l.name), null))
@@ -97,7 +137,7 @@ const stateText = (over: Record<string, unknown> = {}): string =>
97
137
  })
98
138
 
99
139
  // The plugin's atoms, which the engine resets on /clear.
100
- const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'isDashboard', 'dashboardPane', 'pendingWork', 'paneAutoOpened', 'ownTicket']
140
+ const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'isDashboard', 'dashboardPane', 'pendingWork', 'pendingPhase', 'paneAutoOpened', 'ownTicket', 'repoPin']
101
141
 
102
142
  const PANE_PROPS = {
103
143
  title: 'Map',
@@ -127,21 +167,58 @@ const rig = (on: On, engine?: any) => {
127
167
  mapSummary: { 198: { total: 23, completed: 16 }, 14: { total: 5, completed: 2 } } as Record<number, unknown>,
128
168
  // The whiteboard's open and total tickets as the same call reports them (null: the fields are absent).
129
169
  whiteboardCounts: { open: 3, total: 9 } as { open: number; total: number } | null,
170
+ // The open tickets per label, as the total's count read reports them (a missing label counts 0); `countMode`
171
+ // makes that read fail (`fail`), answer `errors` (`errors`) or non-JSON (`junk`), `countTotal` the maps read's `totalCount`.
172
+ openCounts: { 'trailhead:map-198': 5, 'trailhead:map-14': 2, 'trailhead:whiteboard': 3 } as Record<string, number>,
173
+ countMode: 'ok' as 'ok' | 'fail' | 'errors' | 'junk',
174
+ countTotal: null as number | null,
130
175
  otherMap: OTHER_MAP as unknown,
131
176
  otherTickets: OTHER_TICKETS as unknown[],
132
177
  otherHistory: OTHER_HISTORY as unknown[],
133
178
  whiteboard: WHITEBOARD as unknown[],
134
- // The UAT read's answer: one row per UAT ticket, `{ number, body }`; null makes the read fail.
179
+ // The UAT read's answer: one row per open UAT ticket, `{ number, body, labels }` (the scope label rides
180
+ // in `labels`); null makes the read fail. `uatErrors` answers a reply with `errors`, `uatBroken` non-JSON.
135
181
  uat: [] as unknown[] | null,
182
+ uatErrors: false,
183
+ uatBroken: false,
136
184
  dashboard: DASHBOARD as unknown[],
137
185
  gh: null as Gh | null,
138
186
  // What `gh` answers when no test replaced it; a test can wrap it.
139
187
  defaultGh: null as unknown as Gh,
140
188
  deny: null as string | null,
189
+ // The session's working directory, which a shell `cd` moves, and what `session.repo` finds there
190
+ // (null outside any repo). Relative reads and a `gh` without its own cwd resolve against `cwd`.
191
+ cwd: '/repo',
192
+ repo: { root: '/repo', remote: 'git@github.com:o/r.git', internal: false } as { root: string; remote: string | null; internal: boolean } | null,
193
+ // The active-map marker of the second checkout (ROOT_OTHER); null: it has none.
194
+ otherActive: null as string | null,
195
+ // Runs once before the next read of the active-map marker, to land an event inside a check.
196
+ beforeMarkerRead: null as (() => Promise<void>) | null,
197
+ // The worktree checkout's marker, what `git rev-parse --show-toplevel` answers there (null: it fails), and the
198
+ // session's project root; every path read and every `git` run is recorded.
199
+ treeActive: null as string | null,
200
+ top: null as string | null,
201
+ sessionRoot: '/repo',
202
+ reads: [] as string[],
203
+ // The hosts `gh auth status --hostname` accepts besides github.com, and every host it was asked about.
204
+ authHosts: [] as string[],
205
+ authCalls: [] as string[],
206
+ gitCalls: [] as { argv: readonly string[]; cwd: string | undefined }[],
207
+ // The cwd every `gh` run carried (undefined: none given).
208
+ cwds: [] as (string | undefined)[],
141
209
  calls: [] as (readonly string[])[],
142
210
  panes: [] as { id: string; title: string; isShown: boolean; isFocused: boolean; isPlaced: boolean }[],
143
211
  isPlaced: true,
144
212
  logs: [] as string[],
213
+ toasts: [] as string[],
214
+ // What a Bash call prints on stdout (the created issue's URL for a `gh issue create`).
215
+ stdout: 'ok' as string,
216
+ // `session.repo` answers a denial (the checkout cannot be read).
217
+ repoFails: false,
218
+ // What `git remote get-url origin` answers (null: no origin), for a `session.repo` that answers null (#299).
219
+ gitRemote: null as string | null,
220
+ // Checkout roots that no longer exist: git answers 128 there (a removed worktree or clone).
221
+ gone: [] as string[],
145
222
  opens: [] as { id: string; title?: string }[],
146
223
  // `command.run` beneath the plugin: every run is recorded; `gate` holds a work run, `refuse` rejects it.
147
224
  // Every file write beneath the plugin: the pane must never write the active-map marker.
@@ -155,7 +232,15 @@ const rig = (on: On, engine?: any) => {
155
232
  // The clear answered by a `{ deny }` hook: it resolves the call and the session stays.
156
233
  denyClear: false,
157
234
  // Runs between the old session ending and the new one starting.
158
- duringClear: null as (() => void) | null,
235
+ duringClear: null as (() => void | Promise<void>) | null,
236
+ // Runs once, in the next `session.id` read (a dispatch's own read before its clear).
237
+ beforeSessionId: null as (() => void | Promise<void>) | null,
238
+ // Every value written to the pendingPhase atom, in order.
239
+ phases: [] as unknown[],
240
+ // Atom values the engine reports whatever was written (a state a hot reload left); a write clears the key.
241
+ forced: {} as Record<string, unknown>,
242
+ // Runs once, before the next pendingPhase write lands (the row still reads what it read before).
243
+ beforePhaseWrite: null as (() => void | Promise<void>) | null,
159
244
  session: SESSION,
160
245
  clock: mock.clock(on, { now: T0 }),
161
246
  // The person opening the pane: the engine's own record, the plugin is not told.
@@ -164,22 +249,62 @@ const rig = (on: On, engine?: any) => {
164
249
  },
165
250
  }
166
251
  const defaultGh: Gh = argv => {
167
- if (argv.includes('trailhead:uat')) return r.uat === null ? { exitCode: 1, stdout: '', stderr: 'boom' } : ok(JSON.stringify(r.uat))
252
+ if (isTicketsRead(argv)) {
253
+ const label = (argv.find(a => a.startsWith('label=')) ?? '').slice('label='.length)
254
+ const cursor = argv.find(a => a.startsWith('after='))
255
+ const all = (label === 'trailhead:whiteboard' ? r.whiteboard : label === 'trailhead:map-14' ? r.otherTickets : r.tickets) as unknown[]
256
+ const from = cursor === undefined ? 0 : Number(cursor.slice('after=c'.length))
257
+ const to = from + PAGE
258
+ const pageInfo = { hasNextPage: to < all.length, endCursor: `c${to}` }
259
+ return ok(JSON.stringify({ data: { repository: { issues: { nodes: all.slice(from, to).map(flatNode), pageInfo } } } }))
260
+ }
261
+ if (isUatRead(argv)) {
262
+ if (r.uat === null) return { exitCode: 1, stdout: '', stderr: 'boom' }
263
+ if (r.uatBroken) return ok('not json')
264
+ if (r.uatErrors) return ok(JSON.stringify({ data: { repository: { issues: { nodes: [] } } }, errors: [{ message: 'x' }] }))
265
+ const cursor = argv.find(a => a.startsWith('after='))
266
+ const from = cursor === undefined ? 0 : Number(cursor.slice('after=c'.length))
267
+ const to = from + PAGE
268
+ const pageInfo = { hasNextPage: to < r.uat.length, endCursor: `c${to}` }
269
+ const nodes = (r.uat as any[]).slice(from, to).map(u => ({ ...u, labels: { nodes: u.labels ?? [] } }))
270
+ return ok(JSON.stringify({ data: { repository: { issues: { nodes, pageInfo } } } }))
271
+ }
272
+ if (isCountRead(argv)) {
273
+ if (r.countMode === 'fail') return { exitCode: 1, stdout: '', stderr: 'boom' }
274
+ if (r.countMode === 'junk') return ok('not json')
275
+ const query = (argv.find(a => a.startsWith('query=')) ?? '').slice('query='.length)
276
+ const repo = Object.fromEntries([...query.matchAll(/(\w+):issues\(states:OPEN,labels:\["([^"]+)"\]\)\{totalCount\}/g)].map(m => [m[1], { totalCount: r.openCounts[m[2] as string] ?? 0 }]))
277
+ return ok(JSON.stringify({ data: { repository: repo }, ...(r.countMode === 'errors' ? { errors: [{ message: 'x' }] } : {}) }))
278
+ }
168
279
  if (argv.includes('trailhead:whiteboard')) return ok(JSON.stringify(r.whiteboard))
169
280
  if (argv.includes('trailhead:dashboard')) return ok(JSON.stringify(r.dashboard))
170
281
  if (isGraphql(argv)) {
171
282
  const nodes = (r.maps as { number: number }[]).map(m => ({ ...m, subIssuesSummary: r.mapSummary[m.number] }))
172
283
  const wb = r.whiteboardCounts
173
284
  const counts = wb === null ? {} : { whiteboardOpen: { totalCount: wb.open }, whiteboardAll: { totalCount: wb.total } }
174
- return ok(JSON.stringify({ data: { repository: { issues: { nodes }, ...counts } } }))
285
+ const totalCount = r.countTotal ?? nodes.length
286
+ return ok(JSON.stringify({ data: { repository: { issues: { nodes, totalCount }, ...counts } } }))
175
287
  }
176
288
  if (isMapsList(argv)) return ok(JSON.stringify(r.maps))
177
289
  const isOther = argv.includes('14') || argv.includes('trailhead:map-14')
178
290
  if (argv[2] === 'view') return ok(JSON.stringify(isOther ? r.otherMap : r.map))
179
291
  return ok(JSON.stringify(argv.includes('all') ? (isOther ? r.otherHistory : r.history) : isOther ? r.otherTickets : r.tickets))
180
292
  }
181
- on('fs.read', (_$, e) => {
182
- const file = e.path.endsWith('/.trailhead/active-map') ? r.active : e.path.endsWith('/.trailhead/work-state.json') ? r.work : undefined
293
+ on('fs.read', async (_$, e) => {
294
+ // A relative path reaches the hook already resolved against the host's directory, not the repo's: put
295
+ // it under the session's working directory, which is what the engine resolves it against. A path under
296
+ // one of the repos is absolute (a pinned read) and stays.
297
+ const isAbsolute = [ROOT, ROOT_OTHER, ROOT_TREE].some(root => e.path.startsWith(`${root}/`))
298
+ const path = isAbsolute ? e.path : `${r.cwd}${e.path.slice(e.path.indexOf('/.trailhead/'))}`
299
+ r.reads.push(path)
300
+ if (path.endsWith('/.trailhead/active-map') && r.beforeMarkerRead !== null) {
301
+ const hook = r.beforeMarkerRead
302
+ r.beforeMarkerRead = null
303
+ await hook()
304
+ }
305
+ const root = path.startsWith(`${ROOT_TREE}/`) ? ROOT_TREE : path.startsWith(`${ROOT_OTHER}/`) ? ROOT_OTHER : path.startsWith(`${ROOT}/`) ? ROOT : null
306
+ const marker = root === ROOT_TREE ? r.treeActive : root === ROOT_OTHER ? r.otherActive : r.active
307
+ const file = path.endsWith('/.trailhead/active-map') ? (root === null ? null : marker) : path.endsWith('/.trailhead/work-state.json') ? (root === null ? null : r.work) : undefined
183
308
  if (file === undefined) return { deny: `unexpected read of ${e.path}` }
184
309
  if (file === null) return { deny: `ENOENT: no such file or directory, open '${e.path}'` }
185
310
  if (file instanceof Error) return { deny: file.message }
@@ -196,16 +321,24 @@ const rig = (on: On, engine?: any) => {
196
321
  const emptied = new Set<string>()
197
322
  let clears = 0
198
323
  on('state.get', { plugin: 'trailhead' }, async (_$, e, next) => {
324
+ if (e.key in r.forced) return { value: { value: r.forced[e.key], version: 1 } }
199
325
  const read = await next(e)
200
326
  return emptied.has(e.key) ? { value: { value: undefined, version: read.value?.version ?? 0 } } : read
201
327
  })
202
- on('state.set', { plugin: 'trailhead' }, (_$, e, next) => {
328
+ on('state.set', { plugin: 'trailhead' }, async (_$, e, next) => {
329
+ if (e.key === 'pendingPhase') {
330
+ const hold = r.beforePhaseWrite
331
+ r.beforePhaseWrite = null
332
+ await hold?.()
333
+ r.phases.push(e.value)
334
+ }
335
+ delete r.forced[e.key]
203
336
  emptied.delete(e.key)
204
337
  return next(e)
205
338
  })
206
339
  const emulateClear = async () => {
207
340
  await engine.session.end({ reason: 'clear', sessionId: r.session, resume: {} })
208
- r.duringClear?.()
341
+ await r.duringClear?.()
209
342
  ATOM_KEYS.forEach(key => emptied.add(key))
210
343
  r.session = `${NEXT_SESSION}-${(clears += 1)}`
211
344
  await engine.classic.SessionStart({ source: 'clear' })
@@ -224,14 +357,52 @@ const rig = (on: On, engine?: any) => {
224
357
  if (r.refuse !== null) throw new Error(r.refuse)
225
358
  return { text: '' }
226
359
  })
227
- on('session.id', () => ({ value: r.session }))
360
+ on('session.id', async () => {
361
+ const hold = r.beforeSessionId
362
+ r.beforeSessionId = null
363
+ await hold?.()
364
+ return { value: r.session }
365
+ })
228
366
  on('ui.log', (_$, e) => {
229
367
  r.logs.push(e.text)
230
368
  return { value: undefined }
231
369
  })
232
370
  on('ui.status', () => ({ value: undefined }))
371
+ on('ui.toast', (_$, e) => {
372
+ r.toasts.push(e.text)
373
+ return { value: undefined }
374
+ })
375
+ on('session.repo', () => (r.repoFails ? { deny: 'repo read failed' } : { value: r.repo }))
376
+ on('session.root', () => ({ value: r.sessionRoot }))
377
+ on('session.cwd', () => ({ value: r.cwd }))
233
378
  on('process.run', async (_$, e) => {
379
+ // The pin's own `git rev-parse --show-toplevel`: answered apart, never counted as a `gh` call.
380
+ if (e.argv[0] === 'git') {
381
+ r.gitCalls.push({ argv: [...e.argv], cwd: e.init?.cwd })
382
+ // Answered per directory, like git: the worktree gives `top`, the other checkouts their own root.
383
+ const where = e.init?.cwd
384
+ if (e.argv[1] === 'remote') {
385
+ const remote = r.gitRemote === null ? { exitCode: 2, stdout: '', stderr: '' } : { exitCode: 0, stdout: `${r.gitRemote}\n`, stderr: '' }
386
+ return { value: { ...remote, isStdoutTruncated: false, isStderrTruncated: false } }
387
+ }
388
+ const top = where !== undefined && r.gone.includes(where) ? null : where === ROOT_TREE ? r.top : where === ROOT || where === ROOT_OTHER ? where : null
389
+ const out = top === null ? { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' } : { exitCode: 0, stdout: `${top}\n`, stderr: '' }
390
+ return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
391
+ }
392
+ // The pin's host check: answered apart, never counted as a read of the pane.
393
+ if (e.argv[1] === 'auth') {
394
+ const host = e.argv[4] ?? ''
395
+ r.authCalls.push(host)
396
+ const out = r.authHosts.includes(host) ? { exitCode: 0, stdout: '', stderr: '' } : { exitCode: 1, stdout: '', stderr: `You are not logged into any accounts on ${host}` }
397
+ return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
398
+ }
234
399
  r.calls.push([...e.argv])
400
+ r.cwds.push(e.init?.cwd)
401
+ // `gh` needs a repo around it: without its own cwd it uses the session's, wherever a `cd` left it.
402
+ const where = e.init?.cwd ?? r.cwd
403
+ if (where !== ROOT && where !== ROOT_OTHER && where !== ROOT_TREE) {
404
+ return { value: { exitCode: 1, stdout: '', stderr: 'failed to run git: fatal: not a git repository', isStdoutTruncated: false, isStderrTruncated: false } }
405
+ }
235
406
  if (r.deny !== null) return { deny: r.deny }
236
407
  const out = await (r.gh ?? defaultGh)(e.argv)
237
408
  return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
@@ -249,7 +420,7 @@ const rig = (on: On, engine?: any) => {
249
420
  return { value: undefined }
250
421
  })
251
422
  on('ui.panes', () => ({ value: r.panes }))
252
- on('tool.call', { tool: 'Bash' }, () => ({ result: 'ok' }) as never)
423
+ on('tool.call', { tool: 'Bash' }, () => ({ result: { stdout: r.stdout, stderr: '' } }) as never)
253
424
  on('session.start', (_$, e) => ({ cwd: e.cwd }))
254
425
  on('classic.SessionStart', () => ({}))
255
426
  on('turn.start', (_$, e) => ({ turnId: e.turnId }))
@@ -279,11 +450,11 @@ const settle = async (r: Rig) => {
279
450
  for (let i = 0; i < 10; i += 1) await r.clock.settle()
280
451
  }
281
452
  // The open-list reads; the history read rides along with each and is not counted.
282
- const fetches = (r: Rig) => r.calls.filter(argv => argv[2] === 'list' && argv.includes('open') && !argv.includes('trailhead:map') && !argv.includes('trailhead:uat')).length
453
+ const fetches = (r: Rig) => r.calls.filter(argv => isTicketsRead(argv) && !argv.some(a => a.startsWith('after='))).length
283
454
  // A gh that answers as the default does, except for the history read.
284
455
  const withHistory = (r: Rig, answer: GhOut): Gh => argv => {
285
456
  if (argv.includes('all')) return answer
286
- if (isGraphql(argv) || isMapsList(argv)) return r.defaultGh(argv)
457
+ if (isGraphql(argv) || isCountRead(argv) || isMapsList(argv)) return r.defaultGh(argv)
287
458
  return argv[2] === 'view' ? ok(JSON.stringify(r.map)) : ok(JSON.stringify(r.tickets))
288
459
  }
289
460
  const hasKey = async (ui: any, key: string): Promise<boolean> => (await ui.find({ key })) !== undefined
@@ -309,10 +480,11 @@ for (const surface of SURFACES) {
309
480
 
310
481
  expect(r.calls).toEqual([
311
482
  ['gh', 'issue', 'view', '198', '--json', 'title,body,url'],
312
- ['gh', 'issue', 'list', '--label', 'trailhead:map-198', '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url'],
483
+ ticketsCall('trailhead:map-198'),
313
484
  ['gh', 'issue', 'list', '--label', 'trailhead:map-198', '--state', 'all', '--limit', '5000', '--json', 'number,labels,createdAt,closedAt'],
314
485
  MAPS_CALL,
315
- uatCall('trailhead:map-198'),
486
+ uatCall(),
487
+ countCall(14, 198),
316
488
  ])
317
489
  expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
318
490
  expect((await ui.find({ type: 'Link' }))?.props).toMatchObject({ href: `${URL}/198` })
@@ -369,9 +541,9 @@ for (const surface of SURFACES) {
369
541
  issue(243, 'Paused UAT', ['trailhead:task', 'trailhead:uat', 'trailhead:paused']),
370
542
  ]
371
543
  r.uat = [
372
- { number: 240, body: `Run it\n\nUAT of: [Burn-down view of the map](${URL}/209)\n` },
373
- { number: 241, body: `UAT of: #212` },
374
- { number: 243, body: `UAT of: [Paused parent](${URL}/150)` },
544
+ uatRow(240, `Run it\n\nUAT of: [Burn-down view of the map](${URL}/209)\n`),
545
+ uatRow(241, `UAT of: #212`),
546
+ uatRow(243, `UAT of: [Paused parent](${URL}/150)`),
375
547
  ]
376
548
  await $.session.start(START)
377
549
  const ui = await openAndMount($, r)
@@ -391,6 +563,9 @@ for (const surface of SURFACES) {
391
563
  expect(await textOf(ui, 'uat-of-241')).toBe('tests #212')
392
564
  expect(await hasKey(ui, 'uat-of-242')).toBe(false)
393
565
  expect(await textOf(ui, 'uat-of-243')).toBe('tests Paused parent #150')
566
+ // the parents come through one GraphQL read, never through the search index
567
+ expect(r.calls.filter(isUatRead)).toEqual([uatCall()])
568
+ expect(r.calls.filter(isUatSearch)).toEqual([])
394
569
  // a marker, not a state: a frontier UAT task keeps its work button, a held one its holder, a seed stays dim
395
570
  expect(await hasKey(ui, 'work-240')).toBe(true)
396
571
  expect(await textOf(ui, 'holder-242')).toBe('@alice')
@@ -417,20 +592,73 @@ for (const surface of SURFACES) {
417
592
  expect(await hasKey(ui, 'error')).toBe(false)
418
593
  })
419
594
 
595
+ test(`a UAT row of another scope does not set the parent of a row with the same number [${surface}]`, async ($, on) => {
596
+ const r = rig(on, $)
597
+ r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
598
+ r.uat = [uatRow(240, `UAT of: [Elsewhere](${URL}/14)`, 'trailhead:map-14'), uatRow(241, `UAT of: #5`)]
599
+ await $.session.start(START)
600
+ const ui = await openAndMount($, r)
601
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
602
+ expect(await hasKey(ui, 'uat-of-240')).toBe(false)
603
+ })
604
+
605
+ test(`more than one page of follow-ups is read, the second call carrying the cursor [${surface}]`, async ($, on) => {
606
+ const r = rig(on, $)
607
+ r.tickets = [...TICKETS, issue(500, 'Deep UAT', ['trailhead:task', 'trailhead:uat'])]
608
+ r.uat = [...Array.from({ length: 149 }, (_, i) => uatRow(1000 + i, 'no parent line')), uatRow(500, `UAT of: [Deep parent](${URL}/77)`)]
609
+ await $.session.start(START)
610
+ const ui = await openAndMount($, r)
611
+ expect(r.calls.filter(isUatRead)).toEqual([uatCall(), uatCall('c100')])
612
+ expect(await textOf(ui, 'uat-of-500')).toBe('tests Deep parent #77')
613
+ })
614
+
615
+ const failClosed: [string, (r: Rig) => void][] = [
616
+ ['a failed second page', r => { r.gh = argv => (isUatRead(argv) && argv.some(a => a.startsWith('after=')) ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv)) }],
617
+ ['a reply with errors', r => { r.uatErrors = true }],
618
+ ['a reply that is not JSON', r => { r.uatBroken = true }],
619
+ ]
620
+ for (const [name, arm] of failClosed) {
621
+ test(`${name} draws the UAT rows with no parent line, no fallback, no partial map [${surface}]`, async ($, on) => {
622
+ const r = rig(on, $)
623
+ r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
624
+ // the parent sits on page 1; a second page exists so the failed-page case has something to lose
625
+ r.uat = [uatRow(240, `UAT of: [Burn-down](${URL}/209)`), ...Array.from({ length: 120 }, (_, i) => uatRow(1000 + i, 'x'))]
626
+ arm(r)
627
+ await $.session.start(START)
628
+ const ui = await openAndMount($, r)
629
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
630
+ expect(await hasKey(ui, 'uat-of-240')).toBe(false)
631
+ expect(await hasKey(ui, 'error')).toBe(false)
632
+ expect(r.calls.filter(isUatSearch)).toEqual([])
633
+ })
634
+ }
635
+
636
+ test(`a read still paging at the cap draws no parent line and stops at the cap [${surface}]`, async ($, on) => {
637
+ const r = rig(on, $)
638
+ r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
639
+ r.uat = [uatRow(240, `UAT of: [Burn-down](${URL}/209)`), ...Array.from({ length: UAT_MAX_PAGES * PAGE + 5 }, (_, i) => uatRow(2000 + i, 'x'))]
640
+ await $.session.start(START)
641
+ const ui = await openAndMount($, r)
642
+ expect(r.calls.filter(isUatRead)).toHaveLength(UAT_MAX_PAGES)
643
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
644
+ expect(await hasKey(ui, 'uat-of-240')).toBe(false)
645
+ })
646
+
420
647
  test(`the dashboard view groups UAT whiteboard tickets with their parent, reading the whiteboard scope [${surface}]`, async ($, on) => {
421
648
  const r = rig(on, $)
422
649
  r.whiteboard = [...WHITEBOARD, issue(306, 'Loose UAT', [...WB, 'trailhead:task', 'trailhead:uat'])]
423
- r.uat = [{ number: 306, body: `UAT of: [Loose parent](${URL}/290)` }]
650
+ r.uat = [uatRow(306, `UAT of: [Loose parent](${URL}/290)`, 'trailhead:whiteboard'), uatRow(306, `UAT of: #1`, 'trailhead:map-14')]
424
651
  await $.session.start(START)
425
652
  const ui = await openAndMount($, r)
426
653
  await pressDashboard(ui, r)
427
- expect(r.calls.some(argv => JSON.stringify(argv) === JSON.stringify(uatCall('trailhead:whiteboard')))).toBe(true)
654
+ expect(r.calls.filter(isUatRead)).toContainEqual(uatCall())
655
+ expect(r.calls.filter(isUatSearch)).toEqual([])
428
656
  expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
429
657
  expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
430
658
  expect(await textOf(ui, 'uat-of-306')).toBe('tests Loose parent #290')
431
659
  })
432
660
 
433
- test(`a paused ticket sits under Paused between In progress and Blocked, with no work button [${surface}]`, async ($, on) => {
661
+ test(`a paused ticket sits under Paused between In progress and Blocked, with a resume button [${surface}]`, async ($, on) => {
434
662
  const r = rig(on, $)
435
663
  r.work = stateText()
436
664
  r.tickets = [
@@ -449,11 +677,11 @@ for (const surface of SURFACES) {
449
677
  // a paused ticket appears in one group only: Frontier, In progress, Paused, Blocked in order
450
678
  expect(await keysOf(ui, 'row-')).toEqual(['row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-230', 'row-231', 'row-232', 'row-211', 'row-213'])
451
679
  expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-paused', 'group-blocked'])
452
- expect(await textOf(ui, 'holder-230')).toBe('@ToRvaLDz')
453
- expect((await ui.find({ key: 'holder-230' }))?.children[0].props).not.toHaveProperty('dimColor')
454
- expect(await textOf(ui, 'holder-232')).toBe('@bob')
455
- expect(await ui.find({ key: 'holder-231' })).toBeUndefined()
456
- for (const n of [230, 231, 232]) expect(await hasKey(ui, `work-${n}`)).toBe(false)
680
+ for (const n of [230, 231, 232]) {
681
+ expect(await hasKey(ui, `resume-${n}`)).toBe(true)
682
+ expect(await hasKey(ui, `work-${n}`)).toBe(false)
683
+ expect(await hasKey(ui, `holder-${n}`)).toBe(false)
684
+ }
457
685
  expect((await ui.find({ key: 'ticket-231' }))?.children[0].props).not.toHaveProperty('dimColor')
458
686
  })
459
687
 
@@ -466,7 +694,63 @@ for (const surface of SURFACES) {
466
694
  expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
467
695
  expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-305', 'row-303'])
468
696
  expect(await hasKey(ui, 'work-305')).toBe(false)
469
- expect(await textOf(ui, 'holder-305')).toBe('@alice')
697
+ expect(await hasKey(ui, 'resume-305')).toBe(true)
698
+ })
699
+
700
+ test(`needs-you tickets sit in their own group first, once each, with a work button unless held [${surface}]`, async ($, on) => {
701
+ const r = rig(on, $)
702
+ r.tickets = [
703
+ ...TICKETS,
704
+ issue(250, 'Needs you plain', ['trailhead:build', 'trailhead:needs-you']),
705
+ issue(251, 'Needs you paused', ['trailhead:build', 'trailhead:needs-you', 'trailhead:paused']),
706
+ issue(252, 'Needs you paused and held', ['trailhead:build', 'trailhead:needs-you', 'trailhead:paused'], ['bob']),
707
+ issue(253, 'Needs you UAT', ['trailhead:task', 'trailhead:needs-you', 'trailhead:uat']),
708
+ issue(254, 'Needs you held', ['trailhead:build', 'trailhead:needs-you'], ['alice']),
709
+ issue(255, 'Needs you blocked', ['trailhead:build', 'trailhead:needs-you', 'trailhead:blocked']),
710
+ ]
711
+ await $.session.start(START)
712
+ const ui = await openAndMount($, r)
713
+
714
+ expect(await textOf(ui, 'head-needs-you')).toBe('Needs you · 6')
715
+ expect((await ui.find({ key: 'head-needs-you' }))?.children[0]).toMatchObject({ props: { bold: true } })
716
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-needs-you', 'group-frontier', 'group-in-progress', 'group-blocked'])
717
+ // each ticket once, ahead of the frontier rows; the other counts leave them out
718
+ expect(await keysOf(ui, 'row-')).toEqual(['row-250', 'row-251', 'row-252', 'row-253', 'row-254', 'row-255', 'row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-211', 'row-213'])
719
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
720
+ expect(await hasKey(ui, 'head-paused')).toBe(false)
721
+ expect(await hasKey(ui, 'head-uat')).toBe(false)
722
+ for (const n of [250, 251, 253]) {
723
+ expect(await hasKey(ui, `work-${n}`)).toBe(true)
724
+ expect(await hasKey(ui, `resume-${n}`)).toBe(false)
725
+ }
726
+ for (const n of [252, 254]) {
727
+ expect(await textOf(ui, `holder-${n}`)).toBe(n === 252 ? '@bob' : '@alice')
728
+ expect(await hasKey(ui, `work-${n}`)).toBe(false)
729
+ expect(await hasKey(ui, `resume-${n}`)).toBe(false)
730
+ }
731
+ expect(await hasKey(ui, 'work-255')).toBe(false)
732
+ expect(await hasKey(ui, 'resume-255')).toBe(false)
733
+ expect(await hasKey(ui, 'holder-255')).toBe(false)
734
+ expect((await ui.find({ key: 'ticket-255' }))?.children[0].props).toMatchObject({ dimColor: true })
735
+
736
+ await ui.press({ key: 'work-251' })
737
+ await settle(r)
738
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '251' }])
739
+ })
740
+
741
+ test(`the dashboard view puts a needs-you whiteboard ticket first in its own group [${surface}]`, async ($, on) => {
742
+ const r = rig(on, $)
743
+ r.whiteboard = [...WHITEBOARD, issue(307, 'Loose needs you', [...WB, 'trailhead:task', 'trailhead:needs-you', 'trailhead:paused'])]
744
+ await $.session.start(START)
745
+ const ui = await openAndMount($, r)
746
+ await pressDashboard(ui, r)
747
+ expect(await textOf(ui, 'head-needs-you')).toBe('Needs you · 1')
748
+ // first ticket group, right after the maps group
749
+ expect((await keysOf(ui, 'group-')).slice(0, 2)).toEqual(['group-maps', 'group-needs-you'])
750
+ expect((await keysOf(ui, 'row-'))[0]).toBe('row-307')
751
+ expect(await hasKey(ui, 'head-paused')).toBe(false)
752
+ expect(await hasKey(ui, 'work-307')).toBe(true)
753
+ expect(await hasKey(ui, 'resume-307')).toBe(false)
470
754
  })
471
755
 
472
756
  test(`ticket rows are spaced apart on the desktop only [${surface}]`, async ($, on) => {
@@ -736,7 +1020,7 @@ for (const surface of SURFACES) {
736
1020
  await completeTurn($)
737
1021
  await settle(r)
738
1022
  // the open maps are still read, so one can be picked; no map is
739
- expect(r.calls).toEqual([MAPS_CALL])
1023
+ expect(r.calls).toEqual([MAPS_CALL, countCall(14, 198)])
740
1024
  })
741
1025
 
742
1026
  test(`first fetch in flight: the map number and Loading tickets [${surface}]`, async ($, on) => {
@@ -842,9 +1126,9 @@ for (const surface of SURFACES) {
842
1126
  await openAndMount($, r)
843
1127
  expect(fetches(r)).toBe(1)
844
1128
 
1129
+ // two turn ends with no write between: no flush, the trailing refresh is the only extra read
845
1130
  await r.clock.advance(2_000)
846
1131
  await completeTurn($)
847
- await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
848
1132
  await completeTurn($)
849
1133
  await settle(r)
850
1134
  expect(fetches(r)).toBe(1)
@@ -861,6 +1145,275 @@ for (const surface of SURFACES) {
861
1145
  expect(fetches(r)).toBe(2)
862
1146
  })
863
1147
 
1148
+ // A write followed by a turn end flushes: the turn end reads at once, inside the floor. The armed trailing
1149
+ // refresh is kept, so the read after the search index caught up still follows, one floor after the flush.
1150
+ test(`a write then a turn end inside the floor reads at once and keeps the trailing [${surface}]`, async ($, on) => {
1151
+ const r = rig(on, $)
1152
+ await $.session.start(START)
1153
+ await openAndMount($, r)
1154
+ expect(fetches(r)).toBe(1)
1155
+
1156
+ await r.clock.advance(2_000)
1157
+ await completeTurn($)
1158
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
1159
+ await completeTurn($)
1160
+ await settle(r)
1161
+ expect(fetches(r)).toBe(2)
1162
+ // a second turn end with no write since the flush adds nothing
1163
+ await completeTurn($)
1164
+ await settle(r)
1165
+ expect(fetches(r)).toBe(2)
1166
+
1167
+ // the trailing one fires at the first floor's end, lands inside the flush's floor and waits for its end
1168
+ await r.clock.advance(FLOOR - 2_000)
1169
+ await settle(r)
1170
+ expect(fetches(r)).toBe(2)
1171
+ await r.clock.advance(2_000)
1172
+ await settle(r)
1173
+ expect(fetches(r)).toBe(3)
1174
+ await r.clock.advance(FLOOR * 2)
1175
+ await settle(r)
1176
+ expect(fetches(r)).toBe(3)
1177
+ })
1178
+
1179
+ // The host drops a refused timer silently (a closed ticket stayed on the pane for good): the pane must not
1180
+ // wait on a dead handle.
1181
+ test(`a slow timer the host dropped is started again by the next trigger [${surface}]`, async ($, on) => {
1182
+ // Above the mock clock, which answers the wait without passing it on.
1183
+ let isDropping = false
1184
+ on('clock.every', { ms: TIMER }, async ($$, e, next) => (isDropping ? { deny: 'dropped' } : next(e)))
1185
+ const r = rig(on, $)
1186
+ await $.session.start(START)
1187
+ // the first period is refused: the interval ends unseen
1188
+ isDropping = true
1189
+ await openAndMount($, r)
1190
+ expect(fetches(r)).toBe(1)
1191
+ await r.clock.advance(TIMER)
1192
+ await settle(r)
1193
+ isDropping = false
1194
+ await r.clock.advance(TIMER)
1195
+ await settle(r)
1196
+ expect(fetches(r)).toBe(1)
1197
+
1198
+ // one trigger brings the timer back
1199
+ await completeTurn($)
1200
+ await settle(r)
1201
+ expect(fetches(r)).toBe(2)
1202
+ await r.clock.advance(TIMER)
1203
+ await settle(r)
1204
+ expect(fetches(r)).toBe(3)
1205
+ })
1206
+
1207
+ // The trailing refresh is dropped like the slow timer (a closed ticket stayed on the pane as held): the turn
1208
+ // end after a write reads at once, floor or not, so the final tracker state shows with no timer involved.
1209
+ test(`a turn end after a write shows the close although the trailing refresh was dropped [${surface}]`, async ($, on) => {
1210
+ on('clock.after', { ms: FLOOR }, async () => ({ deny: 'dropped' }))
1211
+ const r = rig(on, $)
1212
+ await $.session.start(START)
1213
+ const ui = await openAndMount($, r)
1214
+ await r.clock.advance(FLOOR)
1215
+ // the resolution comment reads at once, before the close
1216
+ await $.tool.call({ tool: 'Bash', command: 'gh issue comment 209 -b done' } as never)
1217
+ await settle(r)
1218
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1219
+ await r.clock.advance(2_000)
1220
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1221
+ await completeTurn($)
1222
+ await settle(r)
1223
+ expect(await hasKey(ui, 'row-209')).toBe(false)
1224
+ })
1225
+
1226
+ // Without a turn end, a pane drawn again past the dead trailing's due time and grace reads at once.
1227
+ test(`a pane drawn again after a dropped trailing refresh reads at once [${surface}]`, async ($, on) => {
1228
+ let isDropping = false
1229
+ on('clock.after', { ms: FLOOR }, async ($$, e, next) => (isDropping ? { deny: 'dropped' } : next(e)))
1230
+ const r = rig(on, $)
1231
+ await $.session.start(START)
1232
+ const ui = await openAndMount($, r)
1233
+ await r.clock.advance(FLOOR)
1234
+ isDropping = true
1235
+ await $.tool.call({ tool: 'Bash', command: 'gh issue comment 209 -b done' } as never)
1236
+ await settle(r)
1237
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1238
+ await r.clock.advance(2_000)
1239
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1240
+ await settle(r)
1241
+ isDropping = false
1242
+ // nothing fired for the close: the pane still shows the closed ticket
1243
+ await r.clock.advance(FLOOR * 4)
1244
+ await settle(r)
1245
+ expect(await hasKey(ui, 'row-209')).toBe(true)
1246
+ await ui.unmount()
1247
+ const again = await mountPane($)
1248
+ await settle(r)
1249
+ expect(await hasKey(again, 'row-209')).toBe(false)
1250
+ })
1251
+
1252
+ // The open list is a GraphQL read, never the search-backed `gh issue list --label`, which lags a write.
1253
+ const listReads = (r: Rig) => r.calls.filter(argv => argv[2] === 'list' && argv.includes('--label') && argv.includes('trailhead:map-198') && argv.includes('open') && !argv.includes('trailhead:uat'))
1254
+
1255
+ test(`a gh issue create shows the new ticket's row without a manual refresh [${surface}]`, async ($, on) => {
1256
+ const r = rig(on, $)
1257
+ await $.session.start(START)
1258
+ const ui = await openAndMount($, r)
1259
+ expect(await hasKey(ui, 'row-240')).toBe(false)
1260
+
1261
+ r.tickets = [...TICKETS, issue(240, 'Just created', ['trailhead:build'])]
1262
+ await r.clock.advance(FLOOR)
1263
+ await $.tool.call({ tool: 'Bash', command: 'gh issue create -t t' } as never)
1264
+ await settle(r)
1265
+ expect(await hasKey(ui, 'row-240')).toBe(true)
1266
+ expect(listReads(r)).toEqual([])
1267
+ })
1268
+
1269
+ test(`a gh issue close drops the closed ticket's row without a manual refresh [${surface}]`, async ($, on) => {
1270
+ const r = rig(on, $)
1271
+ await $.session.start(START)
1272
+ const ui = await openAndMount($, r)
1273
+ expect(await hasKey(ui, 'row-209')).toBe(true)
1274
+
1275
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1276
+ await r.clock.advance(FLOOR)
1277
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1278
+ await settle(r)
1279
+ expect(await hasKey(ui, 'row-209')).toBe(false)
1280
+ expect(listReads(r)).toEqual([])
1281
+ })
1282
+
1283
+ // The reads asked while the pane was undrawn (the person on another session) are dropped: drawn again with a
1284
+ // list older than one slow-timer period, the pane reads at once instead of showing a closed ticket.
1285
+ test(`a pane drawn again with an overdue list reads at once [${surface}]`, async ($, on) => {
1286
+ const r = rig(on, $)
1287
+ await $.session.start(START)
1288
+ const first = await openAndMount($, r)
1289
+ expect(fetches(r)).toBe(1)
1290
+
1291
+ await first.unmount()
1292
+ r.panes = r.panes.map(p => ({ ...p, isPlaced: false }))
1293
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1294
+ await r.clock.advance(FLOOR)
1295
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1296
+ await completeTurn($)
1297
+ await r.clock.advance(TIMER + 30_001)
1298
+ await settle(r)
1299
+ expect(fetches(r)).toBe(1)
1300
+
1301
+ r.panes = r.panes.map(p => ({ ...p, isPlaced: true }))
1302
+ const ui = await mountPane($)
1303
+ await settle(r)
1304
+ expect(fetches(r)).toBe(2)
1305
+ expect(await hasKey(ui, 'row-209')).toBe(false)
1306
+ })
1307
+
1308
+ test(`a create inside the floor shows at the floor's end [${surface}]`, async ($, on) => {
1309
+ const r = rig(on, $)
1310
+ await $.session.start(START)
1311
+ const ui = await openAndMount($, r)
1312
+ await r.clock.advance(2_000)
1313
+ r.tickets = [...TICKETS, issue(240, 'Just created', ['trailhead:build'])]
1314
+ await $.tool.call({ tool: 'Bash', command: 'gh issue create -t t' } as never)
1315
+ await settle(r)
1316
+ expect(await hasKey(ui, 'row-240')).toBe(false)
1317
+ await r.clock.advance(FLOOR - 2_000)
1318
+ await settle(r)
1319
+ expect(await hasKey(ui, 'row-240')).toBe(true)
1320
+ })
1321
+
1322
+ test(`a close inside the floor drops the row at the floor's end [${surface}]`, async ($, on) => {
1323
+ const r = rig(on, $)
1324
+ await $.session.start(START)
1325
+ const ui = await openAndMount($, r)
1326
+ await r.clock.advance(2_000)
1327
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1328
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1329
+ await settle(r)
1330
+ expect(await hasKey(ui, 'row-209')).toBe(true)
1331
+ await r.clock.advance(FLOOR - 2_000)
1332
+ await settle(r)
1333
+ expect(await hasKey(ui, 'row-209')).toBe(false)
1334
+ })
1335
+
1336
+ test(`a list of 150 tickets is read in two pages [${surface}]`, async ($, on) => {
1337
+ const r = rig(on, $)
1338
+ r.tickets = Array.from({ length: 150 }, (_, i) => issue(i + 1, `t${i + 1}`, ['trailhead:build']))
1339
+ await $.session.start(START)
1340
+ const ui = await openAndMount($, r)
1341
+ expect(r.calls.filter(isTicketsRead)).toEqual([ticketsCall('trailhead:map-198'), ticketsCall('trailhead:map-198', 'c100')])
1342
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 150')
1343
+ expect(await hasKey(ui, 'capped')).toBe(false)
1344
+ expect(listReads(r)).toEqual([])
1345
+ })
1346
+
1347
+ test(`a failure on the second page discards the read and falls back to gh issue list once [${surface}]`, async ($, on) => {
1348
+ const r = rig(on, $)
1349
+ r.tickets = Array.from({ length: 150 }, (_, i) => issue(i + 1, `t${i + 1}`, ['trailhead:build']))
1350
+ r.gh = argv => (isTicketsRead(argv) && argv.some(a => a.startsWith('after=')) ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv))
1351
+ await $.session.start(START)
1352
+ const ui = await openAndMount($, r)
1353
+ expect(listReads(r)).toEqual([ticketsListCall('trailhead:map-198')])
1354
+ // the fallback list is whole (the rig's list answer), never the 100 rows of page one
1355
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 150')
1356
+ expect(r.calls.filter(isTicketsRead)).toHaveLength(2)
1357
+ })
1358
+
1359
+ test(`an unreadable first page falls back the same way [${surface}]`, async ($, on) => {
1360
+ const r = rig(on, $)
1361
+ r.gh = argv => (isTicketsRead(argv) ? ok('not json') : argv[2] === 'list' && argv.includes('trailhead:map-198') && argv.includes('open') ? ok(JSON.stringify([issue(300, 'from the list', ['trailhead:build'])])) : r.defaultGh(argv))
1362
+ await $.session.start(START)
1363
+ const ui = await openAndMount($, r)
1364
+ expect(await keysOf(ui, 'row-')).toEqual(['row-300'])
1365
+ })
1366
+
1367
+ test(`the whiteboard read keeps only the rows that are also tickets [${surface}]`, async ($, on) => {
1368
+ const r = rig(on, $)
1369
+ r.whiteboard = [...WHITEBOARD, issue(310, 'Whiteboard label only', ['trailhead:whiteboard', 'trailhead:task'])]
1370
+ await $.session.start(START)
1371
+ const ui = await openAndMount($, r)
1372
+ await pressDashboard(ui, r)
1373
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
1374
+ })
1375
+
1376
+ test(`a write that reads at once is followed by one more read at the floor's end [${surface}]`, async ($, on) => {
1377
+ const r = rig(on, $)
1378
+ await $.session.start(START)
1379
+ await openAndMount($, r)
1380
+ await r.clock.advance(FLOOR)
1381
+ const before = fetches(r)
1382
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
1383
+ await settle(r)
1384
+ expect(fetches(r)).toBe(before + 1)
1385
+ await r.clock.advance(FLOOR - 1)
1386
+ await settle(r)
1387
+ expect(fetches(r)).toBe(before + 1)
1388
+ await r.clock.advance(1)
1389
+ await settle(r)
1390
+ expect(fetches(r)).toBe(before + 2)
1391
+ // the follow-up is spent
1392
+ await r.clock.advance(FLOOR)
1393
+ await settle(r)
1394
+ expect(fetches(r)).toBe(before + 2)
1395
+ })
1396
+
1397
+ test(`a burst of writes costs one read per floor plus one trailing [${surface}]`, async ($, on) => {
1398
+ const r = rig(on, $)
1399
+ await $.session.start(START)
1400
+ await openAndMount($, r)
1401
+ await r.clock.advance(FLOOR)
1402
+ const before = fetches(r)
1403
+ for (const command of ['gh issue close 5', 'gh issue create -t a', 'gh issue comment 5 -b x', 'gh issue edit 5 --add-label y', 'gh issue close 6']) {
1404
+ await $.tool.call({ tool: 'Bash', command } as never)
1405
+ await r.clock.advance(1_000)
1406
+ }
1407
+ await settle(r)
1408
+ expect(fetches(r)).toBe(before + 1)
1409
+ await r.clock.advance(FLOOR)
1410
+ await settle(r)
1411
+ expect(fetches(r)).toBe(before + 2)
1412
+ await r.clock.advance(FLOOR)
1413
+ await settle(r)
1414
+ expect(fetches(r)).toBe(before + 2)
1415
+ })
1416
+
864
1417
  test(`session start, turn complete and the slow timer refresh an open pane [${surface}]`, async ($, on) => {
865
1418
  const r = rig(on, $)
866
1419
  await $.session.start(START)
@@ -887,15 +1440,21 @@ for (const surface of SURFACES) {
887
1440
  await $.session.start(START)
888
1441
  await openAndMount($, r)
889
1442
  const base = fetches(r)
890
- for (const command of ['gh issue list --state open', 'ls -la', 'gh pr close 4', 'gh issue view 5']) {
1443
+ for (const command of ['gh issue list --state open', 'ls -la', 'gh pr close 4', 'gh issue view 5', 'gh api repos/o/r/issues/5', 'gh api graphql -f query=x']) {
891
1444
  await r.clock.advance(FLOOR)
892
1445
  await $.tool.call({ tool: 'Bash', command } as never)
893
1446
  await settle(r)
894
1447
  }
895
1448
  expect(fetches(r)).toBe(base)
896
1449
 
897
- for (const command of ['gh issue close 5', 'gh --repo o/r issue close 5', 'gh issue comment 5 -b hi', 'gh issue edit 5 --add-label x', 'gh issue create -t t', 'gh issue reopen 5']) {
898
- await r.clock.advance(FLOOR)
1450
+ for (const command of ['gh issue close 5', 'gh --repo o/r issue close 5', 'gh issue comment 5 -b hi', 'gh issue edit 5 --add-label x', 'gh issue create -t t', 'gh issue reopen 5', 'gh issue delete 5', 'gh api repos/o/r/issues/198/sub_issues -F sub_issue_id=1', 'node /t/trailhead-issue-body.js append --issue 198 --section x --line y']) {
1451
+ // until a whole floor passes with no read (the previous write's follow-up, the slow timer), so this write starts outside one
1452
+ let seen = -1
1453
+ while (seen !== fetches(r)) {
1454
+ seen = fetches(r)
1455
+ await r.clock.advance(FLOOR)
1456
+ await settle(r)
1457
+ }
899
1458
  const before = fetches(r)
900
1459
  await $.tool.call({ tool: 'Bash', command } as never)
901
1460
  await settle(r)
@@ -975,8 +1534,8 @@ for (const surface of SURFACES) {
975
1534
  const first = ui.press({ key: 'work-209' })
976
1535
  await settle(r)
977
1536
  expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
978
- expect(await textOf(ui, 'queued-209')).toBe('queued')
979
- expect((await ui.find({ key: 'queued-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
1537
+ expect(await textOf(ui, 'starting-209')).toBe('starting')
1538
+ expect((await ui.find({ key: 'starting-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
980
1539
  expect(await ui.find({ key: 'work-209' })).toBeUndefined()
981
1540
  expect(await ui.find({ key: 'work-210' })).toBeDefined()
982
1541
 
@@ -984,12 +1543,12 @@ for (const surface of SURFACES) {
984
1543
  await ui.press({ key: 'work-210' })
985
1544
  await settle(r)
986
1545
  expect(r.runs).toHaveLength(2)
987
- expect(await ui.find({ key: 'queued-210' })).toBeUndefined()
1546
+ expect(await ui.find({ key: 'starting-210' })).toBeUndefined()
988
1547
 
989
1548
  release()
990
1549
  await first
991
1550
  await settle(r)
992
- expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1551
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
993
1552
  expect(await ui.find({ key: 'work-209' })).toBeDefined()
994
1553
 
995
1554
  // free again: the next press goes through
@@ -1003,6 +1562,42 @@ for (const surface of SURFACES) {
1003
1562
  ])
1004
1563
  })
1005
1564
 
1565
+ test(`a resume button on a paused row runs /trailhead:resume, shares the one-at-a-time guard and the refusal [${surface}]`, async ($, on) => {
1566
+ const r = rig(on, $)
1567
+ let release: () => void = () => undefined
1568
+ r.gate = new Promise<void>(resolve => (release = resolve))
1569
+ r.tickets = [...TICKETS, issue(230, 'Paused one', ['trailhead:build', 'trailhead:paused'], ['alice'])]
1570
+ await $.session.start(START)
1571
+ const ui = await openAndMount($, r)
1572
+ expect((await ui.find({ key: 'resume-230' }))?.props).toMatchObject({ label: 'resume', variant: 'primary' })
1573
+
1574
+ const first = ui.press({ key: 'resume-230' })
1575
+ await settle(r)
1576
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:resume', args: '230' }])
1577
+ expect(await textOf(ui, 'starting-230')).toBe('starting')
1578
+ expect(await ui.find({ key: 'resume-230' })).toBeUndefined()
1579
+
1580
+ // a work press meanwhile is ignored: one in flight at a time
1581
+ await ui.press({ key: 'work-210' })
1582
+ await settle(r)
1583
+ expect(r.runs).toHaveLength(2)
1584
+
1585
+ release()
1586
+ await first
1587
+ await settle(r)
1588
+ expect(await ui.find({ key: 'starting-230' })).toBeUndefined()
1589
+ expect(await ui.find({ key: 'resume-230' })).toBeDefined()
1590
+
1591
+ // outside the pinned repo it goes through, handing the pinned checkout to the verb
1592
+ const runs = r.runs.length
1593
+ r.cwd = ROOT_OTHER
1594
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
1595
+ await ui.press({ key: 'resume-230' })
1596
+ await settle(r)
1597
+ expect(r.runs.slice(runs)).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:resume', args: `230 ${IN(ROOT)}` }])
1598
+ expect(r.toasts).toEqual([])
1599
+ })
1600
+
1006
1601
  test(`a work command that is refused clears the queued mark [${surface}]`, async ($, on) => {
1007
1602
  const r = rig(on, $)
1008
1603
  r.refuse = 'cannot run now'
@@ -1015,19 +1610,121 @@ for (const surface of SURFACES) {
1015
1610
  expect(await ui.find({ key: 'work-209' })).toBeDefined()
1016
1611
  })
1017
1612
 
1018
- test(`a work press clears the conversation first, the row staying queued across the reset [${surface}]`, async ($, on) => {
1613
+ test(`a work press with the session outside any repo goes through with the pinned checkout handed over [${surface}]`, async ($, on) => {
1019
1614
  const r = rig(on, $)
1020
- let releaseClear: () => void = () => undefined
1021
- let releaseWork: () => void = () => undefined
1022
- r.clearGate = new Promise<void>(resolve => (releaseClear = resolve))
1023
- r.gate = new Promise<void>(resolve => (releaseWork = resolve))
1024
1615
  await $.session.start(START)
1025
1616
  const ui = await openAndMount($, r)
1026
-
1027
- const first = ui.press({ key: 'work-209' })
1617
+ r.cwd = '/tmp'
1618
+ r.repo = null
1619
+ await ui.press({ key: 'work-209' })
1620
+ await settle(r)
1621
+ expect(r.toasts).toEqual([])
1622
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT)}` }])
1623
+ // The token the verb receives decodes (plain base64url, the helper's alphabet) to the pinned root; the suite has no Buffer.
1624
+ const [number, word] = r.runs[1].args.split(' ')
1625
+ expect(number).toBe('209')
1626
+ expect(word).toMatch(/^--in=[A-Za-z0-9_-]+$/)
1627
+ expect(word).toBe(IN(ROOT))
1628
+ expect(atob(word.slice('--in='.length).replace(/-/g, '+').replace(/_/g, '/'))).toBe(ROOT)
1629
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1630
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1631
+ })
1632
+
1633
+ test(`a work press with the session in another repo goes through with the pinned checkout handed over [${surface}]`, async ($, on) => {
1634
+ const r = rig(on, $)
1635
+ await $.session.start(START)
1636
+ const ui = await openAndMount($, r)
1637
+ r.cwd = ROOT_OTHER
1638
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
1639
+ await ui.press({ key: 'work-209' })
1640
+ await settle(r)
1641
+ expect(r.toasts).toEqual([])
1642
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT)}` }])
1643
+ })
1644
+
1645
+ test(`a pinned worktree checkout is handed over by its own root [${surface}]`, async ($, on) => {
1646
+ const r = rig(on, $)
1647
+ r.top = ROOT_TREE
1648
+ r.cwd = ROOT_TREE
1649
+ r.sessionRoot = ROOT_TREE
1650
+ r.active = '14\n'
1651
+ r.treeActive = '198\n'
1652
+ await $.session.start({ ...START, cwd: ROOT_TREE })
1653
+ const ui = await openAndMount($, r)
1654
+ // From inside it nothing is handed over.
1655
+ await ui.press({ key: 'work-209' })
1656
+ await settle(r)
1657
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1658
+ // A shell cd out of it: the token names the worktree root, not the main tree's.
1659
+ r.cwd = ROOT_OTHER
1660
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
1661
+ r.runs.length = 0
1662
+ await ui.press({ key: 'work-209' })
1663
+ await settle(r)
1664
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT_TREE)}` }])
1665
+ })
1666
+
1667
+ test(`a work press whose pinned checkout is gone while the session is out is refused before the clear [${surface}]`, async ($, on) => {
1668
+ const r = rig(on, $)
1669
+ await $.session.start(START)
1670
+ const ui = await openAndMount($, r)
1671
+ r.gone = [ROOT]
1672
+ r.cwd = '/tmp'
1673
+ r.repo = null
1674
+ await ui.press({ key: 'work-209' })
1675
+ await settle(r)
1676
+ expect(r.runs).toEqual([])
1677
+ expect(r.toasts).toHaveLength(1)
1678
+ expect(r.toasts[0]).toContain('#209')
1679
+ expect(r.toasts[0]).toContain('unreadable')
1680
+ expect(r.toasts[0].length).toBeLessThanOrEqual(40)
1681
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1682
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1683
+ })
1684
+
1685
+ test(`a work press in the pinned repo goes through although session.repo answers null [${surface}]`, async ($, on) => {
1686
+ const r = rig(on, $)
1687
+ await $.session.start(START)
1688
+ const ui = await openAndMount($, r)
1689
+ r.repo = null
1690
+ r.gitRemote = 'git@github.com:o/r.git'
1691
+ await ui.press({ key: 'work-209' })
1692
+ await settle(r)
1693
+ expect(r.toasts).toEqual([])
1694
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1695
+ })
1696
+
1697
+ test(`a work press whose checkout cannot be read is refused, and the next one from the repo goes through [${surface}]`, async ($, on) => {
1698
+ const r = rig(on, $)
1699
+ await $.session.start(START)
1700
+ const ui = await openAndMount($, r)
1701
+ r.repoFails = true
1702
+ await ui.press({ key: 'work-209' })
1703
+ await settle(r)
1704
+ expect(r.runs).toEqual([])
1705
+ expect(r.toasts).toHaveLength(1)
1706
+ expect(r.toasts[0]).toContain('unreadable')
1707
+ expect(r.toasts[0]).not.toContain('cd into')
1708
+ expect(r.toasts[0].length).toBeLessThanOrEqual(40)
1709
+ r.repoFails = false
1710
+ await ui.press({ key: 'work-209' })
1711
+ await settle(r)
1712
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1713
+ })
1714
+
1715
+ test(`a work press clears the conversation first, the row staying starting across the reset [${surface}]`, async ($, on) => {
1716
+ const r = rig(on, $)
1717
+ let releaseClear: () => void = () => undefined
1718
+ let releaseWork: () => void = () => undefined
1719
+ r.clearGate = new Promise<void>(resolve => (releaseClear = resolve))
1720
+ r.gate = new Promise<void>(resolve => (releaseWork = resolve))
1721
+ await $.session.start(START)
1722
+ const ui = await openAndMount($, r)
1723
+
1724
+ const first = ui.press({ key: 'work-209' })
1028
1725
  await settle(r)
1029
1726
  expect(r.runs).toEqual([{ command: 'clear', args: '' }])
1030
- expect(await textOf(ui, 'queued-209')).toBe('queued')
1727
+ expect(await textOf(ui, 'starting-209')).toBe('starting')
1031
1728
 
1032
1729
  // a second press while the clear is pending is ignored
1033
1730
  await ui.press({ key: 'work-210' })
@@ -1040,17 +1737,463 @@ for (const surface of SURFACES) {
1040
1737
  expect(r.session).toBe(`${NEXT_SESSION}-1`)
1041
1738
  expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1042
1739
  await ui.redraw()
1043
- expect(await textOf(ui, 'queued-209')).toBe('queued')
1740
+ expect(await textOf(ui, 'starting-209')).toBe('starting')
1044
1741
  await ui.press({ key: 'work-210' })
1045
1742
  expect(r.runs).toHaveLength(2)
1046
1743
 
1047
1744
  releaseWork()
1048
1745
  await first
1049
1746
  await settle(r)
1747
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
1748
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1749
+ })
1750
+
1751
+ // The main loop's turn, as the host raises it: a press during it is held by the panel until it ends.
1752
+ const startMainTurn = ($: any, id = 'held-turn') => $.turn.start({ text: 'go', turnId: id })
1753
+ const abortTurn = ($: any) =>
1754
+ $.turn.complete({ turnId: `t${(turns += 1)}`, answer: '', durationMs: 1, isAborted: true, reason: 'aborted' })
1755
+
1756
+ test(`a press during a running turn is held with a cancel button and runs when the turn ends [${surface}]`, async ($, on) => {
1757
+ const r = rig(on, $)
1758
+ await $.session.start(START)
1759
+ const ui = await openAndMount($, r)
1760
+ expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
1761
+
1762
+ await startMainTurn($)
1763
+ await ui.press({ key: 'work-209' })
1764
+ await settle(r)
1765
+ // Nothing reaches the host while the turn runs.
1766
+ expect(r.runs).toEqual([])
1767
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1768
+ expect((await ui.find({ key: 'queued-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
1769
+ expect((await ui.find({ key: 'cancel-209' }))?.props).toMatchObject({ label: 'cancel' })
1770
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
1771
+ expect(await ui.find({ key: 'work-209' })).toBeUndefined()
1772
+
1773
+ // A second press while one is held is ignored.
1774
+ await ui.press({ key: 'work-210' })
1775
+ await settle(r)
1776
+ expect(r.runs).toEqual([])
1777
+ expect(await ui.find({ key: 'queued-210' })).toBeUndefined()
1778
+
1779
+ await completeTurn($)
1780
+ await settle(r)
1781
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1782
+ expect(r.session).toBe(`${NEXT_SESSION}-1`)
1050
1783
  expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1051
1784
  expect(await ui.find({ key: 'work-209' })).toBeDefined()
1052
1785
  })
1053
1786
 
1787
+ test(`a press held in the repo while the session cd's out is sent with the pinned checkout handed over [${surface}]`, async ($, on) => {
1788
+ const r = rig(on, $)
1789
+ await $.session.start(START)
1790
+ const ui = await openAndMount($, r)
1791
+ await startMainTurn($)
1792
+ await ui.press({ key: 'work-209' })
1793
+ await settle(r)
1794
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1795
+ // The turn moves the session out of any repo (a GitHub repo would move the pin along with it).
1796
+ r.cwd = '/tmp'
1797
+ r.repo = null
1798
+ await completeTurn($)
1799
+ await settle(r)
1800
+ expect(r.toasts).toEqual([])
1801
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT)}` }])
1802
+ })
1803
+
1804
+ test(`a held press whose checkout cannot be read at release is refused before the clear [${surface}]`, async ($, on) => {
1805
+ const r = rig(on, $)
1806
+ await $.session.start(START)
1807
+ const ui = await openAndMount($, r)
1808
+ await startMainTurn($)
1809
+ await ui.press({ key: 'work-209' })
1810
+ await settle(r)
1811
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1812
+ r.repoFails = true
1813
+ await completeTurn($)
1814
+ await settle(r)
1815
+ expect(r.runs).toEqual([])
1816
+ expect(r.toasts).toHaveLength(1)
1817
+ expect(r.toasts[0]).toContain('unreadable')
1818
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1819
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1820
+ })
1821
+
1822
+ test(`a cancel before the turn ends sends nothing, keeps the conversation and puts the work button back [${surface}]`, async ($, on) => {
1823
+ const r = rig(on, $)
1824
+ await $.session.start(START)
1825
+ const ui = await openAndMount($, r)
1826
+ await startMainTurn($)
1827
+ await ui.press({ key: 'work-209' })
1828
+ await settle(r)
1829
+ expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
1830
+
1831
+ await ui.press({ key: 'cancel-209' })
1832
+ await settle(r)
1833
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1834
+ expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
1835
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1836
+
1837
+ await completeTurn($)
1838
+ await settle(r)
1839
+ expect(r.runs).toEqual([])
1840
+ expect(r.session).toBe(SESSION)
1841
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1842
+
1843
+ // The slot is free again: another row can be pressed and is held.
1844
+ await startMainTurn($, 'second')
1845
+ await ui.press({ key: 'work-210' })
1846
+ await settle(r)
1847
+ expect(await textOf(ui, 'queued-210')).toBe('queued')
1848
+ expect(r.runs).toEqual([])
1849
+ await completeTurn($)
1850
+ await settle(r)
1851
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
1852
+ })
1853
+
1854
+ test(`a cancel of a held resume press sends nothing [${surface}]`, async ($, on) => {
1855
+ const r = rig(on, $)
1856
+ r.tickets = [...TICKETS, issue(230, 'Paused one', ['trailhead:build', 'trailhead:paused'], ['alice'])]
1857
+ await $.session.start(START)
1858
+ const ui = await openAndMount($, r)
1859
+ await startMainTurn($)
1860
+ await ui.press({ key: 'resume-230' })
1861
+ await settle(r)
1862
+ expect(await textOf(ui, 'queued-230')).toBe('queued')
1863
+ await ui.press({ key: 'cancel-230' })
1864
+ await completeTurn($)
1865
+ await settle(r)
1866
+ expect(r.runs).toEqual([])
1867
+ expect(await ui.find({ key: 'resume-230' })).toBeDefined()
1868
+ })
1869
+
1870
+ test(`a press with no turn running starts at once and reads starting, with no cancel button [${surface}]`, async ($, on) => {
1871
+ const r = rig(on, $)
1872
+ let release: () => void = () => undefined
1873
+ r.gate = new Promise<void>(resolve => (release = resolve))
1874
+ await $.session.start(START)
1875
+ const ui = await openAndMount($, r)
1876
+ const first = ui.press({ key: 'work-209' })
1877
+ await settle(r)
1878
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1879
+ expect(await textOf(ui, 'starting-209')).toBe('starting')
1880
+ expect((await ui.find({ key: 'starting-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
1881
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1882
+ expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
1883
+ release()
1884
+ await first
1885
+ await settle(r)
1886
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
1887
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1888
+ })
1889
+
1890
+ test(`an interrupted turn releases a held press too [${surface}]`, async ($, on) => {
1891
+ const r = rig(on, $)
1892
+ await $.session.start(START)
1893
+ const ui = await openAndMount($, r)
1894
+ await startMainTurn($)
1895
+ await ui.press({ key: 'work-209' })
1896
+ await settle(r)
1897
+ expect(r.runs).toEqual([])
1898
+ await abortTurn($)
1899
+ await settle(r)
1900
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1901
+ })
1902
+
1903
+ test(`a subagent's turn does not hold a press, nor does its end release one [${surface}]`, async ($, on) => {
1904
+ const r = rig(on, $)
1905
+ await $.session.start(START)
1906
+ const ui = await openAndMount($, r)
1907
+ await $.turn.start({ text: 'sub', turnId: 'sub-1', agentId: 'agent-1' })
1908
+ await ui.press({ key: 'work-209' })
1909
+ await settle(r)
1910
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1911
+
1912
+ await startMainTurn($, 'main-1')
1913
+ await ui.press({ key: 'work-210' })
1914
+ await settle(r)
1915
+ expect(r.runs).toHaveLength(2)
1916
+ await $.turn.complete({ turnId: 'sub-1', answer: '', durationMs: 1, isAborted: false, reason: 'answer', agentId: 'agent-1' })
1917
+ await settle(r)
1918
+ expect(r.runs).toHaveLength(2)
1919
+ expect(await textOf(ui, 'queued-210')).toBe('queued')
1920
+ await completeTurn($)
1921
+ await settle(r)
1922
+ expect(r.runs).toHaveLength(4)
1923
+ })
1924
+
1925
+ test(`a turn that starts while the dispatch is still reading re-holds the press, cancel offered again [${surface}]`, async ($, on) => {
1926
+ const r = rig(on, $)
1927
+ await $.session.start(START)
1928
+ const ui = await openAndMount($, r)
1929
+ await startMainTurn($)
1930
+ await ui.press({ key: 'work-209' })
1931
+ await completeTurn($)
1932
+ // The next session id read is the dispatch's own, before its clear.
1933
+ r.beforeSessionId = async () => void (await startMainTurn($, 'late'))
1934
+ await settle(r)
1935
+ expect(r.runs).toEqual([])
1936
+ await ui.redraw()
1937
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1938
+ expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
1939
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
1940
+
1941
+ await completeTurn($)
1942
+ await settle(r)
1943
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1944
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1945
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1946
+ })
1947
+
1948
+ test(`a turn that starts between the end of one and the release keeps the press held until it ends [${surface}]`, async ($, on) => {
1949
+ const r = rig(on, $)
1950
+ await $.session.start(START)
1951
+ const ui = await openAndMount($, r)
1952
+ await startMainTurn($)
1953
+ await ui.press({ key: 'work-209' })
1954
+ await completeTurn($)
1955
+ // The release timer has not run yet: a queued prompt of the person's starts a turn.
1956
+ await startMainTurn($, 'next')
1957
+ await settle(r)
1958
+ expect(r.runs).toEqual([])
1959
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1960
+ expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
1961
+
1962
+ await completeTurn($)
1963
+ await settle(r)
1964
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1965
+ })
1966
+
1967
+ for (const reason of ['prompt_input_exit', 'logout', 'other'] as const) {
1968
+ test(`a session that ends (${reason}) while a press is held drops it and resets the turn flag [${surface}]`, async ($, on) => {
1969
+ const r = rig(on, $)
1970
+ await $.session.start(START)
1971
+ const ui = await openAndMount($, r)
1972
+ await startMainTurn($)
1973
+ await ui.press({ key: 'work-209' })
1974
+ await settle(r)
1975
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1976
+
1977
+ await $.session.end({ reason, sessionId: r.session, resume: {} })
1978
+ await settle(r)
1979
+ expect(r.runs).toEqual([])
1980
+ await ui.redraw()
1981
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1982
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1983
+
1984
+ // No turn is running in the next session: a press goes straight through.
1985
+ await ui.press({ key: 'work-210' })
1986
+ await settle(r)
1987
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
1988
+ })
1989
+ }
1990
+
1991
+ test(`a cancel pressed between the release and the clear sends nothing and puts the work button back [${surface}]`, async ($, on) => {
1992
+ const r = rig(on, $)
1993
+ await $.session.start(START)
1994
+ const ui = await openAndMount($, r)
1995
+ await startMainTurn($)
1996
+ await ui.press({ key: 'work-209' })
1997
+ await settle(r)
1998
+ await completeTurn($)
1999
+ // The row on screen still reads queued with its cancel button when the press lands, before the clear.
2000
+ let isPressed = false
2001
+ r.beforePhaseWrite = async () => {
2002
+ try {
2003
+ await ui.press({ key: 'cancel-209' })
2004
+ isPressed = true
2005
+ } catch (err) {
2006
+ r.logs.push(`seam press failed: ${String(err)}`)
2007
+ }
2008
+ }
2009
+ await settle(r)
2010
+ expect(r.logs.filter(l => l.startsWith('seam'))).toEqual([])
2011
+ expect(isPressed).toBe(true)
2012
+ expect(r.runs).toEqual([])
2013
+ expect(r.session).toBe(SESSION)
2014
+ await ui.redraw()
2015
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
2016
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
2017
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2018
+ // The slot is free again.
2019
+ await ui.press({ key: 'work-210' })
2020
+ await settle(r)
2021
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
2022
+ })
2023
+
2024
+ // A cancel from a screen a frame behind: the row is drawn queued (the engine reports that phase) for the press.
2025
+ const staleCancel = (ui: any, r: Rig, number: number) => async () => {
2026
+ r.forced = { pendingPhase: 'queued' }
2027
+ await ui.redraw()
2028
+ await ui.press({ key: `cancel-${number}` })
2029
+ }
2030
+
2031
+ test(`a cancel landing while a re-hold writes queued drops the press instead of holding it [${surface}]`, async ($, on) => {
2032
+ const r = rig(on, $)
2033
+ await $.session.start(START)
2034
+ const ui = await openAndMount($, r)
2035
+ await startMainTurn($)
2036
+ await ui.press({ key: 'work-209' })
2037
+ await settle(r)
2038
+ await completeTurn($)
2039
+ // A turn starts during the dispatch's reads: it re-holds, and the cancel lands on that queued write.
2040
+ r.beforeSessionId = async () => {
2041
+ await startMainTurn($, 'late')
2042
+ r.beforePhaseWrite = staleCancel(ui, r, 209)
2043
+ }
2044
+ await settle(r)
2045
+ await completeTurn($)
2046
+ await settle(r)
2047
+ expect(r.runs).toEqual([])
2048
+ expect(r.session).toBe(SESSION)
2049
+ await ui.redraw()
2050
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
2051
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
2052
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2053
+ })
2054
+
2055
+ test(`a cancel landing between the queued write and the hold drops the press [${surface}]`, async ($, on) => {
2056
+ const r = rig(on, $)
2057
+ await $.session.start(START)
2058
+ const ui = await openAndMount($, r)
2059
+ await startMainTurn($)
2060
+ await ui.press({ key: 'work-209' })
2061
+ await settle(r)
2062
+ await ui.press({ key: 'cancel-209' })
2063
+ await settle(r)
2064
+
2065
+ r.beforePhaseWrite = staleCancel(ui, r, 209)
2066
+ await ui.press({ key: 'work-209' })
2067
+ await settle(r)
2068
+ await completeTurn($)
2069
+ await settle(r)
2070
+ expect(r.runs).toEqual([])
2071
+ await ui.redraw()
2072
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
2073
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2074
+ })
2075
+
2076
+ test(`a press made right after a cancel keeps its queued mark and claim and runs when the turn ends [${surface}]`, async ($, on) => {
2077
+ const r = rig(on, $)
2078
+ await $.session.start(START)
2079
+ const ui = await openAndMount($, r)
2080
+ await startMainTurn($)
2081
+ await ui.press({ key: 'work-209' })
2082
+ await settle(r)
2083
+ // No settling in between: the cancel's own redraw must not let a stale-claim sweep erase the next press.
2084
+ await ui.press({ key: 'cancel-209' })
2085
+ await ui.press({ key: 'work-210' })
2086
+ await settle(r)
2087
+ await ui.redraw()
2088
+ await settle(r)
2089
+ expect(r.runs).toEqual([])
2090
+ expect(await textOf(ui, 'queued-210')).toBe('queued')
2091
+ expect(await ui.find({ key: 'cancel-210' })).toBeDefined()
2092
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2093
+
2094
+ await completeTurn($)
2095
+ await settle(r)
2096
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
2097
+ })
2098
+
2099
+ for (const phase of [null, 'starting'] as const) {
2100
+ test(`a pending claim with phase ${phase} and no press behind it is cleared when the pane draws [${surface}]`, async ($, on) => {
2101
+ const r = rig(on, $)
2102
+ await $.session.start(START)
2103
+ const ui = await openAndMount($, r)
2104
+ // What a hot reload leaves: the atoms say pending, the module state is empty.
2105
+ r.forced = { pendingWork: 209, pendingPhase: phase }
2106
+ await ui.redraw()
2107
+ await settle(r)
2108
+ await ui.redraw()
2109
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
2110
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2111
+ await ui.press({ key: 'work-210' })
2112
+ await settle(r)
2113
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
2114
+ })
2115
+ }
2116
+
2117
+ test(`a press with no turn running never shows queued or a cancel button [${surface}]`, async ($, on) => {
2118
+ const r = rig(on, $)
2119
+ let release: () => void = () => undefined
2120
+ r.clearGate = new Promise<void>(resolve => (release = resolve))
2121
+ await $.session.start(START)
2122
+ const ui = await openAndMount($, r)
2123
+ const first = ui.press({ key: 'work-209' })
2124
+ await settle(r)
2125
+ // The phase went straight to starting: queued was never written, so no cancel was ever offered.
2126
+ expect(r.phases[0]).toBe('starting')
2127
+ expect(r.phases).not.toContain('queued')
2128
+ expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
2129
+ release()
2130
+ await first
2131
+ await settle(r)
2132
+ })
2133
+
2134
+ for (const reason of ['clear', 'resume'] as const) {
2135
+ test(`a session that ends (${reason}) while a press is held drops it [${surface}]`, async ($, on) => {
2136
+ const r = rig(on, $)
2137
+ await $.session.start(START)
2138
+ const ui = await openAndMount($, r)
2139
+ await startMainTurn($)
2140
+ await ui.press({ key: 'work-209' })
2141
+ await settle(r)
2142
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
2143
+
2144
+ await $.session.end({ reason, sessionId: r.session, resume: {} })
2145
+ await settle(r)
2146
+ expect(r.runs).toEqual([])
2147
+ await ui.redraw()
2148
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
2149
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2150
+ // The turn flag was reset too: a press goes straight through.
2151
+ await ui.press({ key: 'work-210' })
2152
+ await settle(r)
2153
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
2154
+ })
2155
+ }
2156
+
2157
+ test(`a cancel on a stuck queued row (nothing held, nothing sending) clears it [${surface}]`, async ($, on) => {
2158
+ const r = rig(on, $)
2159
+ await $.session.start(START)
2160
+ const ui = await openAndMount($, r)
2161
+ // What a hot reload leaves: the atoms say queued, the module state is empty.
2162
+ r.forced = { pendingWork: 209, pendingPhase: 'queued' }
2163
+ await ui.redraw()
2164
+ expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
2165
+ await ui.press({ key: 'cancel-209' })
2166
+ await settle(r)
2167
+ await ui.redraw()
2168
+ expect(r.runs).toEqual([])
2169
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
2170
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2171
+ })
2172
+
2173
+ test(`the mod's own clear leaves the row starting across the reset until the verb settles [${surface}]`, async ($, on) => {
2174
+ const r = rig(on, $)
2175
+ let release: () => void = () => undefined
2176
+ r.gate = new Promise<void>(resolve => (release = resolve))
2177
+ await $.session.start(START)
2178
+ const ui = await openAndMount($, r)
2179
+ let during: string | undefined
2180
+ r.duringClear = async () => {
2181
+ await ui.redraw()
2182
+ during = await textOf(ui, 'starting-209')
2183
+ }
2184
+ const first = ui.press({ key: 'work-209' })
2185
+ await settle(r)
2186
+ expect(during).toBe('starting')
2187
+ await ui.redraw()
2188
+ expect(await textOf(ui, 'starting-209')).toBe('starting')
2189
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
2190
+ release()
2191
+ await first
2192
+ await settle(r)
2193
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
2194
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2195
+ })
2196
+
1054
2197
  test(`a clear that is refused runs no work command and clears the queued mark [${surface}]`, async ($, on) => {
1055
2198
  const r = rig(on, $)
1056
2199
  r.refuseClear = 'cannot clear now'
@@ -1235,6 +2378,27 @@ for (const surface of SURFACES) {
1235
2378
  expect((await panel($)).text).toBe('Map panel closed.')
1236
2379
  })
1237
2380
 
2381
+ test(`/trailhead:panel shows a pane the auto-open left undrawn instead of closing it [${surface}]`, async ($, on) => {
2382
+ const r = rig(on, $)
2383
+ // sessione vuota, terminale sotto la soglia dei pannelli non richiesti: l'auto-open resta non disegnato
2384
+ r.isPlaced = false
2385
+ await $.session.start(START)
2386
+ await mountBand($, FULLSCREEN)
2387
+ await settle(r)
2388
+ expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
2389
+
2390
+ // la richiesta della persona lo disegna a qualsiasi larghezza
2391
+ r.isPlaced = true
2392
+ expect((await panel($)).text).toBe('Map panel opened.')
2393
+ expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, true]])
2394
+ await settle(r)
2395
+ expect(fetches(r)).toBe(1)
2396
+
2397
+ // disegnato, il comando torna a chiuderlo
2398
+ expect((await panel($)).text).toBe('Map panel closed.')
2399
+ expect(r.panes).toEqual([])
2400
+ })
2401
+
1238
2402
  for (const withBand of [false, true]) {
1239
2403
  test(`the pane opens by itself once in fullscreen, the band ${withBand ? 'drawing' : 'yielding'} [${surface}]`, async ($, on) => {
1240
2404
  const r = rig(on, $)
@@ -1393,12 +2557,13 @@ for (const surface of SURFACES) {
1393
2557
  expect(await textOf(ui, 'burndown-counts')).toBe('open 2 · done 0 · removed 0 · peak 2')
1394
2558
  // the ticket in play belongs to the other map: nothing here is marked
1395
2559
  expect(await hasKey(ui, 'mark-208')).toBe(false)
1396
- expect(r.calls.slice(-5).map(argv => argv.slice(0, 4))).toEqual([
2560
+ expect(r.calls.slice(-6).map(argv => argv.slice(0, 4))).toEqual([
1397
2561
  ['gh', 'issue', 'view', '14'],
1398
- ['gh', 'issue', 'list', '--label'],
1399
- ['gh', 'issue', 'list', '--label'],
1400
2562
  ['gh', 'api', 'graphql', '-F'],
1401
2563
  ['gh', 'issue', 'list', '--label'],
2564
+ ['gh', 'api', 'graphql', '-F'],
2565
+ ['gh', 'api', 'graphql', '-F'],
2566
+ ['gh', 'api', 'graphql', '-F'],
1402
2567
  ])
1403
2568
  expect(r.calls.some(argv => argv.includes('trailhead:map-14'))).toBe(true)
1404
2569
 
@@ -1601,10 +2766,11 @@ for (const surface of SURFACES) {
1601
2766
  await pressDashboard(ui, r)
1602
2767
 
1603
2768
  expect(r.calls.slice(before)).toEqual([
1604
- ['gh', 'issue', 'list', '--label', 'trailhead:ticket', '--label', 'trailhead:whiteboard', '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url'],
2769
+ ticketsCall('trailhead:whiteboard'),
1605
2770
  ['gh', 'issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', 'number,url'],
1606
2771
  MAPS_CALL,
1607
- uatCall('trailhead:whiteboard'),
2772
+ uatCall(),
2773
+ countCall(14, 198),
1608
2774
  ])
1609
2775
  expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1610
2776
  expect((await ui.find({ key: 'dashboard-title' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
@@ -1789,25 +2955,185 @@ for (const surface of SURFACES) {
1789
2955
  expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
1790
2956
  })
1791
2957
 
1792
- test(`a failing counts read falls back to the name-only list with no footer error [${surface}]`, async ($, on) => {
2958
+ test(`a failing counts read falls back to the name-only list with no footer error [${surface}]`, async ($, on) => {
2959
+ const r = rig(on, $)
2960
+ const base = r.defaultGh
2961
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
2962
+ await $.session.start(START)
2963
+ const ui = await openAndMount($, r)
2964
+ expect(r.calls.filter(argv => argv.includes('trailhead:map') || isGraphql(argv))).toEqual([MAPS_CALL, MAPS_LIST_CALL])
2965
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2966
+ expect(await textOf(ui, 'map-aside-198')).toBe(' active')
2967
+ expect(await hasKey(ui, 'map-aside-14')).toBe(false)
2968
+ expect(await textOf(ui, 'age')).toBe('updated just now')
2969
+
2970
+ // a reply with errors is a failed read too
2971
+ r.gh = argv => (isGraphql(argv) ? ok(JSON.stringify({ data: { repository: { issues: { nodes: [] } } }, errors: [{ message: 'x' }] })) : base(argv))
2972
+ await r.clock.advance(FLOOR)
2973
+ await completeTurn($)
2974
+ await settle(r)
2975
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2976
+ expect(await hasKey(ui, 'map-aside-14')).toBe(false)
2977
+ })
2978
+
2979
+ // The open-ticket total across every open map plus the whiteboard (5 + 2 + 3 with the default counts).
2980
+ const TOTAL_TEXT = '10 open across all maps + whiteboard'
2981
+ const totalLines = async (ui: any): Promise<number> => (await ui.findAll({ key: 'open-total' })).length
2982
+ const refreshOnce = async ($: any, r: Rig) => {
2983
+ await r.clock.advance(FLOOR)
2984
+ await completeTurn($)
2985
+ await settle(r)
2986
+ }
2987
+
2988
+ test(`the total line shows exactly once in the map view, under the Whiteboard row [${surface}]`, async ($, on) => {
2989
+ const r = rig(on, $)
2990
+ await $.session.start(START)
2991
+ const ui = await openAndMount($, r)
2992
+ expect(await totalLines(ui)).toBe(1)
2993
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
2994
+ expect((await ui.find({ key: 'open-total' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
2995
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
2996
+ expect(keys.indexOf('open-total')).toBeGreaterThan(keys.indexOf('whiteboard-row'))
2997
+ expect(keys.indexOf('open-total')).toBeLessThan(keys.indexOf('group-frontier'))
2998
+ })
2999
+
3000
+ test(`the total line shows exactly once in the loading state and the no-active-map view [${surface}]`, async ($, on) => {
3001
+ const r = rig(on, $)
3002
+ r.active = null
3003
+ await $.session.start(START)
3004
+ const ui = await openAndMount($, r)
3005
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
3006
+ await completeTurn($)
3007
+ await settle(r)
3008
+ expect(await totalLines(ui)).toBe(1)
3009
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
3010
+
3011
+ // a map pressed while its read is held: Loading tickets, the last good total beside the Whiteboard row
3012
+ const base = r.defaultGh
3013
+ let release: () => void = () => undefined
3014
+ const held = new Promise<void>(resolve => (release = resolve))
3015
+ r.gh = async argv => {
3016
+ if (argv[2] === 'view' || isTicketsRead(argv)) await held
3017
+ return base(argv)
3018
+ }
3019
+ await ui.press({ key: 'map-14' })
3020
+ await settle(r)
3021
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
3022
+ expect(await totalLines(ui)).toBe(1)
3023
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
3024
+ release()
3025
+ await settle(r)
3026
+ })
3027
+
3028
+ test(`the total line shows exactly once under the Dashboard header row [${surface}]`, async ($, on) => {
3029
+ const r = rig(on, $)
3030
+ await $.session.start(START)
3031
+ const ui = await openAndMount($, r)
3032
+ await pressDashboard(ui, r)
3033
+ expect(await hasKey(ui, 'dashboard-title')).toBe(true)
3034
+ expect(await totalLines(ui)).toBe(1)
3035
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
3036
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
3037
+ expect(keys.indexOf('open-total')).toBeGreaterThan(keys.indexOf('header-row'))
3038
+ expect(keys.indexOf('open-total')).toBeLessThan(keys.indexOf('group-maps'))
3039
+ })
3040
+
3041
+ test(`the total is counted over the open maps' numbers, one extra call per read [${surface}]`, async ($, on) => {
3042
+ const r = rig(on, $)
3043
+ r.maps = [MAPS[0]]
3044
+ await $.session.start(START)
3045
+ const ui = await openAndMount($, r)
3046
+ expect(r.calls.filter(isCountRead)).toEqual([countCall(198)])
3047
+ // 5 open on the map + 3 on the whiteboard
3048
+ expect(await textOf(ui, 'open-total')).toBe('8 open across all maps + whiteboard')
3049
+ // a closed map is never counted: only the open maps are enumerated
3050
+ r.maps = MAPS
3051
+ await refreshOnce($, r)
3052
+ expect(r.calls.filter(isCountRead).at(-1)).toEqual(countCall(14, 198))
3053
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
3054
+ })
3055
+
3056
+ for (const mode of ['fail', 'errors', 'junk'] as const) {
3057
+ test(`a count read that fails (${mode}) before any good read draws no total line [${surface}]`, async ($, on) => {
3058
+ const r = rig(on, $)
3059
+ r.countMode = mode
3060
+ await $.session.start(START)
3061
+ const ui = await openAndMount($, r)
3062
+ expect(await totalLines(ui)).toBe(0)
3063
+ // the rest of the pane is unharmed and no footer error is raised for it
3064
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3065
+ expect(await textOf(ui, 'age')).toBe('updated just now')
3066
+ })
3067
+ }
3068
+
3069
+ test(`a later failing count read keeps the last good total while the open maps are the same [${surface}]`, async ($, on) => {
3070
+ const r = rig(on, $)
3071
+ await $.session.start(START)
3072
+ const ui = await openAndMount($, r)
3073
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
3074
+ r.countMode = 'fail'
3075
+ r.openCounts = { ...r.openCounts, 'trailhead:whiteboard': 99 }
3076
+ await refreshOnce($, r)
3077
+ expect(await totalLines(ui)).toBe(1)
3078
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
3079
+ // a good read takes over again
3080
+ r.countMode = 'ok'
3081
+ await refreshOnce($, r)
3082
+ expect(await textOf(ui, 'open-total')).toBe('106 open across all maps + whiteboard')
3083
+ })
3084
+
3085
+ test(`a failing count read after the open maps changed hides the total, never stale [${surface}]`, async ($, on) => {
3086
+ const r = rig(on, $)
3087
+ await $.session.start(START)
3088
+ const ui = await openAndMount($, r)
3089
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
3090
+ r.countMode = 'fail'
3091
+ r.maps = [MAPS[0]]
3092
+ await refreshOnce($, r)
3093
+ expect(await totalLines(ui)).toBe(0)
3094
+ })
3095
+
3096
+ test(`the name-only maps fallback draws no total and makes no count call [${surface}]`, async ($, on) => {
3097
+ const r = rig(on, $)
3098
+ const base = r.defaultGh
3099
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
3100
+ await $.session.start(START)
3101
+ const ui = await openAndMount($, r)
3102
+ expect(r.calls.filter(isCountRead)).toEqual([])
3103
+ expect(await totalLines(ui)).toBe(0)
3104
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
3105
+ })
3106
+
3107
+ test(`a fallback after a good total hides it rather than keep a figure over a list of unknown completeness [${surface}]`, async ($, on) => {
3108
+ const r = rig(on, $)
3109
+ const base = r.defaultGh
3110
+ await $.session.start(START)
3111
+ const ui = await openAndMount($, r)
3112
+ expect(await totalLines(ui)).toBe(1)
3113
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
3114
+ await refreshOnce($, r)
3115
+ expect(await totalLines(ui)).toBe(0)
3116
+ })
3117
+
3118
+ test(`a narrow pane keeps the lower bound in the short form [${surface}]`, async ($, on) => {
3119
+ const r = rig(on, $)
3120
+ r.countTotal = 101
3121
+ await $.session.start(START)
3122
+ const ui = await openAndMount($, r, { bodyColumns: 24 })
3123
+ expect(await textOf(ui, 'open-total')).toBe('≥10 open in total')
3124
+ })
3125
+
3126
+ test(`a truncated maps read words the total as a lower bound only [${surface}]`, async ($, on) => {
1793
3127
  const r = rig(on, $)
1794
- const base = r.defaultGh
1795
- r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
3128
+ // more open maps exist (101) than the two the read returned
3129
+ r.countTotal = 101
1796
3130
  await $.session.start(START)
1797
3131
  const ui = await openAndMount($, r)
1798
- expect(r.calls.filter(argv => argv.includes('trailhead:map') || isGraphql(argv))).toEqual([MAPS_CALL, MAPS_LIST_CALL])
1799
- expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1800
- expect(await textOf(ui, 'map-aside-198')).toBe(' active')
1801
- expect(await hasKey(ui, 'map-aside-14')).toBe(false)
1802
- expect(await textOf(ui, 'age')).toBe('updated just now')
1803
-
1804
- // a reply with errors is a failed read too
1805
- r.gh = argv => (isGraphql(argv) ? ok(JSON.stringify({ data: { repository: { issues: { nodes: [] } } }, errors: [{ message: 'x' }] })) : base(argv))
1806
- await r.clock.advance(FLOOR)
1807
- await completeTurn($)
1808
- await settle(r)
1809
- expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1810
- expect(await hasKey(ui, 'map-aside-14')).toBe(false)
3132
+ expect(await textOf(ui, 'open-total')).toBe('at least 10 open across all maps + whiteboard')
3133
+ // exactly as many maps as were read is not a cut
3134
+ r.countTotal = null
3135
+ await refreshOnce($, r)
3136
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
1811
3137
  })
1812
3138
 
1813
3139
  test(`the dashboard heading counts the whiteboard and every assigned trusted ticket is a row [${surface}]`, async ($, on) => {
@@ -1823,7 +3149,7 @@ for (const surface of SURFACES) {
1823
3149
  expect(await textOf(ui, 'head-whiteboard')).toBe('Whiteboard · 4 open · 2 assigned')
1824
3150
  expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
1825
3151
  expect(await textOf(ui, 'holder-302')).toBe('@alice')
1826
- expect(await textOf(ui, 'holder-306')).toBe('@erin')
3152
+ expect(await hasKey(ui, 'resume-306')).toBe(true)
1827
3153
  expect(await hasKey(ui, 'row-305')).toBe(false)
1828
3154
  expect(await textOf(ui, 'unverified')).toBe('2 unverified tickets hidden (quarantined)')
1829
3155
  })
@@ -1886,7 +3212,7 @@ for (const surface of SURFACES) {
1886
3212
  expect((await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)).not.toContain(`${URL}/5`)
1887
3213
 
1888
3214
  r.whiteboard = WHITEBOARD
1889
- r.gh = argv => (argv.includes('trailhead:whiteboard') ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv))
3215
+ r.gh = argv => (argv.includes('trailhead:whiteboard') || argv.includes('label=trailhead:whiteboard') ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv))
1890
3216
  await r.clock.advance(FLOOR)
1891
3217
  await ui.press({ key: 'refresh' })
1892
3218
  await settle(r)
@@ -1918,4 +3244,445 @@ for (const surface of SURFACES) {
1918
3244
  expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1919
3245
  expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
1920
3246
  })
3247
+ test(`the pane keeps its repo when the session cwd moves out of it [${surface}]`, async ($, on) => {
3248
+ const r = rig(on, $)
3249
+ await $.session.start(START)
3250
+ const ui = await openAndMount($, r)
3251
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3252
+ // A shell `cd /tmp`: no repo there, relative reads miss, and a `gh` without its own cwd fails.
3253
+ r.cwd = '/tmp'
3254
+ r.repo = null
3255
+ r.calls.length = 0
3256
+ r.cwds.length = 0
3257
+ await r.clock.advance(FLOOR)
3258
+ await completeTurn($)
3259
+ await settle(r)
3260
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3261
+ expect(await hasKey(ui, 'footer')).toBe(true)
3262
+ expect(await textOf(ui, 'age')).toBe('updated just now')
3263
+ expect(r.calls.length).toBeGreaterThan(0)
3264
+ expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
3265
+ })
3266
+
3267
+ test(`the pane follows a move to another GitHub repo and drops the old one's map [${surface}]`, async ($, on) => {
3268
+ const r = rig(on, $)
3269
+ await $.session.start(START)
3270
+ const ui = await openAndMount($, r)
3271
+ expect(await keysOf(ui, 'row-')).toContain('row-209')
3272
+ r.cwd = ROOT_OTHER
3273
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
3274
+ r.otherActive = '14\n'
3275
+ r.map = OTHER_MAP
3276
+ r.tickets = OTHER_TICKETS
3277
+ r.history = OTHER_HISTORY
3278
+ r.calls.length = 0
3279
+ r.cwds.length = 0
3280
+ await r.clock.advance(FLOOR)
3281
+ await completeTurn($)
3282
+ await settle(r)
3283
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
3284
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3285
+ expect(r.calls).toContainEqual(MAPS_CALL)
3286
+ expect(r.cwds.every(cwd => cwd === ROOT_OTHER)).toBe(true)
3287
+ // The toplevel is read where the session now is, not at its project root, which a shell `cd` leaves behind.
3288
+ expect(r.gitCalls.at(-1)).toEqual({ argv: ['git', 'rev-parse', '--show-toplevel'], cwd: ROOT_OTHER })
3289
+ expect(r.reads).toContain(`${ROOT_OTHER}/.trailhead/active-map`)
3290
+ })
3291
+
3292
+ test(`a session started outside any repo says so and runs no gh [${surface}]`, async ($, on) => {
3293
+ const r = rig(on, $)
3294
+ r.cwd = '/tmp'
3295
+ r.repo = null
3296
+ await $.session.start({ ...START, cwd: '/tmp' })
3297
+ const ui = await openAndMount($, r)
3298
+ expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
3299
+ await completeTurn($)
3300
+ await settle(r)
3301
+ expect(r.calls).toEqual([])
3302
+ })
3303
+
3304
+ test(`a remote that names no host and repo is no pin: the same message [${surface}]`, async ($, on) => {
3305
+ const r = rig(on, $)
3306
+ r.repo = { root: ROOT, remote: '/srv/git/r.git', internal: false }
3307
+ await $.session.start(START)
3308
+ const ui = await openAndMount($, r)
3309
+ expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
3310
+ expect(r.calls).toEqual([])
3311
+ })
3312
+ test(`a worktree session reads both files and runs gh under its own toplevel [${surface}]`, async ($, on) => {
3313
+ const r = rig(on, $)
3314
+ // The repo's root is the main tree's; the session works in the linked worktree.
3315
+ r.top = ROOT_TREE
3316
+ r.cwd = ROOT_TREE
3317
+ r.sessionRoot = ROOT_TREE
3318
+ r.active = '14\n'
3319
+ r.treeActive = '198\n'
3320
+ r.work = stateText()
3321
+ await $.session.start({ ...START, cwd: ROOT_TREE })
3322
+ const ui = await openAndMount($, r)
3323
+ expect(r.gitCalls[0]).toEqual({ argv: ['git', 'rev-parse', '--show-toplevel'], cwd: ROOT_TREE })
3324
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3325
+ expect(r.reads).toContain(`${ROOT_TREE}/.trailhead/active-map`)
3326
+ expect(r.reads).toContain(`${ROOT_TREE}/.trailhead/work-state.json`)
3327
+ // Il record del run vive nel main checkout (#296): e l'unica lettura ammessa fuori dal toplevel.
3328
+ expect(r.reads.filter(path => path.startsWith(`${ROOT}/`) && !path.endsWith('/.trailhead/auto-run.json'))).toEqual([])
3329
+ expect(r.cwds.length).toBeGreaterThan(0)
3330
+ expect(r.cwds.every(cwd => cwd === ROOT_TREE)).toBe(true)
3331
+ })
3332
+
3333
+ for (const remote of ['git@ghe.example.com:o/r.git', 'ssh://git@GitHub.com:22/o/r', 'https://user@github.com/o/r.git']) {
3334
+ test(`the remote ${remote} still pins; gh finds the host itself [${surface}]`, async ($, on) => {
3335
+ const r = rig(on, $)
3336
+ r.repo = { root: ROOT, remote, internal: false }
3337
+ r.authHosts = ['ghe.example.com']
3338
+ await $.session.start(START)
3339
+ const ui = await openAndMount($, r)
3340
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3341
+ expect(r.calls).toContainEqual(MAPS_CALL)
3342
+ expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
3343
+ })
3344
+ }
3345
+
3346
+ test(`a refresh in flight when only the band's refresh moves the pin never commits the old repo's tickets [${surface}]`, async ($, on) => {
3347
+ const r = rig(on, $)
3348
+ await $.session.start(START)
3349
+ let release: () => void = () => {}
3350
+ const gate = new Promise<void>(resolve => {
3351
+ release = resolve
3352
+ })
3353
+ // The answer is fixed when the call starts, so the reads begun in /repo keep /repo's tickets.
3354
+ r.gh = async argv => {
3355
+ const answer = r.defaultGh(argv)
3356
+ await gate
3357
+ return answer
3358
+ }
3359
+ r.open()
3360
+ const ui = await mountPane($)
3361
+ await settle(r)
3362
+ // The same map number in the other repo; a turn start refreshes the band, which moves the pin
3363
+ // while the pane has no hook on it.
3364
+ r.cwd = ROOT_OTHER
3365
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
3366
+ r.otherActive = '198\n'
3367
+ r.tickets = OTHER_TICKETS
3368
+ r.history = OTHER_HISTORY
3369
+ await $.turn.start({ text: 'go', turnId: 'race-1' })
3370
+ release()
3371
+ await settle(r)
3372
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3373
+ })
3374
+
3375
+ test(`a refresh in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
3376
+ const r = rig(on, $)
3377
+ await $.session.start(START)
3378
+ let release: () => void = () => {}
3379
+ const gate = new Promise<void>(resolve => {
3380
+ release = resolve
3381
+ })
3382
+ // The answer is fixed when the call starts, so the reads begun in /repo keep its tracker state.
3383
+ r.gh = async argv => {
3384
+ const answer = r.defaultGh(argv)
3385
+ await gate
3386
+ return answer
3387
+ }
3388
+ r.open()
3389
+ const ui = await mountPane($)
3390
+ await settle(r)
3391
+ // Another checkout of the SAME repo (same remote), with map 198 active there; the tracker changed after the
3392
+ // read started. A turn start refreshes the band, which moves the pin while the read is still in flight.
3393
+ r.cwd = ROOT_OTHER
3394
+ r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
3395
+ r.otherActive = '198\n'
3396
+ r.tickets = OTHER_TICKETS
3397
+ r.history = OTHER_HISTORY
3398
+ await $.turn.start({ text: 'go', turnId: 'race-same-repo' })
3399
+ release()
3400
+ await settle(r)
3401
+ await r.clock.advance(FLOOR)
3402
+ await settle(r)
3403
+ // The dropped read is repeated: the pane shows the new tracker state, not the old list.
3404
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3405
+ })
3406
+
3407
+ test(`a same-repo pin move landing inside the stale check is read again [${surface}]`, async ($, on) => {
3408
+ const r = rig(on, $)
3409
+ await $.session.start(START)
3410
+ let release: () => void = () => {}
3411
+ const gate = new Promise<void>(resolve => {
3412
+ release = resolve
3413
+ })
3414
+ r.gh = async argv => {
3415
+ const answer = r.defaultGh(argv)
3416
+ await gate
3417
+ return answer
3418
+ }
3419
+ r.open()
3420
+ const ui = await mountPane($)
3421
+ await settle(r)
3422
+ // The first marker read once the gate opens is the stale check's last await: the move lands there,
3423
+ // after the check read the old pin and before it decides.
3424
+ r.beforeMarkerRead = async () => {
3425
+ r.cwd = ROOT_OTHER
3426
+ r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
3427
+ r.otherActive = '198\n'
3428
+ r.tickets = OTHER_TICKETS
3429
+ r.history = OTHER_HISTORY
3430
+ await $.turn.start({ text: 'go', turnId: 'race-in-check' })
3431
+ }
3432
+ release()
3433
+ await settle(r)
3434
+ await r.clock.advance(FLOOR)
3435
+ await settle(r)
3436
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3437
+ })
3438
+
3439
+ test(`a dashboard read in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
3440
+ const r = rig(on, $)
3441
+ r.maps = [MAPS[0]]
3442
+ await $.session.start(START)
3443
+ const ui = await openAndMount($, r)
3444
+ let release: () => void = () => {}
3445
+ const gate = new Promise<void>(resolve => {
3446
+ release = resolve
3447
+ })
3448
+ r.gh = async argv => {
3449
+ const answer = r.defaultGh(argv)
3450
+ await gate
3451
+ return answer
3452
+ }
3453
+ // The dashboard read starts under /repo and waits on the gate.
3454
+ await ui.press({ key: 'open-whiteboard' })
3455
+ await settle(r)
3456
+ r.cwd = ROOT_OTHER
3457
+ r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
3458
+ r.otherActive = '198\n'
3459
+ r.whiteboard = [issue(320, 'New loose one', [...WB, 'trailhead:bug'])]
3460
+ await $.turn.start({ text: 'go', turnId: 'race-same-repo-dash' })
3461
+ release()
3462
+ await settle(r)
3463
+ await r.clock.advance(FLOOR)
3464
+ await settle(r)
3465
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
3466
+ expect(await keysOf(ui, 'row-')).toEqual(['row-320'])
3467
+ })
3468
+
3469
+ test(`the pin survives /clear after the session cwd moved out of the repo [${surface}]`, async ($, on) => {
3470
+ const r = rig(on, $)
3471
+ r.work = stateText()
3472
+ await $.session.start(START)
3473
+ const ui = await openAndMount($, r)
3474
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3475
+ r.cwd = '/tmp'
3476
+ r.repo = null
3477
+ r.calls.length = 0
3478
+ r.cwds.length = 0
3479
+ await $.command.run({ command: 'clear', args: '' } as never)
3480
+ await settle(r)
3481
+ expect(await hasKey(ui, 'no-repo')).toBe(false)
3482
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3483
+ expect(r.calls.length).toBeGreaterThan(0)
3484
+ expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
3485
+ })
3486
+ test(`a session started in a host gh does not serve says so and runs no gh read [${surface}]`, async ($, on) => {
3487
+ const r = rig(on, $)
3488
+ r.repo = { root: ROOT, remote: `git@gitlab-${surface}.example.com:o/r.git`, internal: false }
3489
+ await $.session.start(START)
3490
+ const ui = await openAndMount($, r)
3491
+ await completeTurn($)
3492
+ await settle(r)
3493
+ expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
3494
+ expect(r.calls).toEqual([])
3495
+ expect(r.authCalls).toEqual([`gitlab-${surface}.example.com`])
3496
+ })
3497
+
3498
+ test(`a cd into a GitLab repo keeps the pin and the map [${surface}]`, async ($, on) => {
3499
+ const r = rig(on, $)
3500
+ await $.session.start(START)
3501
+ const ui = await openAndMount($, r)
3502
+ r.cwd = ROOT_OTHER
3503
+ r.repo = { root: ROOT_OTHER, remote: 'https://lab.example.com/o/lab.git', internal: false }
3504
+ r.calls.length = 0
3505
+ r.cwds.length = 0
3506
+ await r.clock.advance(FLOOR)
3507
+ await completeTurn($)
3508
+ await settle(r)
3509
+ expect(r.authCalls).toEqual(['lab.example.com'])
3510
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3511
+ expect(await hasKey(ui, 'no-repo')).toBe(false)
3512
+ expect(r.calls.length).toBeGreaterThan(0)
3513
+ expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
3514
+ })
3515
+
3516
+ test(`the host check runs once per host for the session [${surface}]`, async ($, on) => {
3517
+ const r = rig(on, $)
3518
+ r.repo = { root: ROOT, remote: `git@once-${surface}.example.com:o/r.git`, internal: false }
3519
+ r.authHosts = [`once-${surface}.example.com`]
3520
+ await $.session.start(START)
3521
+ const ui = await openAndMount($, r)
3522
+ for (let i = 0; i < 3; i += 1) {
3523
+ await r.clock.advance(FLOOR)
3524
+ await completeTurn($)
3525
+ await settle(r)
3526
+ }
3527
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3528
+ expect(r.authCalls).toEqual([`once-${surface}.example.com`])
3529
+ })
3530
+ test(`a failed host check is retried after a while, so a later login pins [${surface}]`, async ($, on) => {
3531
+ const r = rig(on, $)
3532
+ const host = `late-${surface}.example.com`
3533
+ r.repo = { root: ROOT, remote: `git@${host}:o/r.git`, internal: false }
3534
+ await $.session.start(START)
3535
+ const ui = await openAndMount($, r)
3536
+ expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
3537
+ // A turn soon after does not ask again; one after the retry window does, and the login now holds.
3538
+ await r.clock.advance(FLOOR)
3539
+ await completeTurn($)
3540
+ await settle(r)
3541
+ expect(r.authCalls).toEqual([host])
3542
+ r.authHosts = [host]
3543
+ await r.clock.advance(60_000)
3544
+ await completeTurn($)
3545
+ await settle(r)
3546
+ expect(r.authCalls).toEqual([host, host])
3547
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3548
+ })
3549
+ test(`a pin moved by the band alone never leaves the old repo's rows drawn [${surface}]`, async ($, on) => {
3550
+ const r = rig(on, $)
3551
+ await $.session.start(START)
3552
+ const ui = await openAndMount($, r)
3553
+ expect(await keysOf(ui, 'row-')).toContain('row-209')
3554
+ r.cwd = ROOT_OTHER
3555
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
3556
+ r.otherActive = '14\n'
3557
+ r.map = OTHER_MAP
3558
+ r.tickets = OTHER_TICKETS
3559
+ r.history = OTHER_HISTORY
3560
+ // Only the band hooks a turn start: it moves the pin, no read of the pane is in flight.
3561
+ await $.turn.start({ text: 'go', turnId: 'band-1' })
3562
+ expect(await keysOf(ui, 'row-')).not.toContain('row-209')
3563
+ await settle(r)
3564
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
3565
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3566
+ })
3567
+
3568
+ // The panel follows the whiteboard ticket this session takes (#306).
3569
+ const WB_STATE = { map: null, ticket: 301, title: 'Fix the README badge', step: 'discuss' }
3570
+ const BOARD_KEY = 'dashboard-title'
3571
+ const CREATE_WB = 'gh issue create -t t --label trailhead:whiteboard'
3572
+
3573
+ test(`an own whiteboard ticket in play switches the panel to the whiteboard [${surface}]`, async ($, on) => {
3574
+ const r = rig(on, $)
3575
+ r.work = stateText(WB_STATE)
3576
+ await $.session.start(START)
3577
+ const ui = await openAndMount($, r)
3578
+ expect(await hasKey(ui, BOARD_KEY)).toBe(true)
3579
+ expect(await hasKey(ui, 'map')).toBe(false)
3580
+ })
3581
+
3582
+ test(`going back to the map by hand is not undone by the next turn [${surface}]`, async ($, on) => {
3583
+ const r = rig(on, $)
3584
+ r.work = stateText(WB_STATE)
3585
+ await $.session.start(START)
3586
+ const ui = await openAndMount($, r)
3587
+ await ui.press({ key: 'to-map' })
3588
+ await settle(r)
3589
+ expect(await hasKey(ui, BOARD_KEY)).toBe(false)
3590
+ await r.clock.advance(FLOOR)
3591
+ await completeTurn($)
3592
+ await settle(r)
3593
+ expect(await hasKey(ui, BOARD_KEY)).toBe(false)
3594
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3595
+ })
3596
+
3597
+ test(`the followed ticket is not forced again after /clear [${surface}]`, async ($, on) => {
3598
+ const r = rig(on, $)
3599
+ r.work = stateText(WB_STATE)
3600
+ await $.session.start(START)
3601
+ const ui = await openAndMount($, r)
3602
+ await ui.press({ key: 'to-map' })
3603
+ await settle(r)
3604
+ await $.command.run({ command: 'clear', args: '' } as never)
3605
+ r.work = stateText({ ...WB_STATE, session: r.session })
3606
+ await ui.redraw()
3607
+ await settle(r)
3608
+ await r.clock.advance(FLOOR)
3609
+ await completeTurn($)
3610
+ await settle(r)
3611
+ expect(await hasKey(ui, BOARD_KEY)).toBe(false)
3612
+ })
3613
+
3614
+ test(`a state that appears mid-session is followed after the helper call [${surface}]`, async ($, on) => {
3615
+ const r = rig(on, $)
3616
+ await $.session.start(START)
3617
+ const ui = await openAndMount($, r)
3618
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3619
+ r.work = stateText(WB_STATE)
3620
+ await $.tool.call({ tool: 'Bash', command: 'node /x/trailhead-work-state.js set --ticket 301' } as never)
3621
+ await settle(r)
3622
+ expect(await hasKey(ui, BOARD_KEY)).toBe(true)
3623
+ })
3624
+
3625
+ test(`a state that appears before a turn ends is followed [${surface}]`, async ($, on) => {
3626
+ const r = rig(on, $)
3627
+ await $.session.start(START)
3628
+ const ui = await openAndMount($, r)
3629
+ r.work = stateText(WB_STATE)
3630
+ await r.clock.advance(FLOOR)
3631
+ await completeTurn($)
3632
+ await settle(r)
3633
+ expect(await hasKey(ui, BOARD_KEY)).toBe(true)
3634
+ })
3635
+
3636
+ test(`a map ticket in play does not switch the panel [${surface}]`, async ($, on) => {
3637
+ const r = rig(on, $)
3638
+ r.work = stateText()
3639
+ await $.session.start(START)
3640
+ const ui = await openAndMount($, r)
3641
+ expect(await hasKey(ui, BOARD_KEY)).toBe(false)
3642
+ })
3643
+
3644
+ test(`a Resolve leaves the panel on the whiteboard [${surface}]`, async ($, on) => {
3645
+ const r = rig(on, $)
3646
+ r.work = stateText(WB_STATE)
3647
+ await $.session.start(START)
3648
+ const ui = await openAndMount($, r)
3649
+ r.work = null
3650
+ await r.clock.advance(FLOOR)
3651
+ await completeTurn($)
3652
+ await settle(r)
3653
+ expect(await hasKey(ui, BOARD_KEY)).toBe(true)
3654
+ })
3655
+
3656
+ test(`a whiteboard ticket created while a map is shown raises one toast and no switch [${surface}]`, async ($, on) => {
3657
+ const r = rig(on, $)
3658
+ await $.session.start(START)
3659
+ const ui = await openAndMount($, r)
3660
+ r.stdout = `${URL}/310\n`
3661
+ await $.tool.call({ tool: 'Bash', command: CREATE_WB } as never)
3662
+ await settle(r)
3663
+ expect(r.toasts).toEqual(['new whiteboard ticket #310: open Whiteboard'])
3664
+ expect(await hasKey(ui, BOARD_KEY)).toBe(false)
3665
+ })
3666
+
3667
+ test(`a whiteboard ticket created on the whiteboard view raises no toast [${surface}]`, async ($, on) => {
3668
+ const r = rig(on, $)
3669
+ await $.session.start(START)
3670
+ const ui = await openAndMount($, r)
3671
+ await ui.press({ key: 'open-whiteboard' })
3672
+ await settle(r)
3673
+ r.stdout = `${URL}/310\n`
3674
+ await $.tool.call({ tool: 'Bash', command: CREATE_WB } as never)
3675
+ await settle(r)
3676
+ expect(r.toasts).toEqual([])
3677
+ })
3678
+
3679
+ test(`a ticket created without the whiteboard label raises no toast [${surface}]`, async ($, on) => {
3680
+ const r = rig(on, $)
3681
+ await $.session.start(START)
3682
+ await openAndMount($, r)
3683
+ r.stdout = `${URL}/310\n`
3684
+ await $.tool.call({ tool: 'Bash', command: 'gh issue create -t t --label trailhead:task' } as never)
3685
+ await settle(r)
3686
+ expect(r.toasts).toEqual([])
3687
+ })
1921
3688
  }