@marcomigozzi/trailhead 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.it.md +67 -18
  2. package/README.md +67 -18
  3. package/bin/capture-action.test.js +167 -0
  4. package/bin/capture-docs.test.js +32 -0
  5. package/bin/config-dir-templates-docs.test.js +52 -0
  6. package/bin/decision-builds-docs.test.js +75 -0
  7. package/bin/engine-route-docs.test.js +148 -0
  8. package/bin/fog-vs-ticket-docs.test.js +56 -0
  9. package/bin/grill-model-docs.test.js +155 -0
  10. package/bin/grilling-holistic-review-docs.test.js +7 -3
  11. package/bin/lib/codex-projection.js +30 -14
  12. package/bin/lib/codex-projection.test.js +81 -17
  13. package/bin/lib/host-descriptor.js +15 -2
  14. package/bin/lib/host-descriptor.test.js +24 -0
  15. package/bin/lib/runtime-command-literals.test.js +184 -0
  16. package/bin/report-status-docs.test.js +11 -3
  17. package/bin/session-trust-docs.test.js +37 -0
  18. package/bin/surfaced-scope-docs.test.js +57 -0
  19. package/bin/trailhead.js +7 -5
  20. package/bin/trailhead.test.js +53 -11
  21. package/bin/unblock-docs.test.js +56 -0
  22. package/package.json +1 -1
  23. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  24. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  25. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  26. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  27. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  28. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  29. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  30. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  31. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  32. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  33. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  34. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  35. package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
  36. package/plugins/trailhead/hooks/lib/model-defaults-review.js +72 -22
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +113 -0
  38. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  39. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  40. package/plugins/trailhead/hooks/lib/report-status.test.js +124 -2
  41. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  42. package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
  43. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  44. package/plugins/trailhead/hooks/mod/README.md +24 -10
  45. package/plugins/trailhead/hooks/mod/map-pane.tsx +124 -44
  46. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  47. package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
  48. package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
  49. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  50. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
  51. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
  52. package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
  53. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  54. package/plugins/trailhead/skills/_shared/configuration-reference.md +20 -18
  55. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  56. package/plugins/trailhead/skills/_shared/session-handoff.md +2 -2
  57. package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
  58. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  59. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  60. package/plugins/trailhead/skills/_shared/teamwork.md +9 -5
  61. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  62. package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
  63. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
  64. package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
  65. package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
  66. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  67. package/plugins/trailhead/skills/_shared/techniques/verify.md +4 -2
  68. package/plugins/trailhead/skills/_shared/techniques.md +7 -5
  69. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  70. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  71. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  73. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
  74. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +5 -4
  75. package/plugins/trailhead/skills/trailhead-work/SKILL.md +13 -11
  76. package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +11 -10
  78. package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
  79. package/plugins/trailhead/templates/trailhead-capture.js +418 -0
  80. package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
  81. package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
  82. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  83. package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
  84. package/plugins/trailhead/templates/trailhead-report-status.js +123 -11
  85. package/plugins/trailhead/tests/map-pane-ui.test.ts +299 -37
  86. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  87. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  88. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -15,13 +15,15 @@ const ROOT = '/repo'
15
15
  const ROOT_OTHER = '/other'
16
16
  // A linked worktree of ROOT: the repo's root is the main tree's, the checkout's own is this.
17
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.
18
+ // The checkout handed to a verb: base64url of the root, a `.`, then base64url of the pinned repo (`host/owner/name`),
19
+ // computed here by hand so the pane's own encoder is checked.
19
20
  // The helper's decodeIn cannot be imported here (the CommonJS template does not load in this suite); the node suite
20
21
  // 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(/=+$/, '')}`
22
+ const b64url = (text: string) => {
23
+ const b64 = btoa(Array.from(new TextEncoder().encode(text), byte => String.fromCharCode(byte)).join(''))
24
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
24
25
  }
26
+ const IN = (root: string) => `--in=${b64url(root)}.${b64url('github.com/o/r')}`
25
27
 
26
28
  const MAP = {
27
29
  title: 'trailhead mod',
@@ -101,6 +103,9 @@ const ticketsCall = (label: string, after?: string) => [
101
103
  const ticketsListCall = (label: string) => ['gh', 'issue', 'list', '--label', label, '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url']
102
104
  const isTicketsRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.includes(`query=${TICKETS_QUERY}`)
103
105
  // The maps read only: the ticket reads are GraphQL too, but answered and failed on their own.
106
+ // The login and my-tickets reads (any map, the whiteboard): answered apart from the pane's own reads, recorded in `mineCalls`.
107
+ const isLoginRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.includes('query=query{viewer{login}}')
108
+ const isMineRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.some(a => a.startsWith('login='))
104
109
  const isGraphql = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && !isTicketsRead(argv) && !isUatRead(argv) && !isCountRead(argv)
105
110
  // The open-ticket total: one aliased `totalCount` per open map (`m<n>`) plus the whiteboard (`wb`), in one call.
106
111
  const countQuery = (maps: number[]) =>
@@ -137,7 +142,7 @@ const stateText = (over: Record<string, unknown> = {}): string =>
137
142
  })
