@marcomigozzi/trailhead 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +118 -30
- package/README.md +107 -29
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +1 -1
- package/bin/lib/codex-projection.test.js +41 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +341 -1
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +38 -6
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +168 -15
- package/plugins/trailhead/hooks/mod/map-pane.tsx +275 -106
- package/plugins/trailhead/hooks/mod/register.tsx +15 -2
- package/plugins/trailhead/hooks/mod/work-state-view.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/skills/_shared/choices.md +4 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
- package/plugins/trailhead/skills/_shared/substrate.md +6 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +16 -7
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +14 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -4
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +26 -10
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/map-pane-ui.test.ts +566 -19
- package/plugins/trailhead/tests/map-pane-view.test.ts +304 -16
- package/plugins/trailhead/tests/work-state-view.test.ts +25 -1
- package/plugins/trailhead/types/index.d.ts +37 -3
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { atom, read, update } from 'claude-code'
|
|
5
5
|
import type { EngineInterface, On } from 'claude-code'
|
|
6
6
|
|
|
7
|
-
import type { BurndownHistory, MapPaneData, MapSummary, PaneTicket, WorkState } from '../../types'
|
|
7
|
+
import type { BurndownHistory, DashboardPaneData, MapCounts, MapPaneData, MapSummary, PaneTicket, UatParent, WorkState } from '../../types'
|
|
8
8
|
import {
|
|
9
9
|
ageLabel,
|
|
10
10
|
axisStart,
|
|
@@ -14,20 +14,29 @@ import {
|
|
|
14
14
|
failureReason,
|
|
15
15
|
footerStatus,
|
|
16
16
|
groupTickets,
|
|
17
|
+
headingRoom,
|
|
17
18
|
HISTORY_LIMIT,
|
|
18
19
|
LIST_LIMIT,
|
|
20
|
+
MAP_LABEL,
|
|
19
21
|
mapRowLayout,
|
|
20
22
|
nextViewed,
|
|
21
23
|
parseActiveMap,
|
|
24
|
+
parseDashboard,
|
|
22
25
|
parseHistory,
|
|
23
26
|
parseMap,
|
|
24
27
|
parseMaps,
|
|
28
|
+
parseMapsRead,
|
|
25
29
|
parseTickets,
|
|
30
|
+
parseUatParents,
|
|
26
31
|
rasterCells,
|
|
27
32
|
refreshDecision,
|
|
28
33
|
rowLayout,
|
|
29
34
|
sparkline,
|
|
35
|
+
uatParentLine,
|
|
30
36
|
viewTarget,
|
|
37
|
+
withUatParents,
|
|
38
|
+
whiteboardHeading,
|
|
39
|
+
whiteboardRowLayout,
|
|
31
40
|
} from './map-pane-view'
|
|
32
41
|
import { fit, isMissing, sameValue } from './work-state-view'
|
|
33
42
|
|
|
@@ -41,10 +50,13 @@ export const GH_TIMEOUT_MS = 20_000
|
|
|
41
50
|
const AGE_REFRESH_MS = 60_000
|
|
42
51
|
const ACTIVE_MAP_PATH = '.trailhead/active-map'
|
|
43
52
|
const TICKET_FIELDS = 'number,title,labels,assignees,url'
|
|
53
|
+
const UAT_FIELDS = 'number,body'
|
|
44
54
|
const MAP_FIELDS = 'title,body,url'
|
|
45
55
|
const HISTORY_FIELDS = 'number,labels,createdAt,closedAt'
|
|
46
56
|
const MAPS_FIELDS = 'number,title,url'
|
|
47
57
|
const MAPS_LIMIT = 100
|
|
58
|
+
const MAPS_QUERY = `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){issues(first:${MAPS_LIMIT},states:OPEN,labels:["trailhead:map"]){nodes{number title url subIssuesSummary{total completed}}}whiteboardOpen:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}whiteboardAll:issues(labels:["trailhead:whiteboard"]){totalCount}}}`
|
|
59
|
+
const DASHBOARD_FIELDS = 'number,url'
|
|
48
60
|
// Same colours as the band.
|
|
49
61
|
const ACCENT = '#7aa2f7'
|
|
50
62
|
// The accent as a Raster colour (0x00RRGGBB), and the chart height in terminal rows.
|
|
@@ -58,6 +70,7 @@ const EMPTY: MapPaneData = {
|
|
|
58
70
|
map: null,
|
|
59
71
|
tickets: [],
|
|
60
72
|
maps: [],
|
|
73
|
+
whiteboard: null,
|
|
61
74
|
unverifiedCount: 0,
|
|
62
75
|
isCapped: false,
|
|
63
76
|
history: null,
|
|
@@ -67,11 +80,24 @@ const EMPTY: MapPaneData = {
|
|
|
67
80
|
isLoading: false,
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
const EMPTY_DASHBOARD: DashboardPaneData = {
|
|
84
|
+
dashboard: null,
|
|
85
|
+
tickets: [],
|
|
86
|
+
unverifiedCount: 0,
|
|
87
|
+
isCapped: false,
|
|
88
|
+
fetchedAt: null,
|
|
89
|
+
error: null,
|
|
90
|
+
isLoading: false,
|
|
91
|
+
}
|
|
92
|
+
|
|
70
93
|
export const mapPane = atom({ plugin: 'trailhead', key: 'mapPane' } as const, EMPTY)
|
|
71
94
|
export const activeMap = atom({ plugin: 'trailhead', key: 'activeMap' } as const, null as number | null)
|
|
72
95
|
// The map shown when it is not the active one (a click on the maps list); null follows the active map.
|
|
73
96
|
// The pane only reads `.trailhead/active-map`; the work verb is what changes it.
|
|
74
97
|
export const viewedMap = atom({ plugin: 'trailhead', key: 'viewedMap' } as const, null as number | null)
|
|
98
|
+
// The dashboard view (maps index + whiteboard tickets) in place of a map; a map press leaves it.
|
|
99
|
+
export const isDashboard = atom({ plugin: 'trailhead', key: 'isDashboard' } as const, false)
|
|
100
|
+
export const dashboardPane = atom({ plugin: 'trailhead', key: 'dashboardPane' } as const, EMPTY_DASHBOARD)
|
|
75
101
|
// The ticket whose `work` press is queued; one at a time.
|
|
76
102
|
export const pendingWork = atom({ plugin: 'trailhead', key: 'pendingWork' } as const, null as number | null)
|
|
77
103
|
// Set once the pane was opened by the command, by itself or closed by the person: never auto-opened after.
|
|
@@ -83,6 +109,7 @@ const CLEAR_COMMAND = 'clear'
|
|
|
83
109
|
// The same refs the band's atoms are written under; read here to mark the ticket in play.
|
|
84
110
|
const workState = atom({ plugin: 'trailhead', key: 'workState' } as const, null as WorkState | null)
|
|
85
111
|
const isOrphaned = atom({ plugin: 'trailhead', key: 'isOrphaned' } as const, false)
|
|
112
|
+
const ownTicket = atom({ plugin: 'trailhead', key: 'ownTicket' } as const, null as number | null)
|
|
86
113
|
|
|
87
114
|
type Dollar = EngineInterface
|
|
88
115
|
type Timer = { cancel: () => void }
|
|
@@ -103,7 +130,7 @@ let lastStartedAt: number | null = null
|
|
|
103
130
|
let trailing: Timer | null = null
|
|
104
131
|
let ticker: Timer | null = null
|
|
105
132
|
// What /clear would take from the pane, kept across it (module variables survive it, atoms do not).
|
|
106
|
-
type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null }
|
|
133
|
+
type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null; isDashboard: boolean }
|
|
107
134
|
let carried: Carried | null = null
|
|
108
135
|
// The ticket whose `work` press is running: /clear empties the pendingWork atom meanwhile.
|
|
109
136
|
let workInFlight: number | null = null
|
|
@@ -146,12 +173,26 @@ const readList = ($: Dollar, target: number) =>
|
|
|
146
173
|
gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS]),
|
|
147
174
|
gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'all', '--limit', String(HISTORY_LIMIT), '--json', HISTORY_FIELDS]),
|
|
148
175
|
readMaps($),
|
|
176
|
+
readUat($, `trailhead:map-${target}`),
|
|
149
177
|
])
|
|
150
178
|
|
|
151
|
-
// The
|
|
152
|
-
|
|
179
|
+
// The pending UAT tickets of one scope (a map label, or the whiteboard) with their bodies, for the parent
|
|
180
|
+
// each one tests. A failed or unreadable read is null: the rows still group by label, only the parent line goes.
|
|
181
|
+
const readUat = async ($: Dollar, scope: string): Promise<ReadonlyMap<number, UatParent> | null> => {
|
|
182
|
+
const out = await gh($, ['issue', 'list', '--label', 'trailhead:uat', '--label', scope, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', UAT_FIELDS])
|
|
183
|
+
return out.ok ? parseUatParents(out.stdout) : null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The open maps of the repo and the whiteboard's counts. A failed or unreadable maps read is null: the
|
|
187
|
+
// caller keeps the last good list. One GraphQL call gives every map with its sub-issue counts and the
|
|
188
|
+
// whiteboard's; if it fails or is unreadable the name-only `gh issue list` runs instead (counts null, and
|
|
189
|
+
// the whiteboard null: the row is drawn name-only, never stale). Both bounded at MAPS_LIMIT open maps.
|
|
190
|
+
const readMaps = async ($: Dollar): Promise<{ maps: readonly MapSummary[] | null; whiteboard: MapCounts | null }> => {
|
|
191
|
+
const counted = await gh($, ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MAPS_QUERY}`])
|
|
192
|
+
const withCounts = counted.ok ? parseMapsRead(counted.stdout) : null
|
|
193
|
+
if (withCounts !== null) return withCounts
|
|
153
194
|
const out = await gh($, ['issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', String(MAPS_LIMIT), '--json', MAPS_FIELDS])
|
|
154
|
-
return out.ok ? parseMaps(out.stdout) : null
|
|
195
|
+
return { maps: out.ok ? parseMaps(out.stdout) : null, whiteboard: null }
|
|
155
196
|
}
|
|
156
197
|
|
|
157
198
|
// What the pane compares to skip a write: times aside, but whether the history is as fresh as the list.
|
|
@@ -176,7 +217,7 @@ async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
|
|
|
176
217
|
if (token !== generation) return
|
|
177
218
|
const isStale = async () => {
|
|
178
219
|
if (token !== generation) return true
|
|
179
|
-
if ((await readTarget($)) === target) return false
|
|
220
|
+
if (!(await read($, isDashboard)) && (await readTarget($)) === target) return false
|
|
180
221
|
wantsAnother()
|
|
181
222
|
return true
|
|
182
223
|
}
|
|
@@ -184,38 +225,76 @@ async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
|
|
|
184
225
|
|
|
185
226
|
if (target === null) {
|
|
186
227
|
// No map to show: the open maps alone, so one can be picked.
|
|
187
|
-
const
|
|
228
|
+
const mapsRead = await readMaps($)
|
|
229
|
+
const maps = mapsRead.maps ?? before.maps
|
|
188
230
|
if (await isStale()) return
|
|
189
|
-
return setPane($, { ...EMPTY, maps })
|
|
231
|
+
return setPane($, { ...EMPTY, maps, whiteboard: mapsRead.whiteboard })
|
|
190
232
|
}
|
|
191
233
|
const isSameMap = before.map?.number === target
|
|
192
234
|
if (!isSameMap) {
|
|
193
235
|
if (await isStale()) return
|
|
194
|
-
await setPane($, { ...EMPTY, maps: before.maps, isLoading: true })
|
|
236
|
+
await setPane($, { ...EMPTY, maps: before.maps, whiteboard: before.whiteboard, isLoading: true })
|
|
195
237
|
}
|
|
196
238
|
|
|
197
|
-
const [view, list, historyRead, mapsRead] = await readList($, target)
|
|
198
|
-
const maps = mapsRead ?? before.maps
|
|
199
|
-
|
|
239
|
+
const [view, list, historyRead, mapsRead, uatParents] = await readList($, target)
|
|
240
|
+
const maps = mapsRead.maps ?? before.maps
|
|
241
|
+
const { whiteboard } = mapsRead
|
|
242
|
+
await dropClosedView($, mapsRead.maps)
|
|
200
243
|
const now = await $.clock.now()
|
|
201
244
|
if (await isStale()) return
|
|
202
245
|
|
|
203
246
|
const kept = isSameMap ? before : EMPTY
|
|
204
247
|
const failure = !view.ok ? view.reason : !list.ok ? list.reason : null
|
|
205
|
-
if (failure !== null) return setPane($, { ...kept, maps, error: failure, isLoading: false })
|
|
248
|
+
if (failure !== null) return setPane($, { ...kept, maps, whiteboard, error: failure, isLoading: false })
|
|
206
249
|
const map = view.ok ? parseMap(view.stdout, target) : null
|
|
207
250
|
const tickets = list.ok ? parseTickets(list.stdout) : null
|
|
208
|
-
if (map === null || tickets === null) return setPane($, { ...kept, maps, error: 'gh returned unreadable output', isLoading: false })
|
|
251
|
+
if (map === null || tickets === null) return setPane($, { ...kept, maps, whiteboard, error: 'gh returned unreadable output', isLoading: false })
|
|
209
252
|
|
|
210
253
|
// A history that failed alone keeps the last good one of this map (with its own time), never another map's.
|
|
211
254
|
const history = historyRead.ok ? parseHistory(historyRead.stdout) : null
|
|
212
255
|
const historyPart = history !== null ? { history, historyAt: now } : { history: kept.history, historyAt: kept.historyAt }
|
|
213
|
-
const next: MapPaneData = { map, ...tickets, maps, ...historyPart, fetchedAt: now, error: null, isLoading: false }
|
|
256
|
+
const next: MapPaneData = { map, ...tickets, tickets: withUatParents(tickets.tickets, uatParents), maps, whiteboard, ...historyPart, fetchedAt: now, error: null, isLoading: false }
|
|
214
257
|
const isFresh = before.fetchedAt !== null && now - before.fetchedAt < AGE_REFRESH_MS
|
|
215
258
|
if (isFresh && isSameMap && sameValue(shape(before), shape(next))) return
|
|
216
259
|
await setPane($, next)
|
|
217
260
|
}
|
|
218
261
|
|
|
262
|
+
// The whiteboard's open tickets (map-less) and the pinned index, plus the open maps. Same drop rule
|
|
263
|
+
// as a map read: a result is not committed once the pane closed or the dashboard was left.
|
|
264
|
+
async function fetchDashboard($: Dollar, token: number, wantsAnother: () => void) {
|
|
265
|
+
const isStale = async () => {
|
|
266
|
+
if (token !== generation) return true
|
|
267
|
+
if (await read($, isDashboard)) return false
|
|
268
|
+
wantsAnother()
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
const before = await read($, dashboardPane)
|
|
272
|
+
if (before.fetchedAt === null && !before.isLoading && before.error === null) {
|
|
273
|
+
await update($, dashboardPane, () => ({ ...before, isLoading: true }))
|
|
274
|
+
}
|
|
275
|
+
const [list, index, mapsRead, uatParents] = await Promise.all([
|
|
276
|
+
gh($, ['issue', 'list', '--label', 'trailhead:ticket', '--label', 'trailhead:whiteboard', '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS]),
|
|
277
|
+
gh($, ['issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', DASHBOARD_FIELDS]),
|
|
278
|
+
readMaps($),
|
|
279
|
+
readUat($, 'trailhead:whiteboard'),
|
|
280
|
+
])
|
|
281
|
+
const now = await $.clock.now()
|
|
282
|
+
if (await isStale()) return
|
|
283
|
+
const shown = await read($, mapPane)
|
|
284
|
+
await setPane($, { ...shown, maps: mapsRead.maps ?? shown.maps, whiteboard: mapsRead.whiteboard })
|
|
285
|
+
await dropClosedView($, mapsRead.maps)
|
|
286
|
+
|
|
287
|
+
const kept = await read($, dashboardPane)
|
|
288
|
+
const tickets = list.ok ? parseTickets(list.stdout, { isWhiteboard: true }) : null
|
|
289
|
+
const error = !list.ok ? list.reason : tickets === null ? 'gh returned unreadable output' : null
|
|
290
|
+
if (tickets === null) return update($, dashboardPane, () => ({ ...kept, error, isLoading: false }))
|
|
291
|
+
// A failed index read alone keeps the last link: the list is what the view is about.
|
|
292
|
+
const parsed = index.ok ? parseDashboard(index.stdout) : null
|
|
293
|
+
const dashboard = parsed === null ? kept.dashboard : parsed.ref
|
|
294
|
+
const next: DashboardPaneData = { dashboard, ...tickets, tickets: withUatParents(tickets.tickets, uatParents), fetchedAt: now, error: null, isLoading: false }
|
|
295
|
+
if (!sameValue(kept, next)) await update($, dashboardPane, () => next)
|
|
296
|
+
}
|
|
297
|
+
|
|
219
298
|
async function isPaneUp($: Dollar): Promise<boolean> {
|
|
220
299
|
return (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
|
|
221
300
|
}
|
|
@@ -252,7 +331,9 @@ async function decide($: Dollar, request: Request) {
|
|
|
252
331
|
lastStartedAt = now
|
|
253
332
|
trailing?.cancel()
|
|
254
333
|
trailing = null
|
|
255
|
-
|
|
334
|
+
const again = () => void requestRefresh($, { bypassFloor: true })
|
|
335
|
+
if (await read($, isDashboard)) await fetchDashboard($, generation, again)
|
|
336
|
+
else await fetchMap($, generation, again)
|
|
256
337
|
}
|
|
257
338
|
|
|
258
339
|
// Does nothing while the pane is closed: the data exists only for the pane.
|
|
@@ -270,6 +351,7 @@ async function carryOverClear($: Dollar) {
|
|
|
270
351
|
isAutoOpened: await read($, paneAutoOpened),
|
|
271
352
|
viewed: await read($, viewedMap),
|
|
272
353
|
active: await read($, activeMap),
|
|
354
|
+
isDashboard: await read($, isDashboard),
|
|
273
355
|
}
|
|
274
356
|
}
|
|
275
357
|
|
|
@@ -281,6 +363,7 @@ async function restoreAfterClear($: Dollar) {
|
|
|
281
363
|
carried = null
|
|
282
364
|
try {
|
|
283
365
|
if (kept?.isAutoOpened === true) await update($, paneAutoOpened, () => true)
|
|
366
|
+
if (kept?.isDashboard === true) await update($, isDashboard, () => true)
|
|
284
367
|
const active = await readActiveMap($)
|
|
285
368
|
const viewed = kept?.viewed ?? null
|
|
286
369
|
if (viewed !== null && viewed !== active && active === kept?.active) await update($, viewedMap, () => viewed)
|
|
@@ -323,6 +406,7 @@ async function startWork($: Dollar, number: number) {
|
|
|
323
406
|
// A view change is not a repeat read, so the floor does not apply.
|
|
324
407
|
async function viewMap($: Dollar, number: number | null) {
|
|
325
408
|
const active = await read($, activeMap)
|
|
409
|
+
await update($, isDashboard, () => false)
|
|
326
410
|
await update($, viewedMap, () => (number === active ? null : number))
|
|
327
411
|
void requestRefresh($, { bypassFloor: true })
|
|
328
412
|
}
|
|
@@ -332,6 +416,16 @@ const pressView = ($: Dollar, number: number | null): void => {
|
|
|
332
416
|
void viewMap($, number).catch(err => debug($, `trailhead mod: map view failed: ${String(err)}`))
|
|
333
417
|
}
|
|
334
418
|
|
|
419
|
+
// The switch to the dashboard (the Whiteboard row), or back to the map shown before it (the view is kept).
|
|
420
|
+
async function setDashboard($: Dollar, isOn: boolean) {
|
|
421
|
+
await update($, isDashboard, () => isOn)
|
|
422
|
+
void requestRefresh($, { bypassFloor: true })
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const pressDashboard = ($: Dollar, isOn: boolean): void => {
|
|
426
|
+
void setDashboard($, isOn).catch(err => debug($, `trailhead mod: dashboard switch failed: ${String(err)}`))
|
|
427
|
+
}
|
|
428
|
+
|
|
335
429
|
// Opening always refreshes. A render cannot write state, not even from work it left running,
|
|
336
430
|
// so the auto-open runs from a zero-delay timer, a dispatch of its own; `isOpening` keeps two
|
|
337
431
|
// renders from opening twice before the flag is written.
|
|
@@ -447,8 +541,11 @@ export function registerMapPane(on: On) {
|
|
|
447
541
|
const active = await read($, activeMap)
|
|
448
542
|
const viewed = await read($, viewedMap)
|
|
449
543
|
const target = viewTarget(active, viewed)
|
|
544
|
+
const isBoard = await read($, isDashboard)
|
|
545
|
+
const board = await read($, dashboardPane)
|
|
450
546
|
const play = await read($, workState)
|
|
451
547
|
const orphaned = await read($, isOrphaned)
|
|
548
|
+
const own = await read($, ownTicket)
|
|
452
549
|
const pending = await read($, pendingWork)
|
|
453
550
|
const now = await $.clock.now()
|
|
454
551
|
const cols = e.props.bodyColumns
|
|
@@ -463,8 +560,9 @@ export function registerMapPane(on: On) {
|
|
|
463
560
|
const link = (url: string, label: string) => (url === '' ? label : <Link href={url} label={label} />)
|
|
464
561
|
|
|
465
562
|
// The open maps, one row each; shown with several maps open (or with no active map, to pick one).
|
|
563
|
+
// On the dashboard every map is listed and each row opens it.
|
|
466
564
|
const mapRow = (m: MapSummary) => {
|
|
467
|
-
const r = mapRowLayout(m, { active, viewed, columns: cols })
|
|
565
|
+
const r = mapRowLayout(m, { active, viewed, columns: cols, isDashboard: isBoard })
|
|
468
566
|
return (
|
|
469
567
|
<Box key={`map-row-${m.number}`}>
|
|
470
568
|
{text(`map-mark-${m.number}`, r.isActive ? accent : {}, r.mark)}
|
|
@@ -481,13 +579,13 @@ export function registerMapPane(on: On) {
|
|
|
481
579
|
</Box>
|
|
482
580
|
)
|
|
483
581
|
}
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
582
|
+
const isMapsShown = isBoard ? data.maps.length > 0 : data.maps.length > 1 || (active === null && data.maps.length > 0)
|
|
583
|
+
const mapsGroup = isMapsShown ? (
|
|
584
|
+
<Box key="group-maps" flexDirection="column" marginTop={gap}>
|
|
585
|
+
{text('head-maps', bold, `Maps · ${data.maps.length}`)}
|
|
586
|
+
{data.maps.map(mapRow)}
|
|
587
|
+
</Box>
|
|
588
|
+
) : null
|
|
491
589
|
// Viewing a map other than the active one: say so, with the way back.
|
|
492
590
|
const viewing =
|
|
493
591
|
viewed === null || viewed === active ? null : (
|
|
@@ -500,58 +598,30 @@ export function registerMapPane(on: On) {
|
|
|
500
598
|
/>
|
|
501
599
|
</Box>
|
|
502
600
|
)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (!isCurrent && !data.isLoading && data.error === null && !isFilling) {
|
|
518
|
-
isFilling = true
|
|
519
|
-
$.clock.after(0, () => {
|
|
520
|
-
isFilling = false
|
|
521
|
-
void requestRefresh($)
|
|
522
|
-
})
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const map = isCurrent ? data.map : null
|
|
526
|
-
const heading = map === null ? `map #${target}` : fit(`#${map.number} ${map.title}`, cols)
|
|
527
|
-
const header = (
|
|
528
|
-
<Box key="header" flexDirection="column">
|
|
529
|
-
<Box key="map">
|
|
530
|
-
<Text {...accent}>{map === null ? heading : link(map.url, heading)}</Text>
|
|
531
|
-
</Box>
|
|
532
|
-
{map?.destination == null ? null : text('destination', dim, fit(map.destination, cols))}
|
|
601
|
+
// The dashboard's header: its title, and the `map` button back to the map shown before it.
|
|
602
|
+
const headerRow = (title: ReturnType<typeof text>) => (
|
|
603
|
+
<Box key="header-row" width={cols} justifyContent="space-between">
|
|
604
|
+
{title}
|
|
605
|
+
<Button key="to-map" label={MAP_LABEL} onPress={() => pressDashboard($, false)} />
|
|
606
|
+
</Box>
|
|
607
|
+
)
|
|
608
|
+
// The way into the whiteboard: a row above the maps list, its counts beside the name like a map row.
|
|
609
|
+
const wb = whiteboardRowLayout(data.whiteboard, cols)
|
|
610
|
+
const whiteboardRow = (
|
|
611
|
+
<Box key="whiteboard-row" marginTop={gap}>
|
|
612
|
+
{text('whiteboard-mark', {}, wb.mark)}
|
|
613
|
+
<Button key="open-whiteboard" label={wb.label} onPress={() => pressDashboard($, true)} />
|
|
614
|
+
{wb.aside === null ? null : text('whiteboard-aside', dim, wb.aside)}
|
|
533
615
|
</Box>
|
|
534
616
|
)
|
|
535
|
-
if (isLoading) {
|
|
536
|
-
return (
|
|
537
|
-
<Box flexDirection="column">
|
|
538
|
-
{header}
|
|
539
|
-
{viewing}
|
|
540
|
-
{text('loading', dim, 'Loading tickets…')}
|
|
541
|
-
{mapsGroup}
|
|
542
|
-
</Box>
|
|
543
|
-
)
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
const tickets = isCurrent ? data.tickets : []
|
|
547
|
-
const groups = groupTickets(tickets)
|
|
548
|
-
const isInPlay = (t: PaneTicket) => play !== null && !orphaned && play.ticket === t.number
|
|
549
617
|
|
|
618
|
+
// This session's own ticket survives a work-state file another session took over; without one, the file decides.
|
|
619
|
+
const isInPlay = (t: PaneTicket) => (own !== null ? t.number === own : play !== null && !orphaned && play.ticket === t.number)
|
|
550
620
|
const row = (t: PaneTicket, hasMarkColumn: boolean) => {
|
|
551
621
|
const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, columns: cols })
|
|
552
622
|
const style = r.isInPlay ? accent : r.isDim ? dim : {}
|
|
553
|
-
const holder = r.
|
|
554
|
-
|
|
623
|
+
const holder = r.isDim ? dim : {}
|
|
624
|
+
const line = (
|
|
555
625
|
<Box key={`row-${t.number}`} width={cols} justifyContent="space-between">
|
|
556
626
|
<Box>
|
|
557
627
|
{r.mark === null ? null : text(`mark-${t.number}`, r.isInPlay ? accent : {}, r.mark)}
|
|
@@ -561,6 +631,7 @@ export function registerMapPane(on: On) {
|
|
|
561
631
|
{r.type === null ? null : text(`type-${t.number}`, dim, r.type)}
|
|
562
632
|
</Box>
|
|
563
633
|
{r.aside?.kind === 'holder' ? text(`holder-${t.number}`, holder, r.aside.text) : null}
|
|
634
|
+
{r.aside?.kind === 'self' ? text(`self-${t.number}`, { color: ACCENT }, r.aside.text) : null}
|
|
564
635
|
{r.aside?.kind === 'queued' ? text(`queued-${t.number}`, { color: AMBER }, r.aside.text) : null}
|
|
565
636
|
{r.aside?.kind === 'work' ? (
|
|
566
637
|
<Button
|
|
@@ -572,8 +643,16 @@ export function registerMapPane(on: On) {
|
|
|
572
643
|
) : null}
|
|
573
644
|
</Box>
|
|
574
645
|
)
|
|
646
|
+
const parent = uatParentLine(t, cols)
|
|
647
|
+
return parent === null ? (
|
|
648
|
+
line
|
|
649
|
+
) : (
|
|
650
|
+
<Box key={`entry-${t.number}`} flexDirection="column">
|
|
651
|
+
{line}
|
|
652
|
+
{text(`uat-of-${t.number}`, dim, parent)}
|
|
653
|
+
</Box>
|
|
654
|
+
)
|
|
575
655
|
}
|
|
576
|
-
|
|
577
656
|
const group = (key: string, name: string, rows: readonly PaneTicket[]) => {
|
|
578
657
|
if (rows.length === 0) return null
|
|
579
658
|
const hasMarkColumn = rows.some(isInPlay)
|
|
@@ -589,15 +668,129 @@ export function registerMapPane(on: On) {
|
|
|
589
668
|
</Box>
|
|
590
669
|
)
|
|
591
670
|
}
|
|
671
|
+
const ticketGroups = (tickets: readonly PaneTicket[]) => {
|
|
672
|
+
const groups = groupTickets(tickets)
|
|
673
|
+
return [
|
|
674
|
+
group('frontier', 'Frontier', groups.frontier),
|
|
675
|
+
group('in-progress', 'In progress', groups.inProgress),
|
|
676
|
+
group('uat', 'UAT', groups.uat),
|
|
677
|
+
group('paused', 'Paused', groups.paused),
|
|
678
|
+
group('blocked', 'Blocked', groups.blocked),
|
|
679
|
+
]
|
|
680
|
+
}
|
|
681
|
+
const notes = (d: { unverifiedCount: number; isCapped: boolean }) => [
|
|
682
|
+
d.unverifiedCount === 0 ? null : (
|
|
683
|
+
<Box key="unverified" marginTop={gap}>
|
|
684
|
+
<Text {...dim}>{unverifiedLine(d.unverifiedCount)}</Text>
|
|
685
|
+
</Box>
|
|
686
|
+
),
|
|
687
|
+
!d.isCapped ? null : (
|
|
688
|
+
<Box key="capped" marginTop={gap}>
|
|
689
|
+
<Text {...dim}>{`list capped at ${LIST_LIMIT}`}</Text>
|
|
690
|
+
</Box>
|
|
691
|
+
),
|
|
692
|
+
]
|
|
693
|
+
const footerOf = (d: { fetchedAt: number | null; error: string | null }) => {
|
|
694
|
+
const status =
|
|
695
|
+
d.error === null
|
|
696
|
+
? `updated ${ageLabel(now - (d.fetchedAt ?? now))}`
|
|
697
|
+
: d.fetchedAt === null
|
|
698
|
+
? `⚠ ${d.error}`
|
|
699
|
+
: `⚠ ${d.error} · list from ${ageLabel(now - d.fetchedAt)}`
|
|
700
|
+
const footer = footerStatus(status, cols)
|
|
701
|
+
return (
|
|
702
|
+
<Box key="footer" flexDirection="column">
|
|
703
|
+
{text('rule', dim, '─'.repeat(cols))}
|
|
704
|
+
<Box key="footer-row" width={cols} justifyContent="space-between">
|
|
705
|
+
<Box gap={1}>
|
|
706
|
+
<Button key="refresh" label="refresh" onPress={() => void requestRefresh($)} />
|
|
707
|
+
{footer.isInline ? text('age', dim, footer.text) : null}
|
|
708
|
+
</Box>
|
|
709
|
+
<Button
|
|
710
|
+
key="close"
|
|
711
|
+
label="close"
|
|
712
|
+
onPress={() => void closePane($).catch(err => debug($, `trailhead mod: close press failed: ${String(err)}`))}
|
|
713
|
+
/>
|
|
714
|
+
</Box>
|
|
715
|
+
{footer.isInline ? null : <Box key="age-row">{text('age', dim, footer.text)}</Box>}
|
|
716
|
+
</Box>
|
|
717
|
+
)
|
|
718
|
+
}
|
|
719
|
+
// An empty view (a pane the person opened, or one that waited undrawn) asks for its first read,
|
|
720
|
+
// once. Like the auto-open it leaves the render through a zero-delay timer; inside the floor it
|
|
721
|
+
// waits for the trailing refresh rather than the slow timer.
|
|
722
|
+
const askFill = () => {
|
|
723
|
+
if (isFilling) return
|
|
724
|
+
isFilling = true
|
|
725
|
+
$.clock.after(0, () => {
|
|
726
|
+
isFilling = false
|
|
727
|
+
void requestRefresh($)
|
|
728
|
+
})
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (isBoard) {
|
|
732
|
+
const isLoaded = board.fetchedAt !== null
|
|
733
|
+
if (!isLoaded && !board.isLoading && board.error === null) askFill()
|
|
734
|
+
const heading = fit('Dashboard', headingRoom(cols, MAP_LABEL))
|
|
735
|
+
const title = (
|
|
736
|
+
<Box key="dashboard-title">
|
|
737
|
+
<Text {...accent}>{board.dashboard === null ? heading : link(board.dashboard.url, heading)}</Text>
|
|
738
|
+
</Box>
|
|
739
|
+
)
|
|
740
|
+
const isEmpty = isLoaded && board.error === null && board.tickets.length === 0
|
|
741
|
+
return (
|
|
742
|
+
<Box flexDirection="column" width={cols} {...(isDock ? { minHeight: e.props.scroll.bodyRows } : {})}>
|
|
743
|
+
{headerRow(title)}
|
|
744
|
+
{mapsGroup}
|
|
745
|
+
<Box key="whiteboard" marginTop={gap}>
|
|
746
|
+
{text('head-whiteboard', bold, whiteboardHeading(isLoaded ? board.tickets : null, board.isCapped))}
|
|
747
|
+
</Box>
|
|
748
|
+
{!isLoaded && board.error === null ? text('loading', dim, 'Loading dashboard…') : null}
|
|
749
|
+
{ticketGroups(board.tickets)}
|
|
750
|
+
{isEmpty ? text('no-tickets', dim, 'No open whiteboard tickets.') : null}
|
|
751
|
+
{notes(board)}
|
|
752
|
+
<Box key="spacer" flexGrow={1} />
|
|
753
|
+
{footerOf(board)}
|
|
754
|
+
</Box>
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (target === null) {
|
|
759
|
+
return (
|
|
760
|
+
<Box flexDirection="column" width={cols}>
|
|
761
|
+
{text('empty', dim, 'No active map. Run /trailhead:map to pick one.')}
|
|
762
|
+
{whiteboardRow}
|
|
763
|
+
{mapsGroup}
|
|
764
|
+
</Box>
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
const isCurrent = data.map?.number === target
|
|
768
|
+
const isLoading = !isCurrent && (data.isLoading || data.error === null)
|
|
769
|
+
if (!isCurrent && !data.isLoading && data.error === null) askFill()
|
|
592
770
|
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
771
|
+
const map = isCurrent ? data.map : null
|
|
772
|
+
const heading = map === null ? `map #${target}` : fit(`#${map.number} ${map.title}`, cols)
|
|
773
|
+
const header = (
|
|
774
|
+
<Box key="header" flexDirection="column">
|
|
775
|
+
<Box key="map">
|
|
776
|
+
<Text {...accent}>{map === null ? heading : link(map.url, heading)}</Text>
|
|
777
|
+
</Box>
|
|
778
|
+
{map?.destination == null ? null : text('destination', dim, fit(map.destination, cols))}
|
|
779
|
+
</Box>
|
|
780
|
+
)
|
|
781
|
+
if (isLoading) {
|
|
782
|
+
return (
|
|
783
|
+
<Box flexDirection="column">
|
|
784
|
+
{header}
|
|
785
|
+
{viewing}
|
|
786
|
+
{text('loading', dim, 'Loading tickets…')}
|
|
787
|
+
{whiteboardRow}
|
|
788
|
+
{mapsGroup}
|
|
789
|
+
</Box>
|
|
790
|
+
)
|
|
791
|
+
}
|
|
600
792
|
|
|
793
|
+
const tickets = isCurrent ? data.tickets : []
|
|
601
794
|
const end = burndownEnd(data, now)
|
|
602
795
|
const series = isCurrent && data.history !== null ? burndownSeries(data.history, end.at, cols) : null
|
|
603
796
|
const burndown = (s: BurndownSeries, history: BurndownHistory) => (
|
|
@@ -621,42 +814,18 @@ export function registerMapPane(on: On) {
|
|
|
621
814
|
<Box flexDirection="column" width={cols} {...(isDock ? { minHeight: e.props.scroll.bodyRows } : {})}>
|
|
622
815
|
{header}
|
|
623
816
|
{viewing}
|
|
817
|
+
{whiteboardRow}
|
|
624
818
|
{mapsGroup}
|
|
625
|
-
{
|
|
626
|
-
{group('in-progress', 'In progress', groups.inProgress)}
|
|
627
|
-
{group('blocked', 'Blocked', groups.blocked)}
|
|
819
|
+
{ticketGroups(tickets)}
|
|
628
820
|
{isCurrent && data.error === null && tickets.length === 0 ? (
|
|
629
821
|
<Box key="no-tickets" marginTop={gap}>
|
|
630
822
|
<Text {...dim}>No open tickets. Fog may remain: /trailhead:map</Text>
|
|
631
823
|
</Box>
|
|
632
824
|
) : null}
|
|
633
|
-
{
|
|
634
|
-
<Box key="unverified" marginTop={gap}>
|
|
635
|
-
<Text {...dim}>{unverifiedLine(data.unverifiedCount)}</Text>
|
|
636
|
-
</Box>
|
|
637
|
-
)}
|
|
638
|
-
{!isCurrent || !data.isCapped ? null : (
|
|
639
|
-
<Box key="capped" marginTop={gap}>
|
|
640
|
-
<Text {...dim}>{`list capped at ${LIST_LIMIT}`}</Text>
|
|
641
|
-
</Box>
|
|
642
|
-
)}
|
|
825
|
+
{isCurrent ? notes(data) : null}
|
|
643
826
|
{series === null || data.history === null ? null : burndown(series, data.history)}
|
|
644
827
|
<Box key="spacer" flexGrow={1} />
|
|
645
|
-
|
|
646
|
-
{text('rule', dim, '─'.repeat(cols))}
|
|
647
|
-
<Box key="footer-row" width={cols} justifyContent="space-between">
|
|
648
|
-
<Box gap={1}>
|
|
649
|
-
<Button key="refresh" label="refresh" onPress={() => void requestRefresh($)} />
|
|
650
|
-
{footer.isInline ? text('age', dim, footer.text) : null}
|
|
651
|
-
</Box>
|
|
652
|
-
<Button
|
|
653
|
-
key="close"
|
|
654
|
-
label="close"
|
|
655
|
-
onPress={() => void closePane($).catch(err => debug($, `trailhead mod: close press failed: ${String(err)}`))}
|
|
656
|
-
/>
|
|
657
|
-
</Box>
|
|
658
|
-
{footer.isInline ? null : <Box key="age-row">{text('age', dim, footer.text)}</Box>}
|
|
659
|
-
</Box>
|
|
828
|
+
{footerOf(data)}
|
|
660
829
|
</Box>
|
|
661
830
|
)
|
|
662
831
|
})
|
|
@@ -9,11 +9,13 @@ import type { EngineInterface, Register } from 'claude-code'
|
|
|
9
9
|
import type { PromptSnapshot, WorkState } from '../../types'
|
|
10
10
|
import { registerMapPane } from './map-pane'
|
|
11
11
|
import { promptSection, repoSlug, snapshotKey } from './work-state-prompt'
|
|
12
|
-
import { fit, isMissing, parseWorkState, pipeline, sameValue, statusLine } from './work-state-view'
|
|
12
|
+
import { fit, isMissing, nextOwnTicket, parseWorkState, pipeline, sameValue, statusLine } from './work-state-view'
|
|
13
13
|
import type { StageStatus } from './work-state-view'
|
|
14
14
|
|
|
15
15
|
export const workState = atom({ plugin: 'trailhead', key: 'workState' } as const, null as WorkState | null)
|
|
16
16
|
export const isOrphaned = atom({ plugin: 'trailhead', key: 'isOrphaned' } as const, false)
|
|
17
|
+
// The ticket this session works, kept across a work-state file other sessions share.
|
|
18
|
+
export const ownTicket = atom({ plugin: 'trailhead', key: 'ownTicket' } as const, null as number | null)
|
|
17
19
|
export const promptSnapshot = atom({ plugin: 'trailhead', key: 'promptSnapshot' } as const, null as PromptSnapshot | null)
|
|
18
20
|
|
|
19
21
|
const STATE_PATH = '.trailhead/work-state.json'
|
|
@@ -95,16 +97,27 @@ async function apply($: Dollar, state: WorkState | null, orphaned: boolean) {
|
|
|
95
97
|
pin($, state, isStale)
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
// Decided from the state read before this refresh replaces it.
|
|
101
|
+
async function setOwnTicket($: Dollar, next: WorkState | null) {
|
|
102
|
+
const prev = await read($, ownTicket)
|
|
103
|
+
const value = nextOwnTicket(prev, await read($, workState), next, await $.session.id())
|
|
104
|
+
if (value !== prev) await update($, ownTicket, () => value)
|
|
105
|
+
}
|
|
106
|
+
|
|
98
107
|
async function refresh($: Dollar) {
|
|
99
108
|
let text: string
|
|
100
109
|
try {
|
|
101
110
|
text = (await $.fs.read(STATE_PATH)) as string
|
|
102
111
|
} catch (err) {
|
|
103
112
|
// A missing file means no ticket in play; any other failure is transient: keep the last state.
|
|
104
|
-
if (isMissing(err))
|
|
113
|
+
if (isMissing(err)) {
|
|
114
|
+
await setOwnTicket($, null)
|
|
115
|
+
await apply($, null, false)
|
|
116
|
+
}
|
|
105
117
|
return
|
|
106
118
|
}
|
|
107
119
|
const state = parseWorkState(text)
|
|
120
|
+
await setOwnTicket($, state)
|
|
108
121
|
if (state === null) return apply($, null, false)
|
|
109
122
|
const session = await $.session.id()
|
|
110
123
|
return apply($, state, state.session !== null && state.session !== session)
|