@marcomigozzi/trailhead 0.16.1 → 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 (76) hide show
  1. package/README.it.md +57 -10
  2. package/README.md +57 -10
  3. package/bin/capture-action.test.js +167 -0
  4. package/bin/capture-docs.test.js +32 -0
  5. package/bin/engine-route-docs.test.js +148 -0
  6. package/bin/grill-model-docs.test.js +155 -0
  7. package/bin/grilling-holistic-review-docs.test.js +7 -3
  8. package/bin/lib/codex-projection.js +22 -13
  9. package/bin/lib/codex-projection.test.js +58 -17
  10. package/bin/lib/host-descriptor.js +1 -1
  11. package/bin/lib/host-descriptor.test.js +6 -0
  12. package/bin/report-status-docs.test.js +10 -2
  13. package/bin/session-trust-docs.test.js +37 -0
  14. package/bin/surfaced-scope-docs.test.js +57 -0
  15. package/bin/trailhead.js +7 -5
  16. package/bin/trailhead.test.js +53 -11
  17. package/bin/unblock-docs.test.js +56 -0
  18. package/package.json +1 -1
  19. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  21. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  22. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  23. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  24. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  25. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  26. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  27. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  28. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  29. package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
  30. package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
  31. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
  32. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  33. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  34. package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
  35. package/plugins/trailhead/hooks/mod/README.md +16 -7
  36. package/plugins/trailhead/hooks/mod/map-pane.tsx +72 -16
  37. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  38. package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
  39. package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
  40. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  41. package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
  42. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  43. package/plugins/trailhead/skills/_shared/configuration-reference.md +18 -16
  44. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  45. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  46. package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
  47. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  48. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  49. package/plugins/trailhead/skills/_shared/teamwork.md +6 -2
  50. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  51. package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
  52. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
  53. package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
  54. package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
  55. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  56. package/plugins/trailhead/skills/_shared/techniques/verify.md +3 -1
  57. package/plugins/trailhead/skills/_shared/techniques.md +6 -4
  58. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  59. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  60. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  61. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
  62. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
  63. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  64. package/plugins/trailhead/skills/trailhead-work/SKILL.md +11 -9
  65. package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
  66. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +10 -10
  67. package/plugins/trailhead/templates/trailhead-capture.js +418 -0
  68. package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
  69. package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
  70. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  71. package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
  72. package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
  73. package/plugins/trailhead/tests/map-pane-ui.test.ts +122 -37
  74. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  75. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  76. 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[],
@@ -209,6 +217,8 @@ const rig = (on: On, engine?: any) => {
209
217
  calls: [] as (readonly string[])[],
210
218
  panes: [] as { id: string; title: string; isShown: boolean; isFocused: boolean; isPlaced: boolean }[],
211
219
  isPlaced: true,
220
+ // Why `ui.open` leaves the pane unplaced (the SDK's `reason`).
221
+ reason: 'terminal too narrow',
212
222
  logs: [] as string[],
213
223
  toasts: [] as string[],
214
224
  // What a Bash call prints on stdout (the created issue's URL for a `gh issue create`).
@@ -396,6 +406,14 @@ const rig = (on: On, engine?: any) => {
396
406
  const out = r.authHosts.includes(host) ? { exitCode: 0, stdout: '', stderr: '' } : { exitCode: 1, stdout: '', stderr: `You are not logged into any accounts on ${host}` }
397
407
  return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
398
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
+ }
399
417
  r.calls.push([...e.argv])
400
418
  r.cwds.push(e.init?.cwd)
401
419
  // `gh` needs a repo around it: without its own cwd it uses the session's, wherever a `cd` left it.
@@ -413,7 +431,7 @@ const rig = (on: On, engine?: any) => {
413
431
  ...r.panes.filter(p => p.id !== e.id),
414
432
  { id: e.id, title: e.title ?? e.id, isShown: true, isFocused: false, isPlaced: r.isPlaced },
415
433
  ]
416
- 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 } }
417
435
  })
