@marcomigozzi/trailhead 0.16.1 → 0.18.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 (154) hide show
  1. package/README.it.md +82 -25
  2. package/README.md +82 -25
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/capture-action.test.js +167 -0
  6. package/bin/capture-docs.test.js +32 -0
  7. package/bin/engine-route-docs.test.js +155 -0
  8. package/bin/facet-stamp-docs.test.js +79 -0
  9. package/bin/forced-mutation-docs.test.js +38 -0
  10. package/bin/grill-model-docs.test.js +155 -0
  11. package/bin/grilling-holistic-review-docs.test.js +7 -3
  12. package/bin/guard-axis-docs.test.js +69 -0
  13. package/bin/inbox-needs-info-docs.test.js +68 -0
  14. package/bin/lib/codex-projection.js +28 -19
  15. package/bin/lib/codex-projection.test.js +77 -20
  16. package/bin/lib/host-descriptor.js +1 -1
  17. package/bin/lib/host-descriptor.test.js +6 -0
  18. package/bin/needs-you-docs.test.js +9 -2
  19. package/bin/options-in-chat-docs.test.js +65 -0
  20. package/bin/pick-stress-test-docs.test.js +1 -1
  21. package/bin/pipeline-review-docs.test.js +118 -0
  22. package/bin/plan-review-budget-docs.test.js +63 -0
  23. package/bin/repo-target-docs.test.js +41 -0
  24. package/bin/report-status-docs.test.js +10 -2
  25. package/bin/seam-coverage-docs.test.js +46 -0
  26. package/bin/session-trust-docs.test.js +37 -0
  27. package/bin/standards-brief-docs.test.js +112 -0
  28. package/bin/surfaced-scope-docs.test.js +57 -0
  29. package/bin/test-once-docs.test.js +101 -0
  30. package/bin/test-run-docs.test.js +47 -0
  31. package/bin/trailhead.js +15 -8
  32. package/bin/trailhead.test.js +71 -17
  33. package/bin/unblock-docs.test.js +56 -0
  34. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  35. package/package.json +1 -1
  36. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  37. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  38. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  39. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  40. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  41. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  42. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  43. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  44. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  45. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  46. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  47. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  48. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  49. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  50. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  51. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  52. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  53. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  54. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  55. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  56. package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
  57. package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
  58. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
  59. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  60. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  61. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  62. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  63. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  64. package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
  65. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  66. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  67. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  68. package/plugins/trailhead/hooks/mod/README.md +42 -17
  69. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  70. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  71. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  72. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  73. package/plugins/trailhead/hooks/mod/map-pane.tsx +204 -49
  74. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  75. package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
  76. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  77. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  79. package/plugins/trailhead/hooks/trailhead-check-update.js +20 -2
  80. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  81. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  83. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  84. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  85. package/plugins/trailhead/skills/_shared/capture-core.md +123 -0
  86. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  87. package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
  88. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  89. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  90. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  91. package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
  92. package/plugins/trailhead/skills/_shared/substrate.md +4 -3
  93. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  94. package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
  95. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  96. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  103. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  104. package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
  105. package/plugins/trailhead/skills/_shared/techniques.md +8 -6
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +11 -6
  111. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
  112. package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
  113. package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
  114. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  115. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
  116. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  117. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  118. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  119. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  120. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  121. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  122. package/plugins/trailhead/templates/trailhead-capture.js +419 -0
  123. package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
  125. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  126. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  127. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  128. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +161 -12
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  135. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  136. package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
  137. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  138. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  139. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  140. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  141. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  142. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  143. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  144. package/plugins/trailhead/tests/map-pane-ui.test.ts +386 -50
  145. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  146. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  147. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  148. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  149. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  150. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  151. package/plugins/trailhead/tests/unscrub.ts +8 -0
  152. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  153. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
  154. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -1,5 +1,6 @@
1
1
  import type { On } from 'claude-code'
2
2
  import { expect, mock, test } from 'claude-code/testing'
3
+ import { unscrub } from './unscrub'
3
4
 
4
5
  const SURFACES = ['terminal', 'desktop'] as const
5
6
  const SESSION = 'sess-A'
@@ -15,13 +16,15 @@ const ROOT = '/repo'
15
16
  const ROOT_OTHER = '/other'
16
17
  // A linked worktree of ROOT: the repo's root is the main tree's, the checkout's own is this.
17
18
  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 checkout handed to a verb: base64url of the root, a `.`, then base64url of the pinned repo (`host/owner/name`),
20
+ // computed here by hand so the pane's own encoder is checked.
19
21
  // The helper's decodeIn cannot be imported here (the CommonJS template does not load in this suite); the node suite
20
22
  // 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(/=+$/, '')}`
23
+ const b64url = (text: string) => {
24
+ const b64 = btoa(Array.from(new TextEncoder().encode(text), byte => String.fromCharCode(byte)).join(''))
25
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
24
26
  }
27
+ const IN = (root: string) => `--in=${b64url(root)}.${b64url('github.com/o/r')}`
25
28
 
26
29
  const MAP = {
27
30
  title: 'trailhead mod',
@@ -101,6 +104,9 @@ const ticketsCall = (label: string, after?: string) => [
101
104
  const ticketsListCall = (label: string) => ['gh', 'issue', 'list', '--label', label, '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url']
102
105
  const isTicketsRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.includes(`query=${TICKETS_QUERY}`)
103
106
  // The maps read only: the ticket reads are GraphQL too, but answered and failed on their own.
107
+ // The login and my-tickets reads (any map, the whiteboard): answered apart from the pane's own reads, recorded in `mineCalls`.
108
+ const isLoginRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.includes('query=query{viewer{login}}')
109
+ const isMineRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.some(a => a.startsWith('login='))
104
110
  const isGraphql = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && !isTicketsRead(argv) && !isUatRead(argv) && !isCountRead(argv)
105
111
  // The open-ticket total: one aliased `totalCount` per open map (`m<n>`) plus the whiteboard (`wb`), in one call.
106
112
  const countQuery = (maps: number[]) =>
@@ -137,7 +143,7 @@ const stateText = (over: Record<string, unknown> = {}): string =>
137
143
  })