138
143
 
139
144
  // The plugin's atoms, which the engine resets on /clear.
140
- const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'isDashboard', 'dashboardPane', 'pendingWork', 'pendingPhase', 'paneAutoOpened', 'ownTicket', 'repoPin']
145
+ const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'isDashboard', 'dashboardPane', 'pendingWork', 'pendingPhase', 'paneAutoOpened', 'ownTicket', 'repoPin', 'myTickets']
141
146
 
142
147
  const PANE_PROPS = {
143
148
  title: 'Map',
@@ -179,6 +184,9 @@ const rig = (on: On, engine?: any) => {
179
184
  // The UAT read's answer: one row per open UAT ticket, `{ number, body, labels }` (the scope label rides
180
185
  // in `labels`); null makes the read fail. `uatErrors` answers a reply with `errors`, `uatBroken` non-JSON.
181
186
  uat: [] as unknown[] | null,
187
+ // My open tickets on any map or the whiteboard (the `issue()` shape); null makes the read fail.
188
+ mine: [] as unknown[] | null,
189
+ mineCalls: [] as (readonly string[])[],
182
190
  uatErrors: false,
183
191
  uatBroken: false,
184
192
  dashboard: DASHBOARD as unknown[],
@@ -192,6 +200,8 @@ const rig = (on: On, engine?: any) => {
192
200
  repo: { root: '/repo', remote: 'git@github.com:o/r.git', internal: false } as { root: string; remote: string | null; internal: boolean } | null,
193
201
  // The active-map marker of the second checkout (ROOT_OTHER); null: it has none.
194
202
  otherActive: null as string | null,
203
+ // Runs once before the next read of the active-map marker, to land an event inside a check.
204
+ beforeMarkerRead: null as (() => Promise<void>) | null,
195
205
  // The worktree checkout's marker, what `git rev-parse --show-toplevel` answers there (null: it fails), and the
196
206
  // session's project root; every path read and every `git` run is recorded.
197
207
  treeActive: null as string | null,
@@ -207,6 +217,8 @@ const rig = (on: On, engine?: any) => {
207
217
  calls: [] as (readonly string[])[],
208
218
  panes: [] as { id: string; title: string; isShown: boolean; isFocused: boolean; isPlaced: boolean }[],
209
219
  isPlaced: true,
220
+ // Why `ui.open` leaves the pane unplaced (the SDK's `reason`).
221
+ reason: 'terminal too narrow',
210
222
  logs: [] as string[],
211
223
  toasts: [] as string[],
212
224
  // What a Bash call prints on stdout (the created issue's URL for a `gh issue create`).
@@ -288,13 +300,18 @@ const rig = (on: On, engine?: any) => {
288
300
  if (argv[2] === 'view') return ok(JSON.stringify(isOther ? r.otherMap : r.map))
289
301
  return ok(JSON.stringify(argv.includes('all') ? (isOther ? r.otherHistory : r.history) : isOther ? r.otherTickets : r.tickets))
290
302
  }
291
- on('fs.read', (_$, e) => {
303
+ on('fs.read', async (_$, e) => {
292
304
  // A relative path reaches the hook already resolved against the host's directory, not the repo's: put
293
305
  // it under the session's working directory, which is what the engine resolves it against. A path under
294
306
  // one of the repos is absolute (a pinned read) and stays.
295
307
  const isAbsolute = [ROOT, ROOT_OTHER, ROOT_TREE].some(root => e.path.startsWith(`${root}/`))
296
308
  const path = isAbsolute ? e.path : `${r.cwd}${e.path.slice(e.path.indexOf('/.trailhead/'))}`
297
309
  r.reads.push(path)
310
+ if (path.endsWith('/.trailhead/active-map') && r.beforeMarkerRead !== null) {
311
+ const hook = r.beforeMarkerRead
312
+ r.beforeMarkerRead = null
313
+ await hook()
314
+ }
298
315
  const root = path.startsWith(`${ROOT_TREE}/`) ? ROOT_TREE : path.startsWith(`${ROOT_OTHER}/`) ? ROOT_OTHER : path.startsWith(`${ROOT}/`) ? ROOT : null
299
316
  const marker = root === ROOT_TREE ? r.treeActive : root === ROOT_OTHER ? r.otherActive : r.active
300
317
  const file = path.endsWith('/.trailhead/active-map') ? (root === null ? null : marker) : path.endsWith('/.trailhead/work-state.json') ? (root === null ? null : r.work) : undefined
@@ -389,6 +406,14 @@ const rig = (on: On, engine?: any) => {
389
406
  const out = r.authHosts.includes(host) ? { exitCode: 0, stdout: '', stderr: '' } : { exitCode: 1, stdout: '', stderr: `You are not logged into any accounts on ${host}` }
390
407
  return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
391
408
  }
409
+ if (isLoginRead(e.argv) || isMineRead(e.argv)) {
410
+ r.mineCalls.push([...e.argv])
411
+ const body = isLoginRead(e.argv)
412
+ ? { data: { viewer: { login: 'me' } } }
413
+ : { data: { repository: { issues: { nodes: (r.mine ?? []).map(flatNode), pageInfo: { hasNextPage: false, endCursor: null } } } } }
414
+ const out = r.mine === null && isMineRead(e.argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : { exitCode: 0, stdout: JSON.stringify(body), stderr: '' }
415
+ return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
416
+ }
392
417
  r.calls.push([...e.argv])
393
418
  r.cwds.push(e.init?.cwd)
394
419
  // `gh` needs a repo around it: without its own cwd it uses the session's, wherever a `cd` left it.
@@ -406,7 +431,7 @@ const rig = (on: On, engine?: any) => {
406
431
  ...r.panes.filter(p => p.id !== e.id),
407
432
  { id: e.id, title: e.title ?? e.id, isShown: true, isFocused: false, isPlaced: r.isPlaced },
408
433
  ]
409
- return { value: r.isPlaced ? { isPlaced: true as const } : { isPlaced: false as const, reason: 'terminal too narrow' } }
434
+ return { value: r.isPlaced ? { isPlaced: true as const } : { isPlaced: false as const, reason: r.reason } }
410
435
  })
411
436
  on('ui.close', (_$, e) => {
412
437
  r.panes = r.panes.filter(p => p.id !== e.id)
@@ -489,7 +514,7 @@ for (const surface of SURFACES) {
489
514
  expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
490
515
  expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
491
516
  expect((await ui.find({ key: 'head-frontier' }))?.children[0]).toMatchObject({ props: { bold: true } })
492
- expect(await keysOf(ui, 'row-')).toEqual(['row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-211', 'row-213'])
517
+ expect(await keysOf(ui, 'row-')).toEqual(['row-208', 'row-212', 'row-209', 'row-210', 'row-214', 'row-211', 'row-213'])
493
518
 
494
519
  expect(await textOf(ui, 'ticket-209')).toBe('#209 Burn-down view of the map')
495
520
  expect(await textOf(ui, 'type-209')).toBe(' build')
@@ -543,10 +568,10 @@ for (const surface of SURFACES) {
543
568
 
544
569
  expect(await textOf(ui, 'head-uat')).toBe('UAT · 3')
545
570
  expect((await ui.find({ key: 'head-uat' }))?.children[0]).toMatchObject({ props: { bold: true } })
546
- expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-uat', 'group-paused', 'group-blocked'])
571
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-in-progress', 'group-frontier', 'group-uat', 'group-paused', 'group-blocked'])
547
572
  // each ticket once: a UAT row is in no other group, a paused UAT stays under Paused
548
573
  expect(await keysOf(ui, 'row-')).toEqual([
549
- 'row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-240', 'row-241', 'row-242', 'row-243', 'row-211', 'row-213',
574
+ 'row-208', 'row-212', 'row-209', 'row-210', 'row-214', 'row-240', 'row-241', 'row-242', 'row-243', 'row-211', 'row-213',
550
575
  ])
551
576
  expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
552
577
  expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
@@ -647,7 +672,7 @@ for (const surface of SURFACES) {
647
672
  expect(r.calls.filter(isUatRead)).toContainEqual(uatCall())
648
673
  expect(r.calls.filter(isUatSearch)).toEqual([])
649
674
  expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
650
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
675
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-306', 'row-303'])
651
676
  expect(await textOf(ui, 'uat-of-306')).toBe('tests Loose parent #290')
652
677
  })
653
678
 
@@ -668,8 +693,8 @@ for (const surface of SURFACES) {
668
693
  expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
669
694
  expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
670
695
  // a paused ticket appears in one group only: Frontier, In progress, Paused, Blocked in order
671
- 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'])
672
- expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-paused', 'group-blocked'])
696
+ expect(await keysOf(ui, 'row-')).toEqual(['row-208', 'row-212', 'row-209', 'row-210', 'row-214', 'row-230', 'row-231', 'row-232', 'row-211', 'row-213'])
697
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-in-progress', 'group-frontier', 'group-paused', 'group-blocked'])
673
698
  for (const n of [230, 231, 232]) {
674
699
  expect(await hasKey(ui, `resume-${n}`)).toBe(true)
675
700
  expect(await hasKey(ui, `work-${n}`)).toBe(false)
@@ -685,12 +710,12 @@ for (const surface of SURFACES) {
685
710
  const ui = await openAndMount($, r)
686
711
  await pressDashboard(ui, r)
687
712
  expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
688
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-305', 'row-303'])
713
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-305', 'row-303'])
689
714
  expect(await hasKey(ui, 'work-305')).toBe(false)
690
715
  expect(await hasKey(ui, 'resume-305')).toBe(true)
691
716
  })
692
717
 
693
- test(`needs-you tickets sit in their own group first, once each, with a work button unless held [${surface}]`, async ($, on) => {
718
+ test(`needs-you tickets sit in their own group right after In progress, once each, with a work button unless held [${surface}]`, async ($, on) => {
694
719
  const r = rig(on, $)
695
720
  r.tickets = [
696
721
  ...TICKETS,
@@ -706,9 +731,9 @@ for (const surface of SURFACES) {
706
731
 
707
732
  expect(await textOf(ui, 'head-needs-you')).toBe('Needs you · 6')
708
733
  expect((await ui.find({ key: 'head-needs-you' }))?.children[0]).toMatchObject({ props: { bold: true } })
709
- expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-needs-you', 'group-frontier', 'group-in-progress', 'group-blocked'])
710
- // each ticket once, ahead of the frontier rows; the other counts leave them out
711
- 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'])
734
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-in-progress', 'group-needs-you', 'group-frontier', 'group-blocked'])
735
+ // each ticket once, after my in-progress rows and ahead of the frontier rows; the other counts leave them out
736
+ expect(await keysOf(ui, 'row-')).toEqual(['row-208', 'row-212', 'row-250', 'row-251', 'row-252', 'row-253', 'row-254', 'row-255', 'row-209', 'row-210', 'row-214', 'row-211', 'row-213'])
712
737
  expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
713
738
  expect(await hasKey(ui, 'head-paused')).toBe(false)
714
739
  expect(await hasKey(ui, 'head-uat')).toBe(false)
@@ -731,16 +756,16 @@ for (const surface of SURFACES) {
731
756
  expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '251' }])
732
757
  })
733
758
 
734
- test(`the dashboard view puts a needs-you whiteboard ticket first in its own group [${surface}]`, async ($, on) => {
759
+ test(`the dashboard view puts a needs-you whiteboard ticket in its own group right after In progress [${surface}]`, async ($, on) => {
735
760
  const r = rig(on, $)
736
761
  r.whiteboard = [...WHITEBOARD, issue(307, 'Loose needs you', [...WB, 'trailhead:task', 'trailhead:needs-you', 'trailhead:paused'])]
737
762
  await $.session.start(START)
738
763
  const ui = await openAndMount($, r)
739
764
  await pressDashboard(ui, r)
740
765
  expect(await textOf(ui, 'head-needs-you')).toBe('Needs you · 1')
741
- // first ticket group, right after the maps group
742
- expect((await keysOf(ui, 'group-')).slice(0, 2)).toEqual(['group-maps', 'group-needs-you'])
743
- expect((await keysOf(ui, 'row-'))[0]).toBe('row-307')
766
+ // right after the maps group and In progress
767
+ expect((await keysOf(ui, 'group-')).slice(0, 3)).toEqual(['group-maps', 'group-in-progress', 'group-needs-you'])
768
+ expect((await keysOf(ui, 'row-')).slice(0, 2)).toEqual(['row-302', 'row-307'])
744
769
  expect(await hasKey(ui, 'head-paused')).toBe(false)
745
770
  expect(await hasKey(ui, 'work-307')).toBe(true)
746
771
  expect(await hasKey(ui, 'resume-307')).toBe(false)
@@ -1119,9 +1144,9 @@ for (const surface of SURFACES) {
1119
1144
  await openAndMount($, r)
1120
1145
  expect(fetches(r)).toBe(1)
1121
1146
 
1147
+ // two turn ends with no write between: no flush, the trailing refresh is the only extra read
1122
1148
  await r.clock.advance(2_000)
1123
1149
  await completeTurn($)
1124
- await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
1125
1150
  await completeTurn($)
1126
1151
  await settle(r)
1127
1152
  expect(fetches(r)).toBe(1)
@@ -1138,6 +1163,37 @@ for (const surface of SURFACES) {
1138
1163
  expect(fetches(r)).toBe(2)
1139
1164
  })
1140
1165
 
1166
+ // A write followed by a turn end flushes: the turn end reads at once, inside the floor. The armed trailing
1167
+ // refresh is kept, so the read after the search index caught up still follows, one floor after the flush.
1168
+ test(`a write then a turn end inside the floor reads at once and keeps the trailing [${surface}]`, async ($, on) => {
1169
+ const r = rig(on, $)
1170
+ await $.session.start(START)
1171
+ await openAndMount($, r)
1172
+ expect(fetches(r)).toBe(1)
1173
+
1174
+ await r.clock.advance(2_000)
1175
+ await completeTurn($)
1176
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
1177
+ await completeTurn($)
1178
+ await settle(r)
1179
+ expect(fetches(r)).toBe(2)
1180
+ // a second turn end with no write since the flush adds nothing
1181
+ await completeTurn($)
1182
+ await settle(r)
1183
+ expect(fetches(r)).toBe(2)
1184
+
1185
+ // the trailing one fires at the first floor's end, lands inside the flush's floor and waits for its end
1186
+ await r.clock.advance(FLOOR - 2_000)
1187
+ await settle(r)
1188
+ expect(fetches(r)).toBe(2)
1189
+ await r.clock.advance(2_000)
1190
+ await settle(r)
1191
+ expect(fetches(r)).toBe(3)
1192
+ await r.clock.advance(FLOOR * 2)
1193
+ await settle(r)
1194
+ expect(fetches(r)).toBe(3)
1195
+ })
1196
+
1141
1197
  // The host drops a refused timer silently (a closed ticket stayed on the pane for good): the pane must not
1142
1198
  // wait on a dead handle.
1143
1199
  test(`a slow timer the host dropped is started again by the next trigger [${surface}]`, async ($, on) => {
@@ -1166,6 +1222,51 @@ for (const surface of SURFACES) {
1166
1222
  expect(fetches(r)).toBe(3)
1167
1223
  })
1168
1224
 
1225
+ // The trailing refresh is dropped like the slow timer (a closed ticket stayed on the pane as held): the turn
1226
+ // end after a write reads at once, floor or not, so the final tracker state shows with no timer involved.
1227
+ test(`a turn end after a write shows the close although the trailing refresh was dropped [${surface}]`, async ($, on) => {
1228
+ on('clock.after', { ms: FLOOR }, async () => ({ deny: 'dropped' }))
1229
+ const r = rig(on, $)
1230
+ await $.session.start(START)
1231
+ const ui = await openAndMount($, r)
1232
+ await r.clock.advance(FLOOR)
1233
+ // the resolution comment reads at once, before the close
1234
+ await $.tool.call({ tool: 'Bash', command: 'gh issue comment 209 -b done' } as never)
1235
+ await settle(r)
1236
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1237
+ await r.clock.advance(2_000)
1238
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1239
+ await completeTurn($)
1240
+ await settle(r)
1241
+ expect(await hasKey(ui, 'row-209')).toBe(false)
1242
+ })
1243
+
1244
+ // Without a turn end, a pane drawn again past the dead trailing's due time and grace reads at once.
1245
+ test(`a pane drawn again after a dropped trailing refresh reads at once [${surface}]`, async ($, on) => {
1246
+ let isDropping = false
1247
+ on('clock.after', { ms: FLOOR }, async ($$, e, next) => (isDropping ? { deny: 'dropped' } : next(e)))
1248
+ const r = rig(on, $)
1249
+ await $.session.start(START)
1250
+ const ui = await openAndMount($, r)
1251
+ await r.clock.advance(FLOOR)
1252
+ isDropping = true
1253
+ await $.tool.call({ tool: 'Bash', command: 'gh issue comment 209 -b done' } as never)
1254
+ await settle(r)
1255
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1256
+ await r.clock.advance(2_000)
1257
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1258
+ await settle(r)
1259
+ isDropping = false
1260
+ // nothing fired for the close: the pane still shows the closed ticket
1261
+ await r.clock.advance(FLOOR * 4)
1262
+ await settle(r)
1263
+ expect(await hasKey(ui, 'row-209')).toBe(true)
1264
+ await ui.unmount()
1265
+ const again = await mountPane($)
1266
+ await settle(r)
1267
+ expect(await hasKey(again, 'row-209')).toBe(false)
1268
+ })
1269
+
1169
1270
  // The open list is a GraphQL read, never the search-backed `gh issue list --label`, which lags a write.
1170
1271
  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'))
1171
1272
 
@@ -1287,7 +1388,7 @@ for (const surface of SURFACES) {
1287
1388
  await $.session.start(START)
1288
1389
  const ui = await openAndMount($, r)
1289
1390
  await pressDashboard(ui, r)
1290
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
1391
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-303'])
1291
1392
  })
1292
1393
 
1293
1394
  test(`a write that reads at once is followed by one more read at the floor's end [${surface}]`, async ($, on) => {
@@ -1540,9 +1641,11 @@ for (const surface of SURFACES) {
1540
1641
  // The token the verb receives decodes (plain base64url, the helper's alphabet) to the pinned root; the suite has no Buffer.
1541
1642
  const [number, word] = r.runs[1].args.split(' ')
1542
1643
  expect(number).toBe('209')
1543
- expect(word).toMatch(/^--in=[A-Za-z0-9_-]+$/)
1644
+ expect(word).toMatch(/^--in=[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/)
1544
1645
  expect(word).toBe(IN(ROOT))
1545
- expect(atob(word.slice('--in='.length).replace(/-/g, '+').replace(/_/g, '/'))).toBe(ROOT)
1646
+ const [rootPart, repoPart] = word.slice('--in='.length).split('.')
1647
+ expect(atob(rootPart.replace(/-/g, '+').replace(/_/g, '/'))).toBe(ROOT)
1648
+ expect(atob(repoPart.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(repoPart.length / 4) * 4, '='))).toBe('github.com/o/r')
1546
1649
  expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1547
1650
  expect(await ui.find({ key: 'work-209' })).toBeDefined()
1548
1651
  })
@@ -2197,7 +2300,7 @@ for (const surface of SURFACES) {
2197
2300
  await settle(r)
2198
2301
  expect(await textOf(ui, 'map')).toBe('#14 older map')
2199
2302
  expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
2200
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
2303
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
2201
2304
  })
2202
2305
 
2203
2306
  test(`the refresh button obeys the same floor [${surface}]`, async ($, on) => {
@@ -2295,6 +2398,20 @@ for (const surface of SURFACES) {
2295
2398
  expect((await panel($)).text).toBe('Map panel closed.')
2296
2399
  })
2297
2400
 
2401
+ // #321: nessuna superficie collegata (inizio sessione) non è un errore: il pannello resta aperto in attesa.
2402
+ test(`/trailhead:panel with no surface attached keeps the pane pending and says so plainly [${surface}]`, async ($, on) => {
2403
+ const r = rig(on, $)
2404
+ r.isPlaced = false
2405
+ r.reason = 'no-surface: no surface is attached ($.session.surfaces() is empty)'
2406
+ await $.session.start(START)
2407
+ const pending = 'Map panel will open as soon as the screen is ready.'
2408
+ expect((await panel($)).text).toBe(pending)
2409
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
2410
+ // a second command while still pending is idempotent: it never closes the pane
2411
+ expect((await panel($)).text).toBe(pending)
2412
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
2413
+ })
2414
+
2298
2415
  test(`/trailhead:panel shows a pane the auto-open left undrawn instead of closing it [${surface}]`, async ($, on) => {
2299
2416
  const r = rig(on, $)
2300
2417
  // sessione vuota, terminale sotto la soglia dei pannelli non richiesti: l'auto-open resta non disegnato
@@ -2469,7 +2586,7 @@ for (const surface of SURFACES) {
2469
2586
 
2470
2587
  expect(await textOf(ui, 'map')).toBe('#14 older map')
2471
2588
  expect(await textOf(ui, 'destination')).toBe('An older destination.')
2472
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
2589
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
2473
2590
  expect(await textOf(ui, 'holder-141')).toBe('@carol')
2474
2591
  expect(await textOf(ui, 'burndown-counts')).toBe('open 2 · done 0 · removed 0 · peak 2')
2475
2592
  // the ticket in play belongs to the other map: nothing here is marked
@@ -2644,7 +2761,7 @@ for (const surface of SURFACES) {
2644
2761
  await settle(r)
2645
2762
  expect(await hasKey(ui, 'ticket-300')).toBe(false)
2646
2763
  expect(await textOf(ui, 'map')).toBe('#14 older map')
2647
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
2764
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
2648
2765
  })
2649
2766
 
2650
2767
  test(`with no active map the open maps are listed and one can be viewed [${surface}]`, async ($, on) => {
@@ -2674,6 +2791,57 @@ for (const surface of SURFACES) {
2674
2791
  await settle(r)
2675
2792
  }
2676
2793
 
2794
+ // My tickets in play on other maps and the whiteboard, under every view's In progress.
2795
+ const MINE = [
2796
+ issue(150, 'My whiteboard fix', [...WB, 'trailhead:build'], ['me']),
2797
+ issue(160, 'My other map ticket', ['trailhead:ticket', 'trailhead:build', 'trailhead:map-14'], ['me']),
2798
+ issue(161, 'My paused one', ['trailhead:ticket', 'trailhead:build', 'trailhead:map-14', 'trailhead:paused'], ['me']),
2799
+ issue(212, 'Native gh-backed tools', ['trailhead:ticket', 'trailhead:build', 'trailhead:map-198'], ['me']),
2800
+ ]
2801
+
2802
+ test(`the map's In progress adds my tickets of the other maps and the whiteboard, each saying where [${surface}]`, async ($, on) => {
2803
+ const r = rig(on, $)
2804
+ r.mine = MINE
2805
+ await $.session.start(START)
2806
+ const ui = await openAndMount($, r)
2807
+ expect(r.mineCalls[0]).toEqual(['gh', 'api', 'graphql', '-f', 'query=query{viewer{login}}'])
2808
+ expect(r.mineCalls.some(argv => argv.includes('login=me'))).toBe(true)
2809
+ expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 4')
2810
+ // the map's own first, then mine elsewhere; a paused one and one the map already lists are not added
2811
+ expect(await keysOf(ui, 'row-')).toEqual(['row-208', 'row-212', 'row-150', 'row-160', 'row-209', 'row-210', 'row-214', 'row-211', 'row-213'])
2812
+ expect(await textOf(ui, 'scope-of-150')).toBe('on whiteboard')
2813
+ expect(await textOf(ui, 'scope-of-160')).toBe('on map #14 older map')
2814
+ expect((await ui.find({ key: 'scope-of-160' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
2815
+ expect(await textOf(ui, 'holder-160')).toBe('@me')
2816
+ expect(await hasKey(ui, 'scope-of-212')).toBe(false)
2817
+ expect(await hasKey(ui, 'row-161')).toBe(false)
2818
+ })
2819
+
2820
+ test(`the dashboard's In progress adds my tickets of every map [${surface}]`, async ($, on) => {
2821
+ const r = rig(on, $)
2822
+ r.mine = MINE
2823
+ await $.session.start(START)
2824
+ const ui = await openAndMount($, r)
2825
+ await pressDashboard(ui, r)
2826
+ expect(await textOf(ui, 'scope-of-160')).toBe('on map #14 older map')
2827
+ expect(await textOf(ui, 'scope-of-212')).toBe('on map #198 trailhead mod')
2828
+ // a whiteboard ticket of mine belongs to this view's own list, never a second time with a scope line
2829
+ expect(await hasKey(ui, 'scope-of-150')).toBe(false)
2830
+ })
2831
+
2832
+ test(`a failed read of my tickets keeps the last ones and sets no error [${surface}]`, async ($, on) => {
2833
+ const r = rig(on, $)
2834
+ r.mine = MINE
2835
+ await $.session.start(START)
2836
+ const ui = await openAndMount($, r)
2837
+ expect(await hasKey(ui, 'row-160')).toBe(true)
2838
+ r.mine = null
2839
+ await r.clock.advance(TIMER)
2840
+ await settle(r)
2841
+ expect(await hasKey(ui, 'row-160')).toBe(true)
2842
+ expect(await textOf(ui, 'age')).toBe('updated just now')
2843
+ })
2844
+
2677
2845
  test(`the Whiteboard row shows the maps index and the whiteboard tickets [${surface}]`, async ($, on) => {
2678
2846
  const r = rig(on, $)
2679
2847
  await $.session.start(START)
@@ -2709,7 +2877,7 @@ for (const surface of SURFACES) {
2709
2877
  expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
2710
2878
  expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 1')
2711
2879
  expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 1')
2712
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
2880
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-303'])
2713
2881
  expect(await textOf(ui, 'holder-302')).toBe('@alice')
2714
2882
  expect(await hasKey(ui, 'work-301')).toBe(true)
2715
2883
  expect(await textOf(ui, 'unverified')).toBe('1 unverified ticket hidden (quarantined)')
@@ -3064,7 +3232,7 @@ for (const surface of SURFACES) {
3064
3232
  const ui = await openAndMount($, r)
3065
3233
  await pressDashboard(ui, r)
3066
3234
  expect(await textOf(ui, 'head-whiteboard')).toBe('Whiteboard · 4 open · 2 assigned')
3067
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
3235
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-306', 'row-303'])
3068
3236
  expect(await textOf(ui, 'holder-302')).toBe('@alice')
3069
3237
  expect(await hasKey(ui, 'resume-306')).toBe(true)
3070
3238
  expect(await hasKey(ui, 'row-305')).toBe(false)
@@ -3093,7 +3261,7 @@ for (const surface of SURFACES) {
3093
3261
  await pressDashboard(ui, r)
3094
3262
  await pressMap(ui, r, 14)
3095
3263
  expect(await textOf(ui, 'map')).toBe('#14 older map')
3096
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3264
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3097
3265
 
3098
3266
  await pressDashboard(ui, r)
3099
3267
  await pressMap(ui, r, 198)
@@ -3145,7 +3313,7 @@ for (const surface of SURFACES) {
3145
3313
  const ui = await openAndMount($, r)
3146
3314
  expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
3147
3315
  await pressDashboard(ui, r)
3148
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
3316
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-303'])
3149
3317
  await ui.press({ key: 'to-map' })
3150
3318
  await settle(r)
3151
3319
  expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
@@ -3159,7 +3327,7 @@ for (const surface of SURFACES) {
3159
3327
  await $.command.run({ command: 'clear', args: '' } as never)
3160
3328
  await settle(r)
3161
3329
  expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
3162
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
3330
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-303'])
3163
3331
  })
3164
3332
  test(`the pane keeps its repo when the session cwd moves out of it [${surface}]`, async ($, on) => {
3165
3333
  const r = rig(on, $)
@@ -3198,7 +3366,7 @@ for (const surface of SURFACES) {
3198
3366
  await completeTurn($)
3199
3367
  await settle(r)
3200
3368
  expect(await textOf(ui, 'map')).toBe('#14 older map')
3201
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3369
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3202
3370
  expect(r.calls).toContainEqual(MAPS_CALL)
3203
3371
  expect(r.cwds.every(cwd => cwd === ROOT_OTHER)).toBe(true)
3204
3372
  // The toplevel is read where the session now is, not at its project root, which a shell `cd` leaves behind.
@@ -3286,7 +3454,101 @@ for (const surface of SURFACES) {
3286
3454
  await $.turn.start({ text: 'go', turnId: 'race-1' })
3287
3455
  release()
3288
3456
  await settle(r)
3289
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3457
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3458
+ })
3459
+
3460
+ test(`a refresh in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
3461
+ const r = rig(on, $)
3462
+ await $.session.start(START)
3463
+ let release: () => void = () => {}
3464
+ const gate = new Promise<void>(resolve => {
3465
+ release = resolve
3466
+ })
3467
+ // The answer is fixed when the call starts, so the reads begun in /repo keep its tracker state.
3468
+ r.gh = async argv => {
3469
+ const answer = r.defaultGh(argv)
3470
+ await gate
3471
+ return answer
3472
+ }
3473
+ r.open()
3474
+ const ui = await mountPane($)
3475
+ await settle(r)
3476
+ // Another checkout of the SAME repo (same remote), with map 198 active there; the tracker changed after the
3477
+ // read started. A turn start refreshes the band, which moves the pin while the read is still in flight.
3478
+ r.cwd = ROOT_OTHER
3479
+ r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
3480
+ r.otherActive = '198\n'
3481
+ r.tickets = OTHER_TICKETS
3482
+ r.history = OTHER_HISTORY
3483
+ await $.turn.start({ text: 'go', turnId: 'race-same-repo' })
3484
+ release()
3485
+ await settle(r)
3486
+ await r.clock.advance(FLOOR)
3487
+ await settle(r)
3488
+ // The dropped read is repeated: the pane shows the new tracker state, not the old list.
3489
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3490
+ })
3491
+
3492
+ test(`a same-repo pin move landing inside the stale check is read again [${surface}]`, async ($, on) => {
3493
+ const r = rig(on, $)
3494
+ await $.session.start(START)
3495
+ let release: () => void = () => {}
3496
+ const gate = new Promise<void>(resolve => {
3497
+ release = resolve
3498
+ })
3499
+ r.gh = async argv => {
3500
+ const answer = r.defaultGh(argv)
3501
+ await gate
3502
+ return answer
3503
+ }
3504
+ r.open()
3505
+ const ui = await mountPane($)
3506
+ await settle(r)
3507
+ // The first marker read once the gate opens is the stale check's last await: the move lands there,
3508
+ // after the check read the old pin and before it decides.
3509
+ r.beforeMarkerRead = async () => {
3510
+ r.cwd = ROOT_OTHER
3511
+ r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
3512
+ r.otherActive = '198\n'
3513
+ r.tickets = OTHER_TICKETS
3514
+ r.history = OTHER_HISTORY
3515
+ await $.turn.start({ text: 'go', turnId: 'race-in-check' })
3516
+ }
3517
+ release()
3518
+ await settle(r)
3519
+ await r.clock.advance(FLOOR)
3520
+ await settle(r)
3521
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3522
+ })
3523
+
3524
+ test(`a dashboard read in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
3525
+ const r = rig(on, $)
3526
+ r.maps = [MAPS[0]]
3527
+ await $.session.start(START)
3528
+ const ui = await openAndMount($, r)
3529
+ let release: () => void = () => {}
3530
+ const gate = new Promise<void>(resolve => {
3531
+ release = resolve
3532
+ })
3533
+ r.gh = async argv => {
3534
+ const answer = r.defaultGh(argv)
3535
+ await gate
3536
+ return answer
3537
+ }
3538
+ // The dashboard read starts under /repo and waits on the gate.
3539
+ await ui.press({ key: 'open-whiteboard' })
3540
+ await settle(r)
3541
+ r.cwd = ROOT_OTHER
3542
+ r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
3543
+ r.otherActive = '198\n'
3544
+ r.whiteboard = [issue(320, 'New loose one', [...WB, 'trailhead:bug'])]
3545
+ await $.turn.start({ text: 'go', turnId: 'race-same-repo-dash' })
3546
+ release()
3547
+ await settle(r)
3548
+ await r.clock.advance(FLOOR)
3549
+ await settle(r)
3550
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
3551
+ expect(await keysOf(ui, 'row-')).toEqual(['row-320'])
3290
3552
  })
3291
3553
 
3292
3554
  test(`the pin survives /clear after the session cwd moved out of the repo [${surface}]`, async ($, on) => {
@@ -3385,7 +3647,7 @@ for (const surface of SURFACES) {
3385
3647
  expect(await keysOf(ui, 'row-')).not.toContain('row-209')
3386
3648
  await settle(r)
3387
3649
  expect(await textOf(ui, 'map')).toBe('#14 older map')
3388
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3650
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3389
3651
  })
3390
3652
 
3391
3653
  // The panel follows the whiteboard ticket this session takes (#306).