418
436
  on('ui.close', (_$, e) => {
419
437
  r.panes = r.panes.filter(p => p.id !== e.id)
@@ -496,7 +514,7 @@ for (const surface of SURFACES) {
496
514
  expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
497
515
  expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
498
516
  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'])
517
+ expect(await keysOf(ui, 'row-')).toEqual(['row-208', 'row-212', 'row-209', 'row-210', 'row-214', 'row-211', 'row-213'])
500
518
 
501
519
  expect(await textOf(ui, 'ticket-209')).toBe('#209 Burn-down view of the map')
502
520
  expect(await textOf(ui, 'type-209')).toBe(' build')
@@ -550,10 +568,10 @@ for (const surface of SURFACES) {
550
568
 
551
569
  expect(await textOf(ui, 'head-uat')).toBe('UAT · 3')
552
570
  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'])
571
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-in-progress', 'group-frontier', 'group-uat', 'group-paused', 'group-blocked'])
554
572
  // each ticket once: a UAT row is in no other group, a paused UAT stays under Paused
555
573
  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',
574
+ 'row-208', 'row-212', 'row-209', 'row-210', 'row-214', 'row-240', 'row-241', 'row-242', 'row-243', 'row-211', 'row-213',
557
575
  ])
558
576
  expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
559
577
  expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
@@ -654,7 +672,7 @@ for (const surface of SURFACES) {
654
672
  expect(r.calls.filter(isUatRead)).toContainEqual(uatCall())
655
673
  expect(r.calls.filter(isUatSearch)).toEqual([])
656
674
  expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
657
- 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'])
658
676
  expect(await textOf(ui, 'uat-of-306')).toBe('tests Loose parent #290')
659
677
  })
660
678
 
@@ -675,8 +693,8 @@ for (const surface of SURFACES) {
675
693
  expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
676
694
  expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
677
695
  // 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'])
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'])
680
698
  for (const n of [230, 231, 232]) {
681
699
  expect(await hasKey(ui, `resume-${n}`)).toBe(true)
682
700
  expect(await hasKey(ui, `work-${n}`)).toBe(false)
@@ -692,12 +710,12 @@ for (const surface of SURFACES) {
692
710
  const ui = await openAndMount($, r)
693
711
  await pressDashboard(ui, r)
694
712
  expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
695
- 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'])
696
714
  expect(await hasKey(ui, 'work-305')).toBe(false)
697
715
  expect(await hasKey(ui, 'resume-305')).toBe(true)
698
716
  })
699
717
 
700
- 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) => {
701
719
  const r = rig(on, $)
702
720
  r.tickets = [
703
721
  ...TICKETS,
@@ -713,9 +731,9 @@ for (const surface of SURFACES) {
713
731
 
714
732
  expect(await textOf(ui, 'head-needs-you')).toBe('Needs you · 6')
715
733
  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'])
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'])
719
737
  expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
720
738
  expect(await hasKey(ui, 'head-paused')).toBe(false)
721
739
  expect(await hasKey(ui, 'head-uat')).toBe(false)
@@ -738,16 +756,16 @@ for (const surface of SURFACES) {
738
756
  expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '251' }])
739
757
  })
740
758
 
741
- 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) => {
742
760
  const r = rig(on, $)
743
761
  r.whiteboard = [...WHITEBOARD, issue(307, 'Loose needs you', [...WB, 'trailhead:task', 'trailhead:needs-you', 'trailhead:paused'])]
744
762
  await $.session.start(START)
745
763
  const ui = await openAndMount($, r)
746
764
  await pressDashboard(ui, r)
747
765
  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')
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'])
751
769
  expect(await hasKey(ui, 'head-paused')).toBe(false)
752
770
  expect(await hasKey(ui, 'work-307')).toBe(true)
753
771
  expect(await hasKey(ui, 'resume-307')).toBe(false)
@@ -1370,7 +1388,7 @@ for (const surface of SURFACES) {
1370
1388
  await $.session.start(START)
1371
1389
  const ui = await openAndMount($, r)
1372
1390
  await pressDashboard(ui, r)
1373
- 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'])
1374
1392
  })