138
144
 
139
145
  // 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']
146
+ const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'isDashboard', 'dashboardPane', 'pendingWork', 'pendingPhase', 'paneAutoOpened', 'ownTicket', 'repoPin', 'myTickets']
141
147
 
142
148
  const PANE_PROPS = {
143
149
  title: 'Map',
@@ -179,6 +185,9 @@ const rig = (on: On, engine?: any) => {
179
185
  // The UAT read's answer: one row per open UAT ticket, `{ number, body, labels }` (the scope label rides
180
186
  // in `labels`); null makes the read fail. `uatErrors` answers a reply with `errors`, `uatBroken` non-JSON.
181
187
  uat: [] as unknown[] | null,
188
+ // My open tickets on any map or the whiteboard (the `issue()` shape); null makes the read fail.
189
+ mine: [] as unknown[] | null,
190
+ mineCalls: [] as (readonly string[])[],
182
191
  uatErrors: false,
183
192
  uatBroken: false,
184
193
  dashboard: DASHBOARD as unknown[],
@@ -209,6 +218,8 @@ const rig = (on: On, engine?: any) => {
209
218
  calls: [] as (readonly string[])[],
210
219
  panes: [] as { id: string; title: string; isShown: boolean; isFocused: boolean; isPlaced: boolean }[],
211
220
  isPlaced: true,
221
+ // Why `ui.open` leaves the pane unplaced (the SDK's `reason`).
222
+ reason: 'terminal too narrow',
212
223
  logs: [] as string[],
213
224
  toasts: [] as string[],
214
225
  // What a Bash call prints on stdout (the created issue's URL for a `gh issue create`).
@@ -373,15 +384,18 @@ const rig = (on: On, engine?: any) => {
373
384
  return { value: undefined }
374
385
  })
375
386
  on('session.repo', () => (r.repoFails ? { deny: 'repo read failed' } : { value: r.repo }))
387
+ // A checkout in `gone` no longer exists on disk; nothing else is listed beside it.
388
+ on('fs.exists', (_$, e) => ({ value: !r.gone.includes(e.path) }))
389
+ on('fs.list', () => ({ value: [] }))
376
390
  on('session.root', () => ({ value: r.sessionRoot }))
377
391
  on('session.cwd', () => ({ value: r.cwd }))
378
392
  on('process.run', async (_$, e) => {
379
393
  // 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 })
394
+ if (unscrub(e.argv)[0] === 'git') {
395
+ r.gitCalls.push({ argv: [...unscrub(e.argv)], cwd: e.init?.cwd })
382
396
  // Answered per directory, like git: the worktree gives `top`, the other checkouts their own root.
383
397
  const where = e.init?.cwd
384
- if (e.argv[1] === 'remote') {
398
+ if (unscrub(e.argv)[1] === 'remote') {
385
399
  const remote = r.gitRemote === null ? { exitCode: 2, stdout: '', stderr: '' } : { exitCode: 0, stdout: `${r.gitRemote}\n`, stderr: '' }
386
400
  return { value: { ...remote, isStdoutTruncated: false, isStderrTruncated: false } }
387
401
  }
@@ -390,21 +404,31 @@ const rig = (on: On, engine?: any) => {
390
404
  return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
391
405
  }
392
406
  // 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] ?? ''
407
+ if (unscrub(e.argv)[1] === 'auth') {
408
+ const host = unscrub(e.argv)[4] ?? ''
395
409
  r.authCalls.push(host)
396
410
  const out = r.authHosts.includes(host) ? { exitCode: 0, stdout: '', stderr: '' } : { exitCode: 1, stdout: '', stderr: `You are not logged into any accounts on ${host}` }
397
411
  return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
398
412
  }
399
- r.calls.push([...e.argv])
413
+ if (isLoginRead(unscrub(e.argv)) || isMineRead(unscrub(e.argv))) {
414
+ r.mineCalls.push([...unscrub(e.argv)])
415
+ const body = isLoginRead(unscrub(e.argv))
416
+ ? { data: { viewer: { login: 'me' } } }
417
+ : { data: { repository: { issues: { nodes: (r.mine ?? []).map(flatNode), pageInfo: { hasNextPage: false, endCursor: null } } } } }
418
+ const out = r.mine === null && isMineRead(unscrub(e.argv)) ? { exitCode: 1, stdout: '', stderr: 'boom' } : { exitCode: 0, stdout: JSON.stringify(body), stderr: '' }
419
+ return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
420
+ }
421
+ r.calls.push([...unscrub(e.argv)])
400
422
  r.cwds.push(e.init?.cwd)
401
423
  // `gh` needs a repo around it: without its own cwd it uses the session's, wherever a `cd` left it.
402
424
  const where = e.init?.cwd ?? r.cwd
425
+ // A spawn in a deleted directory fails the way a missing binary does.
426
+ if (r.gone.includes(where)) return { deny: 'spawn gh ENOENT' }
403
427
  if (where !== ROOT && where !== ROOT_OTHER && where !== ROOT_TREE) {
404
428
  return { value: { exitCode: 1, stdout: '', stderr: 'failed to run git: fatal: not a git repository', isStdoutTruncated: false, isStderrTruncated: false } }
405
429
  }
406
430
  if (r.deny !== null) return { deny: r.deny }
407
- const out = await (r.gh ?? defaultGh)(e.argv)
431
+ const out = await (r.gh ?? defaultGh)(unscrub(e.argv))
408
432
  return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
409
433
  })
410
434
  on('ui.open', (_$, e) => {
@@ -413,7 +437,7 @@ const rig = (on: On, engine?: any) => {
413
437
  ...r.panes.filter(p => p.id !== e.id),
414
438
  { id: e.id, title: e.title ?? e.id, isShown: true, isFocused: false, isPlaced: r.isPlaced },
415
439
  ]
416
- return { value: r.isPlaced ? { isPlaced: true as const } : { isPlaced: false as const, reason: 'terminal too narrow' } }
440
+ return { value: r.isPlaced ? { isPlaced: true as const } : { isPlaced: false as const, reason: r.reason } }
417
441
  })
418
442
  on('ui.close', (_$, e) => {
419
443
  r.panes = r.panes.filter(p => p.id !== e.id)
@@ -426,6 +450,7 @@ const rig = (on: On, engine?: any) => {
426
450
  on('turn.start', (_$, e) => ({ turnId: e.turnId }))
427
451
  on('turn.complete', () => ({ text: '' }))
428
452
  on('session.end', (_$, e) => ({ sessionId: e.sessionId }))
453
+ on('session.attach', (_$, e) => ({ clientId: e.clientId }))
429
454
  on('ui.render', () => ({ type: 'Box', props: { key: 'engine' }, children: [] }))
430
455
  return r
431
456
  }
@@ -439,6 +464,8 @@ const BAND_PROPS = {
439
464
  scroll: { offset: 0, bodyRows: 10 },
440
465
  view: {},
441
466
  } as const
467
+ // What the command answers for an undrawn pane (never the host's raw reason).
468
+ const NOT_SHOWN = 'Map panel is open but not shown yet: it appears once the screen can place it.'
442
469
  const FULLSCREEN = { columns: 200, rows: 50, isFullscreen: true } as const
443
470
 
444
471
  const START = { cwd: '/repo', surface: 'terminal', isInteractive: true } as const
@@ -495,13 +522,24 @@ for (const surface of SURFACES) {
495
522
  expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
496
523
  expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
497
524
  expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
498
- expect((await ui.find({ key: 'head-frontier' }))?.children[0]).toMatchObject({ props: { bold: true } })
499
- expect(await keysOf(ui, 'row-')).toEqual(['row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-211', 'row-213'])
525
+ expect((await ui.find({ key: 'head-frontier' }))?.children[0]).toMatchObject({ props: { bold: true, color: '#9ece6a' } })
526
+ // ogni sezione ha il suo glifo e un filetto colorato davanti a ogni riga
527
+ expect(await textOf(ui, 'glyph-frontier')).toBe('◆ ')
528
+ expect(await textOf(ui, 'glyph-blocked')).toBe('■ ')
529
+ if (surface === 'terminal') {
530
+ expect(await textOf(ui, 'gutter-209')).toBe('│ ')
531
+ expect((await ui.find({ key: 'gutter-211' }))?.children[0]).toMatchObject({ props: { color: '#f7768e' } })
532
+ } else {
533
+ // sul desktop il filetto è una barra continua, alta quanto il blocco
534
+ expect((await ui.find({ key: 'gutter-211-bar' }))?.props).toMatchObject({ backgroundColor: '#f7768e' })
535
+ }
536
+ expect(await keysOf(ui, 'row-')).toEqual(['row-208', 'row-212', 'row-209', 'row-210', 'row-214', 'row-211', 'row-213'])
500
537
 
501
538
  expect(await textOf(ui, 'ticket-209')).toBe('#209 Burn-down view of the map')
502
539
  expect(await textOf(ui, 'type-209')).toBe(' build')
503
- expect((await ui.find({ key: 'type-209' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
540
+ expect((await ui.find({ key: 'type-209' }))?.children[0]).toMatchObject({ props: { color: '#7dcfff' } })
504
541
  expect(await textOf(ui, 'type-214')).toBe(' decision')
542
+ expect((await ui.find({ key: 'type-214' }))?.children[0]).toMatchObject({ props: { color: '#ff9e64' } })
505
543
  const links = (await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)
506
544
  expect(links).toContain(`${URL}/209`)
507
545
 
@@ -527,6 +565,7 @@ for (const surface of SURFACES) {
527
565
  // the quarantined ticket is counted, never listed
528
566
  expect(await ui.find({ key: 'row-215' })).toBeUndefined()
529
567
  expect(await textOf(ui, 'unverified')).toBe('1 unverified ticket hidden (quarantined)')
568
+ expect((await ui.find({ key: 'unverified' }))?.children[0]).toMatchObject({ props: { color: '#e0af68' } })
530
569
  expect(await textOf(ui, 'age')).toBe('updated just now')
531
570
  })
532
571
 
@@ -550,16 +589,20 @@ for (const surface of SURFACES) {
550
589
 
551
590
  expect(await textOf(ui, 'head-uat')).toBe('UAT · 3')
552
591
  expect((await ui.find({ key: 'head-uat' }))?.children[0]).toMatchObject({ props: { bold: true } })
553
- expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-uat', 'group-paused', 'group-blocked'])
592
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-in-progress', 'group-frontier', 'group-uat', 'group-paused', 'group-blocked'])
554
593
  // each ticket once: a UAT row is in no other group, a paused UAT stays under Paused
555
594
  expect(await keysOf(ui, 'row-')).toEqual([
556
- 'row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-240', 'row-241', 'row-242', 'row-243', 'row-211', 'row-213',
595
+ 'row-208', 'row-212', 'row-209', 'row-210', 'row-214', 'row-240', 'row-241', 'row-242', 'row-243', 'row-211', 'row-213',
557
596
  ])
558
597
  expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
559
598
  expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
560
599
  // the parent line, dim, under the row; none without a parent
561
600
  expect(await textOf(ui, 'uat-of-240')).toBe('tests Burn-down view of the map #209')
562
601
  expect((await ui.find({ key: 'uat-of-240' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
602
+ // la riga del genitore sta nel blocco del ticket, dopo il filetto che copre entrambe le righe
603
+ expect(await keysOf(ui, 'entry-lines-240')).toEqual(['entry-lines-240'])
604
+ expect(await textOf(ui, 'entry-lines-240')).toContain('tests Burn-down view of the map #209')
605
+ if (surface === 'terminal') expect(await textOf(ui, 'gutter-240')).toBe('│ │ ')
563
606
  expect(await textOf(ui, 'uat-of-241')).toBe('tests #212')
564
607
  expect(await hasKey(ui, 'uat-of-242')).toBe(false)
565
608
  expect(await textOf(ui, 'uat-of-243')).toBe('tests Paused parent #150')
@@ -654,7 +697,7 @@ for (const surface of SURFACES) {
654
697
  expect(r.calls.filter(isUatRead)).toContainEqual(uatCall())
655
698
  expect(r.calls.filter(isUatSearch)).toEqual([])
656
699
  expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
657
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
700
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-306', 'row-303'])
658
701
  expect(await textOf(ui, 'uat-of-306')).toBe('tests Loose parent #290')
659
702
  })
660
703
 
@@ -675,8 +718,8 @@ for (const surface of SURFACES) {
675
718
  expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
676
719
  expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
677
720
  // a paused ticket appears in one group only: Frontier, In progress, Paused, Blocked in order
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'])
679
- expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-paused', 'group-blocked'])
721
+ 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'])
722
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-in-progress', 'group-frontier', 'group-paused', 'group-blocked'])
680
723
  for (const n of [230, 231, 232]) {
681
724
  expect(await hasKey(ui, `resume-${n}`)).toBe(true)
682
725
  expect(await hasKey(ui, `work-${n}`)).toBe(false)
@@ -692,12 +735,12 @@ for (const surface of SURFACES) {
692
735
  const ui = await openAndMount($, r)
693
736
  await pressDashboard(ui, r)
694
737
  expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
695
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-305', 'row-303'])
738
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-305', 'row-303'])
696
739
  expect(await hasKey(ui, 'work-305')).toBe(false)
697
740
  expect(await hasKey(ui, 'resume-305')).toBe(true)
698
741
  })
699
742
 
700
- test(`needs-you tickets sit in their own group first, once each, with a work button unless held [${surface}]`, async ($, on) => {
743
+ test(`needs-you tickets sit in their own group right after In progress, once each, with a work button unless held [${surface}]`, async ($, on) => {
701
744
  const r = rig(on, $)
702
745
  r.tickets = [
703
746
  ...TICKETS,
@@ -713,9 +756,9 @@ for (const surface of SURFACES) {
713
756
 
714
757
  expect(await textOf(ui, 'head-needs-you')).toBe('Needs you · 6')
715
758
  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'])
759
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-in-progress', 'group-needs-you', 'group-frontier', 'group-blocked'])
760
+ // each ticket once, after my in-progress rows and ahead of the frontier rows; the other counts leave them out
761
+ 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'])
719
762
  expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
720
763
  expect(await hasKey(ui, 'head-paused')).toBe(false)
721
764
  expect(await hasKey(ui, 'head-uat')).toBe(false)
@@ -738,16 +781,16 @@ for (const surface of SURFACES) {
738
781
  expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '251' }])
739
782
  })
740
783
 
741
- test(`the dashboard view puts a needs-you whiteboard ticket first in its own group [${surface}]`, async ($, on) => {
784
+ test(`the dashboard view puts a needs-you whiteboard ticket in its own group right after In progress [${surface}]`, async ($, on) => {
742
785
  const r = rig(on, $)
743
786
  r.whiteboard = [...WHITEBOARD, issue(307, 'Loose needs you', [...WB, 'trailhead:task', 'trailhead:needs-you', 'trailhead:paused'])]
744
787
  await $.session.start(START)
745
788
  const ui = await openAndMount($, r)
746
789
  await pressDashboard(ui, r)
747
790
  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')
791
+ // right after the maps group and In progress
792
+ expect((await keysOf(ui, 'group-')).slice(0, 3)).toEqual(['group-maps', 'group-in-progress', 'group-needs-you'])
793
+ expect((await keysOf(ui, 'row-')).slice(0, 2)).toEqual(['row-302', 'row-307'])
751
794
  expect(await hasKey(ui, 'head-paused')).toBe(false)
752
795
  expect(await hasKey(ui, 'work-307')).toBe(true)
753
796
  expect(await hasKey(ui, 'resume-307')).toBe(false)
@@ -1107,6 +1150,17 @@ for (const surface of SURFACES) {
1107
1150
  expect(await reasonFor(() => (r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'boom' })))).toMatch(/^⚠ gh failed \(exit 1\) · list from /)
1108
1151
  })
1109
1152
 
1153
+ test(`a pinned checkout that is gone is named, not blamed on gh [${surface}]`, async ($, on) => {
1154
+ const r = rig(on, $)
1155
+ await $.session.start(START)
1156
+ const ui = await openAndMount($, r)
1157
+ r.gone = [ROOT]
1158
+ await r.clock.advance(FLOOR)
1159
+ await completeTurn($)
1160
+ await settle(r)
1161
+ expect(await textOf(ui, 'age')).toMatch(/^⚠ checkout not found: \/repo · list from /)
1162
+ })
1163
+
1110
1164
  test(`a first fetch that fails shows the reason without a list [${surface}]`, async ($, on) => {
1111
1165
  const r = rig(on, $)
1112
1166
  r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'boom' })
@@ -1280,6 +1334,22 @@ for (const surface of SURFACES) {
1280
1334
  expect(listReads(r)).toEqual([])
1281
1335
  })
1282
1336
 
1337
+ // A heredoc body is data: a write-looking line inside one starts no read, a real write after it still does (#369).
1338
+ test(`a heredoc body that names a tracker write starts no pane read [${surface}]`, async ($, on) => {
1339
+ const r = rig(on, $)
1340
+ await $.session.start(START)
1341
+ await openAndMount($, r)
1342
+ await r.clock.advance(FLOOR)
1343
+ const before = fetches(r)
1344
+ await $.tool.call({ tool: 'Bash', command: "cat > /tmp/x.md <<'EOF'\ngh issue close 209\nEOF" } as never)
1345
+ await settle(r)
1346
+ expect(fetches(r)).toBe(before)
1347
+
1348
+ await $.tool.call({ tool: 'Bash', command: "cat > /tmp/x.md <<'EOF'\nbody\nEOF\ngh issue close 209" } as never)
1349
+ await settle(r)
1350
+ expect(fetches(r)).toBe(before + 1)
1351
+ })
1352
+
1283
1353
  // The reads asked while the pane was undrawn (the person on another session) are dropped: drawn again with a
1284
1354
  // list older than one slow-timer period, the pane reads at once instead of showing a closed ticket.
1285
1355
  test(`a pane drawn again with an overdue list reads at once [${surface}]`, async ($, on) => {
@@ -1370,7 +1440,7 @@ for (const surface of SURFACES) {
1370
1440
  await $.session.start(START)
1371
1441
  const ui = await openAndMount($, r)
1372
1442
  await pressDashboard(ui, r)
1373
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
1443
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-303'])
1374
1444
  })
1375
1445
 
1376
1446
  test(`a write that reads at once is followed by one more read at the floor's end [${surface}]`, async ($, on) => {
@@ -1462,6 +1532,34 @@ for (const surface of SURFACES) {
1462
1532
  }
1463
1533
  })
1464
1534
 
1535
+ test(`a multi-line Bash write refreshes, a multi-line read does not [${surface}]`, async ($, on) => {
1536
+ const r = rig(on, $)
1537
+ await $.session.start(START)
1538
+ await openAndMount($, r)
1539
+ const heredoc = "cat > /tmp/x.md <<'EOF'\nbody line\nEOF"
1540
+ const base = fetches(r)
1541
+ for (const command of [`${heredoc}\ngh issue view 5`, `${heredoc}\ngh issue list --state open`]) {
1542
+ await r.clock.advance(FLOOR)
1543
+ await $.tool.call({ tool: 'Bash', command } as never)
1544
+ await settle(r)
1545
+ }
1546
+ expect(fetches(r)).toBe(base)
1547
+
1548
+ for (const command of [`${heredoc}\ncd /repo && gh issue edit 325 --title "t"`, `${heredoc}\ngh issue create --body-file /tmp/x.md`]) {
1549
+ // until a whole floor passes with no read (the previous write's follow-up, the slow timer), so this write starts outside one
1550
+ let seen = -1
1551
+ while (seen !== fetches(r)) {
1552
+ seen = fetches(r)
1553
+ await r.clock.advance(FLOOR)
1554
+ await settle(r)
1555
+ }
1556
+ const before = fetches(r)
1557
+ await $.tool.call({ tool: 'Bash', command } as never)
1558
+ await settle(r)
1559
+ expect([command, fetches(r)]).toEqual([command, before + 1])
1560
+ }
1561
+ })
1562
+
1465
1563
  test(`closing the pane stops refreshing [${surface}]`, async ($, on) => {
1466
1564
  const r = rig(on, $)
1467
1565
  await $.session.start(START)
@@ -1623,9 +1721,11 @@ for (const surface of SURFACES) {
1623
1721
  // The token the verb receives decodes (plain base64url, the helper's alphabet) to the pinned root; the suite has no Buffer.
1624
1722
  const [number, word] = r.runs[1].args.split(' ')
1625
1723
  expect(number).toBe('209')
1626
- expect(word).toMatch(/^--in=[A-Za-z0-9_-]+$/)
1724
+ expect(word).toMatch(/^--in=[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/)
1627
1725
  expect(word).toBe(IN(ROOT))
1628
- expect(atob(word.slice('--in='.length).replace(/-/g, '+').replace(/_/g, '/'))).toBe(ROOT)
1726
+ const [rootPart, repoPart] = word.slice('--in='.length).split('.')
1727
+ expect(atob(rootPart.replace(/-/g, '+').replace(/_/g, '/'))).toBe(ROOT)
1728
+ expect(atob(repoPart.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(repoPart.length / 4) * 4, '='))).toBe('github.com/o/r')
1629
1729
  expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1630
1730
  expect(await ui.find({ key: 'work-209' })).toBeDefined()
1631
1731
  })
@@ -2280,7 +2380,7 @@ for (const surface of SURFACES) {
2280
2380
  await settle(r)
2281
2381
  expect(await textOf(ui, 'map')).toBe('#14 older map')
2282
2382
  expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
2283
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
2383
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
2284
2384
  })
2285
2385
 
2286
2386
  test(`the refresh button obeys the same floor [${surface}]`, async ($, on) => {
@@ -2367,17 +2467,60 @@ for (const surface of SURFACES) {
2367
2467
  expect(r.logs).toEqual([])
2368
2468
  })
2369
2469
 
2370
- test(`/trailhead:panel says why a pane cannot be placed [${surface}]`, async ($, on) => {
2470
+ test(`/trailhead:panel says plainly that a pane cannot be placed yet [${surface}]`, async ($, on) => {
2371
2471
  const r = rig(on, $)
2372
2472
  r.isPlaced = false
2373
2473
  await $.session.start(START)
2374
- expect((await panel($)).text).toBe('Map panel not shown: terminal too narrow')
2474
+ expect((await panel($)).text).toBe(NOT_SHOWN)
2375
2475
  await settle(r)
2376
2476
  expect(fetches(r)).toBe(0)
2377
- // a pane waiting undrawn can still be closed
2477
+ // a pane the person never saw is not closed by a repeat: it stays open, undrawn, until it can be placed
2478
+ expect((await panel($)).text).toBe(NOT_SHOWN)
2479
+ expect((await panel($)).text).toBe(NOT_SHOWN)
2480
+ expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
2481
+ // placed at last, the command sees it drawn and closes it
2482
+ r.isPlaced = true
2483
+ expect((await panel($)).text).toBe('Map panel opened.')
2484
+ expect((await panel($)).text).toBe('Map panel closed.')
2485
+ expect(r.panes).toEqual([])
2486
+ })
2487
+
2488
+ // #348: l'auto-open su una superficie che non ha ancora comunicato il layout resta non disegnato; il comando
2489
+ // non deve chiudere un pannello che la persona non ha mai visto.
2490
+ test(`/trailhead:panel never closes a pane the auto-open left undrawn on a surface without layout [${surface}]`, async ($, on) => {
2491
+ const r = rig(on, $)
2492
+ r.isPlaced = false
2493
+ r.reason = 'attached surfaces place no panes: desktop has not reported its layout'
2494
+ await $.session.start(START)
2495
+ await mountBand($, FULLSCREEN)
2496
+ await settle(r)
2497
+ expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
2498
+
2499
+ const shown = NOT_SHOWN
2500
+ expect((await panel($)).text).toBe(shown)
2501
+ expect((await panel($)).text).toBe(shown)
2502
+ expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
2503
+
2504
+ // the layout arrives: the command draws it, and only then does a repeat close it
2505
+ r.isPlaced = true
2506
+ expect((await panel($)).text).toBe('Map panel opened.')
2378
2507
  expect((await panel($)).text).toBe('Map panel closed.')
2379
2508
  })
2380
2509
 
2510
+ // #321: nessuna superficie collegata (inizio sessione) non è un errore: il pannello resta aperto in attesa.
2511
+ test(`/trailhead:panel with no surface attached keeps the pane pending and says so plainly [${surface}]`, async ($, on) => {
2512
+ const r = rig(on, $)
2513
+ r.isPlaced = false
2514
+ r.reason = 'no-surface: no surface is attached ($.session.surfaces() is empty)'
2515
+ await $.session.start(START)
2516
+ const pending = 'Map panel will open as soon as the screen is ready.'
2517
+ expect((await panel($)).text).toBe(pending)
2518
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
2519
+ // a second command while still pending is idempotent: it never closes the pane
2520
+ expect((await panel($)).text).toBe(pending)
2521
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
2522
+ })
2523
+
2381
2524
  test(`/trailhead:panel shows a pane the auto-open left undrawn instead of closing it [${surface}]`, async ($, on) => {
2382
2525
  const r = rig(on, $)
2383
2526
  // sessione vuota, terminale sotto la soglia dei pannelli non richiesti: l'auto-open resta non disegnato
@@ -2399,6 +2542,148 @@ for (const surface of SURFACES) {
2399
2542
  expect(r.panes).toEqual([])
2400
2543
  })
2401
2544
 
2545
+ // #348: un auto-open che resta non disegnato non conta come fatto, così un trigger successivo può riprovare.
2546
+ test(`an auto-open left undrawn does not end the auto-open, a drawn pane does [${surface}]`, async ($, on) => {
2547
+ const r = rig(on, $)
2548
+ r.isPlaced = false
2549
+ r.reason = 'attached surfaces place no panes: desktop has not reported its layout'
2550
+ await $.session.start(START)
2551
+ await mountBand($, FULLSCREEN)
2552
+ await settle(r)
2553
+ expect(r.opens).toHaveLength(1)
2554
+
2555
+ // listed but undrawn: further renders do not open it again (the host places it when it can)
2556
+ await mountBand($, FULLSCREEN)
2557
+ await settle(r)
2558
+ expect(r.opens).toHaveLength(1)
2559
+
2560
+ // the host dropped it while still undrawn: the next render tries again
2561
+ r.panes = []
2562
+ await mountBand($, FULLSCREEN)
2563
+ await settle(r)
2564
+ expect(r.opens).toHaveLength(2)
2565
+
2566
+ // drawn at last: its own render completes the auto-open for good
2567
+ r.panes = r.panes.map(p => ({ ...p, isPlaced: true }))
2568
+ await mountPane($)
2569
+ await settle(r)
2570
+ r.panes = []
2571
+ await mountBand($, FULLSCREEN)
2572
+ await settle(r)
2573
+ expect(r.opens).toHaveLength(2)
2574
+ })
2575
+
2576
+ test(`an auto-open that finds the pane listed and drawn ends the auto-open [${surface}]`, async ($, on) => {
2577
+ const r = rig(on, $)
2578
+ r.isPlaced = false
2579
+ await $.session.start(START)
2580
+ await mountBand($, FULLSCREEN)
2581
+ await settle(r)
2582
+ expect(r.opens).toHaveLength(1)
2583
+
2584
+ // placed by the host with no pane render yet (the pane's render needs the dock): the next band render sees it
2585
+ r.panes = r.panes.map(p => ({ ...p, isPlaced: true }))
2586
+ await mountBand($, FULLSCREEN)
2587
+ await settle(r)
2588
+ r.panes = []
2589
+ await mountBand($, FULLSCREEN)
2590
+ await settle(r)
2591
+ expect(r.opens).toHaveLength(1)
2592
+ })
2593
+
2594
+ test(`the person's own request on an undrawn pane ends the auto-open, a close of it stays closed [${surface}]`, async ($, on) => {
2595
+ const r = rig(on, $)
2596
+ r.isPlaced = false
2597
+ await $.session.start(START)
2598
+ await mountBand($, FULLSCREEN)
2599
+ await settle(r)
2600
+ expect(r.opens).toHaveLength(1)
2601
+
2602
+ // the command is the person acting on it: the auto-open is done even though the pane is still undrawn
2603
+ expect((await panel($)).text).toBe(NOT_SHOWN)
2604
+ expect(r.opens).toHaveLength(2)
2605
+ r.panes = []
2606
+ await mountBand($, FULLSCREEN)
2607
+ await settle(r)
2608
+ expect(r.opens).toHaveLength(2)
2609
+ })
2610
+
2611
+ // Il desktop avvia la sessione al primo invio: il comando che l'ha avviata arriva subito dopo l'auto-open.
2612
+ test(`/trailhead:panel right after the auto-open keeps the pane the person never saw [${surface}]`, async ($, on) => {
2613
+ const r = rig(on, $)
2614
+ await $.session.start(START)
2615
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
2616
+ await settle(r)
2617
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
2618
+
2619
+ const opened = await panel($)
2620
+ expect(opened.text).toBe('Map panel opened.')
2621
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
2622
+
2623
+ // the next press acts on a pane the person asked for: it closes, however soon
2624
+ const closed = await panel($)
2625
+ expect(closed.text).toBe('Map panel closed.')
2626
+ expect(r.panes).toEqual([])
2627
+ })
2628
+
2629
+ test(`/trailhead:panel closes an auto-opened pane once it has been on screen [${surface}]`, async ($, on) => {
2630
+ const r = rig(on, $)
2631
+ await $.session.start(START)
2632
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
2633
+ await settle(r)
2634
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
2635
+
2636
+ await r.clock.advance(2_000)
2637
+ const closed = await panel($)
2638
+ expect(closed.text).toBe('Map panel closed.')
2639
+ expect(r.panes).toEqual([])
2640
+ })
2641
+
2642
+ test(`a surface attaching in fullscreen retries the auto-open [${surface}]`, async ($, on) => {
2643
+ const r = rig(on, $)
2644
+ r.isPlaced = false
2645
+ r.reason = 'no-surface: no surface is attached ($.session.surfaces() is empty)'
2646
+ await $.session.start(START)
2647
+ await settle(r)
2648
+ expect(r.opens).toEqual([])
2649
+
2650
+ // not fullscreen, or no layout said: the render trigger decides
2651
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: { columns: 200, rows: 50, isFullscreen: false } } as never)
2652
+ await $.session.attach({ surface, clientId: `${surface}:default` } as never)
2653
+ await settle(r)
2654
+ expect(r.opens).toEqual([])
2655
+
2656
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
2657
+ await settle(r)
2658
+ expect(r.opens).toHaveLength(1)
2659
+
2660
+ // still undrawn and listed: another attach does not open again
2661
+ await $.session.attach({ surface, clientId: `${surface}:other`, viewport: FULLSCREEN } as never)
2662
+ await settle(r)
2663
+ expect(r.opens).toHaveLength(1)
2664
+ })
2665
+
2666
+ test(`an attach never opens the pane after the person closed it [${surface}]`, async ($, on) => {
2667
+ const r = rig(on, $)
2668
+ await $.session.start(START)
2669
+ await panel($)
2670
+ await panel($)
2671
+ expect(r.panes).toEqual([])
2672
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
2673
+ await settle(r)
2674
+ expect(r.opens).toHaveLength(1)
2675
+
2676
+ })
2677
+
2678
+ test(`an attach without an active map does not open the pane [${surface}]`, async ($, on) => {
2679
+ const r = rig(on, $)
2680
+ r.active = null
2681
+ await $.session.start(START)
2682
+ await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
2683
+ await settle(r)
2684
+ expect(r.opens).toEqual([])
2685
+ })
2686
+
2402
2687
  for (const withBand of [false, true]) {
2403
2688
  test(`the pane opens by itself once in fullscreen, the band ${withBand ? 'drawing' : 'yielding'} [${surface}]`, async ($, on) => {
2404
2689
  const r = rig(on, $)
@@ -2519,7 +2804,7 @@ for (const surface of SURFACES) {
2519
2804
  expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2520
2805
  expect(await keysOf(ui, 'map-row-')).toEqual(['map-row-14', 'map-row-198'])
2521
2806
  expect(await textOf(ui, 'map-mark-198')).toBe('● ')
2522
- expect((await ui.find({ key: 'map-mark-198' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
2807
+ expect((await ui.find({ key: 'map-mark-198' }))?.children[0]).toMatchObject({ props: { bold: true, color: '#bb9af7' } })
2523
2808
  expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
2524
2809
  expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
2525
2810
  expect((await ui.find({ key: 'map-label-198' }))?.children[0]).toMatchObject({ props: { bold: true } })
@@ -2552,7 +2837,7 @@ for (const surface of SURFACES) {
2552
2837
 
2553
2838
  expect(await textOf(ui, 'map')).toBe('#14 older map')
2554
2839
  expect(await textOf(ui, 'destination')).toBe('An older destination.')
2555
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
2840
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
2556
2841
  expect(await textOf(ui, 'holder-141')).toBe('@carol')
2557
2842
  expect(await textOf(ui, 'burndown-counts')).toBe('open 2 · done 0 · removed 0 · peak 2')
2558
2843
  // the ticket in play belongs to the other map: nothing here is marked
@@ -2727,7 +3012,7 @@ for (const surface of SURFACES) {
2727
3012
  await settle(r)
2728
3013
  expect(await hasKey(ui, 'ticket-300')).toBe(false)
2729
3014
  expect(await textOf(ui, 'map')).toBe('#14 older map')
2730
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3015
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
2731
3016
  })
2732
3017
 
2733
3018
  test(`with no active map the open maps are listed and one can be viewed [${surface}]`, async ($, on) => {
@@ -2757,6 +3042,57 @@ for (const surface of SURFACES) {
2757
3042
  await settle(r)
2758
3043
  }
2759
3044
 
3045
+ // My tickets in play on other maps and the whiteboard, under every view's In progress.
3046
+ const MINE = [
3047
+ issue(150, 'My whiteboard fix', [...WB, 'trailhead:build'], ['me']),
3048
+ issue(160, 'My other map ticket', ['trailhead:ticket', 'trailhead:build', 'trailhead:map-14'], ['me']),
3049
+ issue(161, 'My paused one', ['trailhead:ticket', 'trailhead:build', 'trailhead:map-14', 'trailhead:paused'], ['me']),
3050
+ issue(212, 'Native gh-backed tools', ['trailhead:ticket', 'trailhead:build', 'trailhead:map-198'], ['me']),
3051
+ ]
3052
+
3053
+ test(`the map's In progress adds my tickets of the other maps and the whiteboard, each saying where [${surface}]`, async ($, on) => {
3054
+ const r = rig(on, $)
3055
+ r.mine = MINE
3056
+ await $.session.start(START)
3057
+ const ui = await openAndMount($, r)
3058
+ expect(r.mineCalls[0]).toEqual(['gh', 'api', 'graphql', '-f', 'query=query{viewer{login}}'])
3059
+ expect(r.mineCalls.some(argv => argv.includes('login=me'))).toBe(true)
3060
+ expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 4')
3061
+ // the map's own first, then mine elsewhere; a paused one and one the map already lists are not added
3062
+ expect(await keysOf(ui, 'row-')).toEqual(['row-208', 'row-212', 'row-150', 'row-160', 'row-209', 'row-210', 'row-214', 'row-211', 'row-213'])
3063
+ expect(await textOf(ui, 'scope-of-150')).toBe('on whiteboard')
3064
+ expect(await textOf(ui, 'scope-of-160')).toBe('on map #14 older map')
3065
+ expect((await ui.find({ key: 'scope-of-160' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
3066
+ expect(await textOf(ui, 'holder-160')).toBe('@me')
3067
+ expect(await hasKey(ui, 'scope-of-212')).toBe(false)
3068
+ expect(await hasKey(ui, 'row-161')).toBe(false)
3069
+ })
3070
+
3071
+ test(`the dashboard's In progress adds my tickets of every map [${surface}]`, async ($, on) => {
3072
+ const r = rig(on, $)
3073
+ r.mine = MINE
3074
+ await $.session.start(START)
3075
+ const ui = await openAndMount($, r)
3076
+ await pressDashboard(ui, r)
3077
+ expect(await textOf(ui, 'scope-of-160')).toBe('on map #14 older map')
3078
+ expect(await textOf(ui, 'scope-of-212')).toBe('on map #198 trailhead mod')
3079
+ // a whiteboard ticket of mine belongs to this view's own list, never a second time with a scope line
3080
+ expect(await hasKey(ui, 'scope-of-150')).toBe(false)
3081
+ })
3082
+
3083
+ test(`a failed read of my tickets keeps the last ones and sets no error [${surface}]`, async ($, on) => {
3084
+ const r = rig(on, $)
3085
+ r.mine = MINE
3086
+ await $.session.start(START)
3087
+ const ui = await openAndMount($, r)
3088
+ expect(await hasKey(ui, 'row-160')).toBe(true)
3089
+ r.mine = null
3090
+ await r.clock.advance(TIMER)
3091
+ await settle(r)
3092
+ expect(await hasKey(ui, 'row-160')).toBe(true)
3093
+ expect(await textOf(ui, 'age')).toBe('updated just now')
3094
+ })
3095
+
2760
3096
  test(`the Whiteboard row shows the maps index and the whiteboard tickets [${surface}]`, async ($, on) => {
2761
3097
  const r = rig(on, $)
2762
3098
  await $.session.start(START)
@@ -2792,7 +3128,7 @@ for (const surface of SURFACES) {
2792
3128
  expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
2793
3129
  expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 1')
2794
3130
  expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 1')
2795
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
3131
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-303'])
2796
3132
  expect(await textOf(ui, 'holder-302')).toBe('@alice')
2797
3133
  expect(await hasKey(ui, 'work-301')).toBe(true)
2798
3134
  expect(await textOf(ui, 'unverified')).toBe('1 unverified ticket hidden (quarantined)')
@@ -3147,7 +3483,7 @@ for (const surface of SURFACES) {
3147
3483
  const ui = await openAndMount($, r)
3148
3484
  await pressDashboard(ui, r)
3149
3485
  expect(await textOf(ui, 'head-whiteboard')).toBe('Whiteboard · 4 open · 2 assigned')
3150
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
3486
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-306', 'row-303'])
3151
3487
  expect(await textOf(ui, 'holder-302')).toBe('@alice')
3152
3488
  expect(await hasKey(ui, 'resume-306')).toBe(true)
3153
3489
  expect(await hasKey(ui, 'row-305')).toBe(false)
@@ -3176,7 +3512,7 @@ for (const surface of SURFACES) {
3176
3512
  await pressDashboard(ui, r)
3177
3513
  await pressMap(ui, r, 14)
3178
3514
  expect(await textOf(ui, 'map')).toBe('#14 older map')
3179
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3515
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3180
3516
 
3181
3517
  await pressDashboard(ui, r)
3182
3518
  await pressMap(ui, r, 198)
@@ -3228,7 +3564,7 @@ for (const surface of SURFACES) {
3228
3564
  const ui = await openAndMount($, r)
3229
3565
  expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
3230
3566
  await pressDashboard(ui, r)
3231
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
3567
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-303'])
3232
3568
  await ui.press({ key: 'to-map' })
3233
3569
  await settle(r)
3234
3570
  expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
@@ -3242,7 +3578,7 @@ for (const surface of SURFACES) {
3242
3578
  await $.command.run({ command: 'clear', args: '' } as never)
3243
3579
  await settle(r)
3244
3580
  expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
3245
- expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
3581
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-303'])
3246
3582
  })
3247
3583
  test(`the pane keeps its repo when the session cwd moves out of it [${surface}]`, async ($, on) => {
3248
3584
  const r = rig(on, $)
@@ -3281,7 +3617,7 @@ for (const surface of SURFACES) {
3281
3617
  await completeTurn($)
3282
3618
  await settle(r)
3283
3619
  expect(await textOf(ui, 'map')).toBe('#14 older map')
3284
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3620
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3285
3621
  expect(r.calls).toContainEqual(MAPS_CALL)
3286
3622
  expect(r.cwds.every(cwd => cwd === ROOT_OTHER)).toBe(true)
3287
3623
  // The toplevel is read where the session now is, not at its project root, which a shell `cd` leaves behind.
@@ -3369,7 +3705,7 @@ for (const surface of SURFACES) {
3369
3705
  await $.turn.start({ text: 'go', turnId: 'race-1' })
3370
3706
  release()
3371
3707
  await settle(r)
3372
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3708
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3373
3709
  })
3374
3710
 
3375
3711
  test(`a refresh in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
@@ -3401,7 +3737,7 @@ for (const surface of SURFACES) {
3401
3737
  await r.clock.advance(FLOOR)
3402
3738
  await settle(r)
3403
3739
  // 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'])
3740
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3405
3741
  })
3406
3742
 
3407
3743
  test(`a same-repo pin move landing inside the stale check is read again [${surface}]`, async ($, on) => {
@@ -3433,7 +3769,7 @@ for (const surface of SURFACES) {
3433
3769
  await settle(r)
3434
3770
  await r.clock.advance(FLOOR)
3435
3771
  await settle(r)
3436
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3772
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3437
3773
  })
3438
3774
 
3439
3775
  test(`a dashboard read in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
@@ -3562,7 +3898,7 @@ for (const surface of SURFACES) {
3562
3898
  expect(await keysOf(ui, 'row-')).not.toContain('row-209')
3563
3899
  await settle(r)
3564
3900
  expect(await textOf(ui, 'map')).toBe('#14 older map')
3565
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3901
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3566
3902
  })
3567
3903
 
3568
3904
  // The panel follows the whiteboard ticket this session takes (#306).