1375
1393
 
1376
1394
  test(`a write that reads at once is followed by one more read at the floor's end [${surface}]`, async ($, on) => {
@@ -1623,9 +1641,11 @@ for (const surface of SURFACES) {
1623
1641
  // The token the verb receives decodes (plain base64url, the helper's alphabet) to the pinned root; the suite has no Buffer.
1624
1642
  const [number, word] = r.runs[1].args.split(' ')
1625
1643
  expect(number).toBe('209')
1626
- expect(word).toMatch(/^--in=[A-Za-z0-9_-]+$/)
1644
+ expect(word).toMatch(/^--in=[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/)
1627
1645
  expect(word).toBe(IN(ROOT))
1628
- 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')
1629
1649
  expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1630
1650
  expect(await ui.find({ key: 'work-209' })).toBeDefined()
1631
1651
  })
@@ -2280,7 +2300,7 @@ for (const surface of SURFACES) {
2280
2300
  await settle(r)
2281
2301
  expect(await textOf(ui, 'map')).toBe('#14 older map')
2282
2302
  expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
2283
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
2303
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
2284
2304
  })
2285
2305
 
2286
2306
  test(`the refresh button obeys the same floor [${surface}]`, async ($, on) => {
@@ -2378,6 +2398,20 @@ for (const surface of SURFACES) {
2378
2398
  expect((await panel($)).text).toBe('Map panel closed.')
2379
2399
  })
2380
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
+
2381
2415
  test(`/trailhead:panel shows a pane the auto-open left undrawn instead of closing it [${surface}]`, async ($, on) => {
2382
2416
  const r = rig(on, $)
2383
2417
  // sessione vuota, terminale sotto la soglia dei pannelli non richiesti: l'auto-open resta non disegnato
@@ -2552,7 +2586,7 @@ for (const surface of SURFACES) {
2552
2586
 
2553
2587
  expect(await textOf(ui, 'map')).toBe('#14 older map')
2554
2588
  expect(await textOf(ui, 'destination')).toBe('An older destination.')
2555
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
2589
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
2556
2590
  expect(await textOf(ui, 'holder-141')).toBe('@carol')
2557
2591
  expect(await textOf(ui, 'burndown-counts')).toBe('open 2 · done 0 · removed 0 · peak 2')
2558
2592
  // the ticket in play belongs to the other map: nothing here is marked
@@ -2727,7 +2761,7 @@ for (const surface of SURFACES) {
2727
2761
  await settle(r)
2728
2762
  expect(await hasKey(ui, 'ticket-300')).toBe(false)
2729
2763
  expect(await textOf(ui, 'map')).toBe('#14 older map')
2730
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
2764
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
2731
2765
  })
2732
2766
 
2733
2767
  test(`with no active map the open maps are listed and one can be viewed [${surface}]`, async ($, on) => {
@@ -2757,6 +2791,57 @@ for (const surface of SURFACES) {
2757
2791
  await settle(r)
2758
2792
  }
2759
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
+
2760
2845
  test(`the Whiteboard row shows the maps index and the whiteboard tickets [${surface}]`, async ($, on) => {
2761
2846
  const r = rig(on, $)
2762
2847
  await $.session.start(START)
@@ -2792,7 +2877,7 @@ for (const surface of SURFACES) {
2792
2877
  expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
2793
2878
  expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 1')
2794
2879
  expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 1')
2795
- 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'])
2796
2881
  expect(await textOf(ui, 'holder-302')).toBe('@alice')
2797
2882
  expect(await hasKey(ui, 'work-301')).toBe(true)
2798
2883
  expect(await textOf(ui, 'unverified')).toBe('1 unverified ticket hidden (quarantined)')
@@ -3147,7 +3232,7 @@ for (const surface of SURFACES) {
3147
3232
  const ui = await openAndMount($, r)
3148
3233
  await pressDashboard(ui, r)
3149
3234
  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'])
3235
+ expect(await keysOf(ui, 'row-')).toEqual(['row-302', 'row-301', 'row-306', 'row-303'])
3151
3236
  expect(await textOf(ui, 'holder-302')).toBe('@alice')
3152
3237
  expect(await hasKey(ui, 'resume-306')).toBe(true)
3153
3238
  expect(await hasKey(ui, 'row-305')).toBe(false)
@@ -3176,7 +3261,7 @@ for (const surface of SURFACES) {
3176
3261
  await pressDashboard(ui, r)
3177
3262
  await pressMap(ui, r, 14)
3178
3263
  expect(await textOf(ui, 'map')).toBe('#14 older map')
3179
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3264
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3180
3265
 
3181
3266
  await pressDashboard(ui, r)
3182
3267
  await pressMap(ui, r, 198)
@@ -3228,7 +3313,7 @@ for (const surface of SURFACES) {
3228
3313
  const ui = await openAndMount($, r)
3229
3314
  expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
3230
3315
  await pressDashboard(ui, r)
3231
- 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'])
3232
3317
  await ui.press({ key: 'to-map' })
3233
3318
  await settle(r)
3234
3319
  expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
@@ -3242,7 +3327,7 @@ for (const surface of SURFACES) {
3242
3327
  await $.command.run({ command: 'clear', args: '' } as never)
3243
3328
  await settle(r)
3244
3329
  expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
3245
- 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'])
3246
3331
  })
3247
3332
  test(`the pane keeps its repo when the session cwd moves out of it [${surface}]`, async ($, on) => {
3248
3333
  const r = rig(on, $)
@@ -3281,7 +3366,7 @@ for (const surface of SURFACES) {
3281
3366
  await completeTurn($)
3282
3367
  await settle(r)
3283
3368
  expect(await textOf(ui, 'map')).toBe('#14 older map')
3284
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3369
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3285
3370
  expect(r.calls).toContainEqual(MAPS_CALL)
3286
3371
  expect(r.cwds.every(cwd => cwd === ROOT_OTHER)).toBe(true)
3287
3372
  // The toplevel is read where the session now is, not at its project root, which a shell `cd` leaves behind.
@@ -3369,7 +3454,7 @@ for (const surface of SURFACES) {
3369
3454
  await $.turn.start({ text: 'go', turnId: 'race-1' })
3370
3455
  release()
3371
3456
  await settle(r)
3372
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3457
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3373
3458
  })
3374
3459
 
3375
3460
  test(`a refresh in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
@@ -3401,7 +3486,7 @@ for (const surface of SURFACES) {
3401
3486
  await r.clock.advance(FLOOR)
3402
3487
  await settle(r)
3403
3488
  // 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'])
3489
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3405
3490
  })
3406
3491
 
3407
3492
  test(`a same-repo pin move landing inside the stale check is read again [${surface}]`, async ($, on) => {
@@ -3433,7 +3518,7 @@ for (const surface of SURFACES) {
3433
3518
  await settle(r)
3434
3519
  await r.clock.advance(FLOOR)
3435
3520
  await settle(r)
3436
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3521
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3437
3522
  })
3438
3523
 
3439
3524
  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 +3647,7 @@ for (const surface of SURFACES) {
3562
3647
  expect(await keysOf(ui, 'row-')).not.toContain('row-209')
3563
3648
  await settle(r)
3564
3649
  expect(await textOf(ui, 'map')).toBe('#14 older map')
3565
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3650
+ expect(await keysOf(ui, 'row-')).toEqual(['row-141', 'row-140'])
3566
3651
  })
3567
3652
 
3568
3653
  // The panel follows the whiteboard ticket this session takes (#306).
@@ -0,0 +1,66 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+
3
+ import { parseTickets } from '../hooks/mod/map-pane-view'
4
+ import { elsewhereInProgress, myTicketsFromRows, parseLogin, scopeLine } from '../hooks/mod/my-tickets'
5
+
6
+ const row = (number: number, names: string[], logins: string[] = ['me']) => ({
7
+ number,
8
+ title: `t${number}`,
9
+ url: `https://github.com/o/r/issues/${number}`,
10
+ labels: names.map(name => ({ name })),
11
+ assignees: logins.map(login => ({ login })),
12
+ })
13
+
14
+ test('parseLogin reads the viewer login, null on anything else', () => {
15
+ expect(parseLogin(JSON.stringify({ data: { viewer: { login: 'me' } } }))).toBe('me')
16
+ expect(parseLogin(JSON.stringify({ data: { viewer: { login: '' } } }))).toBeNull()
17
+ expect(parseLogin(JSON.stringify({ errors: [{}], data: { viewer: { login: 'me' } } }))).toBeNull()
18
+ expect(parseLogin('not json')).toBeNull()
19
+ })
20
+
21
+ test('myTicketsFromRows tags each ticket with its map or the whiteboard, and drops the rest', () => {
22
+ const mine = myTicketsFromRows([
23
+ row(5, ['trailhead:ticket', 'trailhead:build', 'trailhead:map-12']),
24
+ row(6, ['trailhead:ticket', 'trailhead:bug', 'trailhead:whiteboard']),
25
+ // no scope, contradictory scopes, and quarantined: never shown
26
+ row(7, ['trailhead:ticket', 'trailhead:build']),
27
+ row(8, ['trailhead:ticket', 'trailhead:map-1', 'trailhead:whiteboard']),
28
+ row(9, ['trailhead:ticket', 'trailhead:map-1', 'trailhead:map-2']),
29
+ row(10, ['trailhead:ticket', 'trailhead:map-1', 'trailhead:unverified']),
30
+ ])
31
+ expect(mine.map(t => [t.number, t.scope])).toEqual([
32
+ [5, { kind: 'map', number: 12 }],
33
+ [6, { kind: 'whiteboard' }],
34
+ ])
35
+ expect(mine[0]?.state).toBe('in-progress')
36
+ })
37
+
38
+ test('elsewhereInProgress keeps my in-progress tickets of the other scopes only', () => {
39
+ const mine = myTicketsFromRows([
40
+ row(1, ['trailhead:ticket', 'trailhead:map-12']),
41
+ row(2, ['trailhead:ticket', 'trailhead:map-30']),
42
+ row(3, ['trailhead:ticket', 'trailhead:whiteboard']),
43
+ // not in progress: paused, blocked, needs-you, UAT
44
+ row(4, ['trailhead:ticket', 'trailhead:map-30', 'trailhead:paused']),
45
+ row(5, ['trailhead:ticket', 'trailhead:map-30', 'trailhead:blocked']),
46
+ row(6, ['trailhead:ticket', 'trailhead:map-30', 'trailhead:needs-you']),
47
+ row(7, ['trailhead:ticket', 'trailhead:map-30', 'trailhead:uat']),
48
+ ])
49
+ expect(elsewhereInProgress(mine, { kind: 'map', number: 12 }, []).map(t => t.number)).toEqual([2, 3])
50
+ expect(elsewhereInProgress(mine, { kind: 'whiteboard' }, []).map(t => t.number)).toEqual([1, 2])
51
+ // a ticket the view already lists is never shown twice
52
+ const shown = parseTickets(JSON.stringify([row(2, ['trailhead:ticket', 'trailhead:map-12'])]))?.tickets ?? []
53
+ expect(elsewhereInProgress(mine, { kind: 'map', number: 12 }, shown).map(t => t.number)).toEqual([3])
54
+ })
55
+
56
+ test('scopeLine names the map by number and title, or the whiteboard, cut to the width', () => {
57
+ const [onMap, onBoard] = myTicketsFromRows([
58
+ row(1, ['trailhead:ticket', 'trailhead:map-30']),
59
+ row(2, ['trailhead:ticket', 'trailhead:whiteboard']),
60
+ ])
61
+ const maps = [{ number: 30, title: 'Billing rework', counts: null }]
62
+ expect(onMap && scopeLine(onMap, maps, 60)).toBe('on map #30 Billing rework')
63
+ expect(onMap && scopeLine(onMap, [], 60)).toBe('on map #30')
64
+ expect(onBoard && scopeLine(onBoard, maps, 60)).toBe('on whiteboard')
65
+ expect((onMap && scopeLine(onMap, maps, 16))?.length).toBeLessThanOrEqual(16)
66
+ })
@@ -112,12 +112,27 @@ test('repoFromGit keeps a repo with no origin remote unpinned, so the last pin s
112
112
  expect(nextPin(current, repo)).toBe(current)
113
113
  })
114
114
 
115
- test('checkoutArg hands the root over as one shell-safe word the helper decodes back', () => {
115
+ const fromBase64url = (token: string): string => {
116
+ const bytes = Uint8Array.from(atob(token.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(token.length / 4) * 4, '=')), c => c.charCodeAt(0))
117
+ return new TextDecoder().decode(bytes)
118
+ }
119
+
120
+ test('checkoutArg hands the root and the repo over as one shell-safe word the helper decodes back', () => {
116
121
  for (const root of ['/repo', '/with space/r', "/it's/r", '/-dash/r', '/uni\u00e8\u4e2d/r', '/a"b$c`d']) {
117
122
  const arg = checkoutArg({ ...GH, root })
118
- expect(arg).toMatch(/^--in=[A-Za-z0-9_-]+$/)
119
- const token = arg.slice('--in='.length)
120
- const bytes = Uint8Array.from(atob(token.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(token.length / 4) * 4, '=')), c => c.charCodeAt(0))
121
- expect(new TextDecoder().decode(bytes)).toBe(root)
123
+ expect(arg).toMatch(/^--in=[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/)
124
+ const [path, repo] = arg.slice('--in='.length).split('.')
125
+ expect(fromBase64url(path)).toBe(root)
126
+ expect(fromBase64url(repo)).toBe('github.com/o/r')
122
127
  }
123
128
  })
129
+
130
+ test('checkoutArg carries host and a nested slug in the repo part', () => {
131
+ const arg = checkoutArg({ root: '/w/p', host: 'gitlab.com', slug: 'g/sub/r' })
132
+ expect(fromBase64url(arg.split('.')[1])).toBe('gitlab.com/g/sub/r')
133
+ })
134
+
135
+ test('checkoutArg carries a dotless host in the repo part', () => {
136
+ const arg = checkoutArg({ root: '/w/p', host: 'gitea', slug: 'owner/name' })
137
+ expect(fromBase64url(arg.split('.')[1])).toBe('gitea/owner/name')
138
+ })
@@ -42,6 +42,11 @@ export type PaneTicket = {
42
42
  uatOf: UatParent | null
43
43
  }
44
44
 
45
+ // Where one of my tickets lives: an open map by number, or the whiteboard.
46
+ export type TicketScope = { kind: 'map'; number: number } | { kind: 'whiteboard' }
47
+ // A ticket assigned to the gh login, tagged with its scope (the In progress group lists it outside that scope).
48
+ export type MyTicket = PaneTicket & { scope: TicketScope }
49
+
45
50
  // A map's sub-issue counts: `open` still to do, `total` all of them.
46
51
  export type MapCounts = { open: number; total: number }
47
52
  // One open map of the repo, as the maps list shows it; `counts` null = unknown, drawn name-only.
@@ -113,6 +118,8 @@ declare module 'claude-code' {
113
118
  // The pane shows the dashboard (maps index + whiteboard) instead of a map.
114
119
  isDashboard: boolean
115
120
  dashboardPane: DashboardPaneData
121
+ // My open tickets across every map and the whiteboard (the last good read), for every view's In progress.
122
+ myTickets: readonly MyTicket[]
116
123
  pendingWork: number | null
117
124
  // Whether the pending press waits for the turn to end or has been sent.
118
125
  pendingPhase: 'queued' | 'starting' | null