jackherd 1.18.2 → 1.19.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/CHANGELOG.md +19 -0
- package/INSTALL.md +1 -1
- package/README.md +30 -25
- package/assets/icons/model.svg +1 -0
- package/dist/{chunk-N5FAOQZL.js → chunk-JDO6O3UO.js} +52 -2
- package/dist/config.d.ts +15 -2
- package/dist/config.js +22 -3
- package/dist/plan-format.d.ts +84 -1
- package/dist/plan-format.js +85 -1
- package/docs/plan-contract.md +8 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +10 -11
- package/kit/hooks/hooks/approval-gate/run.ts +23 -3
- package/kit/hooks/hooks/browser-guard/decision.ts +7 -0
- package/kit/hooks/hooks/browser-guard/index.ts +50 -0
- package/kit/hooks/hooks/plain-question/arguments.ts +14 -1
- package/kit/hooks/hooks/plain-question/card.ts +102 -0
- package/kit/hooks/hooks/plain-question/form.ts +25 -9
- package/kit/hooks/hooks/plain-question/index.ts +20 -25
- package/kit/hooks/hooks/plain-question/recommendation.ts +6 -6
- package/kit/hooks/hooks/return-brief/decision.ts +29 -0
- package/kit/hooks/hooks/return-brief/index.ts +37 -0
- package/kit/hooks/hooks/return-brief/settings.ts +11 -0
- package/kit/hooks/hooks/shared/coordinator-call.ts +25 -0
- package/kit/hooks/hooks/shared/transcript.ts +9 -0
- package/kit/hooks/hooks.json +18 -1
- package/kit/review/agents/security-audit.md +1 -1
- package/kit/workflow/agents/browser-check.md +29 -0
- package/kit/workflow/skills/implement/SKILL.md +5 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +36 -14
- package/kit/workflow/skills/orchestrate/references/acceptance.md +10 -9
- package/kit/workflow/skills/orchestrate/references/coordinator-clear.md +45 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +15 -12
- package/kit/workflow/skills/plan/SKILL.md +2 -0
- package/kit/workflow/skills/plan/references/template-English.md +8 -0
- package/kit/workflow/skills/plan/references/template-German.md +8 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +16 -0
- package/src/brief/command.ts +45 -0
- package/src/brief/lane-brief.ts +51 -0
- package/src/brief/lane-facts.ts +64 -0
- package/src/check/skill-budget-defaults.ts +13 -2
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +22 -0
- package/src/lane-prompt/texts.ts +2 -2
- package/src/plan-contract/command.ts +1 -0
- package/src/plan-contract/contract.ts +28 -5
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/plan-head.ts +131 -0
- package/src/plans/active-tasks.ts +8 -1
- package/src/plans/click-hint.ts +2 -1
- package/src/plans/focus-overlay.ts +5 -2
- package/src/plans/folded-detail.ts +47 -0
- package/src/plans/lane-line.ts +86 -0
- package/src/plans/lane-reason.ts +24 -4
- package/src/plans/layout-phase.ts +4 -20
- package/src/plans/layout-summary.ts +56 -45
- package/src/plans/layout.ts +178 -44
- package/src/plans/port-listening.ts +25 -0
- package/src/plans/selection.ts +11 -0
- package/src/plans/snapshot.ts +27 -9
- package/src/plans/task-checklist.ts +57 -0
- package/src/plans/texts.ts +34 -9
- package/src/resume/command.ts +40 -6
- package/src/resume/coordinator-read.ts +136 -0
- package/src/resume/state.ts +163 -0
- package/src/resume/texts.ts +6 -0
- package/src/sync/render.ts +5 -1
- package/src/vocabulary/index.ts +74 -0
- package/src/watch/clear-moment.ts +228 -0
- package/src/watch/command.ts +92 -8
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/reference-paths.ts +8 -4
- package/src/watch/watch-events.ts +13 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { TONE_ROLE } from '../config/tones'
|
|
2
|
+
import { ellipsize } from '../plan-format/terminal-text'
|
|
3
|
+
import { badge, paint } from '../terminal/text'
|
|
4
|
+
import { AGENT_STATUS, isRestingAgent } from '../terminal/types'
|
|
5
|
+
import { LANE_REASON, type LaneReason } from './lane-reason'
|
|
6
|
+
import { TEXTS } from './texts'
|
|
7
|
+
import { paintOfRole } from './tones'
|
|
8
|
+
|
|
9
|
+
/** What a folded lane line says first: the lane needs the user, is blocked on something, or runs. */
|
|
10
|
+
export const LANE_STATE = { needsYou: 'needs-you', blocked: 'blocked', running: 'running' } as const
|
|
11
|
+
export type LaneState = (typeof LANE_STATE)[keyof typeof LANE_STATE]
|
|
12
|
+
|
|
13
|
+
/** A lane as its line needs it: the agent's status, why a resting lane rests, the age of its last activity and its step. */
|
|
14
|
+
export type LaneLineInput = {
|
|
15
|
+
agent: string | null
|
|
16
|
+
reason: LaneReason | null
|
|
17
|
+
minutesAgo: number | null
|
|
18
|
+
step: string
|
|
19
|
+
/** The most cells the text behind the status may take; under `MIN_TEXT_WIDTH` the lead is the status alone. */
|
|
20
|
+
maxText?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const LIST_MARKER = /^·\s*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The state of a lane: a blocked agent or a resting lane with an open question needs you; a resting lane that waits
|
|
27
|
+
* on a row of "Your turn" or on its starts-after cell is blocked; a working lane runs. Null for the rest (a resting
|
|
28
|
+
* lane without a known reason, a lane without an agent), which the line leaves to its older words.
|
|
29
|
+
*/
|
|
30
|
+
export const laneStateOf = (agent: string | null, reason: LaneReason | null): LaneState | null => {
|
|
31
|
+
if (agent === AGENT_STATUS.blocked) return LANE_STATE.needsYou
|
|
32
|
+
if (agent === AGENT_STATUS.working) return LANE_STATE.running
|
|
33
|
+
if (!isRestingAgent(agent) || !reason) return null
|
|
34
|
+
if (reason.kind === LANE_REASON.you) return LANE_STATE.needsYou
|
|
35
|
+
return reason.kind === LANE_REASON.none ? null : LANE_STATE.blocked
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const STATE_WORD: Record<LaneState, () => string> = {
|
|
39
|
+
[LANE_STATE.needsYou]: () => TEXTS.laneNeedsYou,
|
|
40
|
+
[LANE_STATE.blocked]: () => TEXTS.laneBlocked,
|
|
41
|
+
[LANE_STATE.running]: () => TEXTS.laneRunning,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const STATE_PAINT: Record<LaneState, (text: string) => string> = {
|
|
45
|
+
[LANE_STATE.needsYou]: (text) => paint.bold(paintOfRole(TONE_ROLE.waiting)(text)),
|
|
46
|
+
[LANE_STATE.blocked]: paintOfRole(TONE_ROLE.waiting),
|
|
47
|
+
[LANE_STATE.running]: paintOfRole(TONE_ROLE.running),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The most of a question, wait reason or step the lead names, so the phase count behind it stays in view. */
|
|
51
|
+
const TEXT_WIDTH = 44
|
|
52
|
+
|
|
53
|
+
const MIN_TEXT_WIDTH = 8
|
|
54
|
+
|
|
55
|
+
const rawText = (state: LaneState, { reason, step }: LaneLineInput): string => {
|
|
56
|
+
if (state === LANE_STATE.running) return step
|
|
57
|
+
if (!reason) return ''
|
|
58
|
+
return state === LANE_STATE.needsYou
|
|
59
|
+
? reason.text.replace(LIST_MARKER, '')
|
|
60
|
+
: [reason.label, reason.text].filter(Boolean).join(' ')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The question to the user is never cut for room; a wait reason or a step gives way to the phase count behind it. */
|
|
64
|
+
const stateText = (state: LaneState, input: LaneLineInput): string => {
|
|
65
|
+
if (state === LANE_STATE.needsYou) return rawText(state, input)
|
|
66
|
+
const width = Math.min(TEXT_WIDTH, input.maxText ?? TEXT_WIDTH)
|
|
67
|
+
return width < MIN_TEXT_WIDTH ? '' : ellipsize(rawText(state, input), width)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The words that open a folded lane line: `needs you 3 min · <question>`, `blocked 3 min · waits on <what>` or
|
|
72
|
+
* `running 3 min · <step>`. The age is that of the lane's last activity, whole minutes, so a line changes once a
|
|
73
|
+
* minute; the step is the lane's reported task, which changes with the work and not with every action. Empty
|
|
74
|
+
* for a lane without a state.
|
|
75
|
+
*/
|
|
76
|
+
export const laneLead = (input: LaneLineInput): string => {
|
|
77
|
+
const state = laneStateOf(input.agent, input.reason)
|
|
78
|
+
if (!state) return ''
|
|
79
|
+
const age = input.minutesAgo === null ? '' : ` ${TEXTS.laneAge(input.minutesAgo)}`
|
|
80
|
+
const text = stateText(state, input)
|
|
81
|
+
return `${STATE_PAINT[state](`${STATE_WORD[state]()}${age}`)}${text ? `${paint.dim(' ·')} ${text}` : ''}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `:3100 ↗` as a badge linked to the lane's dev server; empty while the lane has no port or nothing listens on it. */
|
|
85
|
+
export const portBadge = (port: number | null, listening: boolean): string =>
|
|
86
|
+
port === null || !listening ? '' : badge(`http://localhost:${port}`, `:${port} ↗`)
|
package/src/plans/lane-reason.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SprintLane } from '../plan-format'
|
|
2
|
+
import { ellipsize } from '../plan-format/terminal-text'
|
|
2
3
|
import { AGENT_STATUS, isRestingAgent } from '../terminal/types'
|
|
3
|
-
import { REST_QUESTION_COUNT } from '../watch/lane-rest'
|
|
4
|
+
import { REST_NOTE_LABEL, REST_QUESTION_COUNT } from '../watch/lane-rest'
|
|
4
5
|
import { waitingNote } from './lane-standing'
|
|
5
6
|
import { plain } from './markdown-text'
|
|
6
7
|
import { shortTitle } from './task-name'
|
|
@@ -28,9 +29,28 @@ const shortOf = (text: string): string => plain(shortTitle(text)).trim()
|
|
|
28
29
|
/** How many open questions the watcher counted in the report; a note without its count is one. */
|
|
29
30
|
const noteQuestionCount = (note: string): number => Number(note.match(REST_QUESTION_COUNT)?.[1] ?? 1)
|
|
30
31
|
|
|
32
|
+
const LIST_MARKER = /^[*\-\s]*(?:\d+\.\s+)?/
|
|
33
|
+
|
|
34
|
+
/** The first question of a rest note in its words, markdown and list markers out; empty when only its label or count remains. */
|
|
35
|
+
const noteQuestion = (note: string): string =>
|
|
36
|
+
plain((note.startsWith(REST_NOTE_LABEL) ? note.slice(REST_NOTE_LABEL.length) : note).replace(REST_QUESTION_COUNT, ''))
|
|
37
|
+
.trim()
|
|
38
|
+
.replace(LIST_MARKER, '')
|
|
39
|
+
|
|
40
|
+
/** The most of a question the line shows: a longer one would push the plan title out of a wide pane. */
|
|
41
|
+
const QUESTION_WIDTH = 30
|
|
42
|
+
|
|
43
|
+
/** What a question to you says: the first question in its words and `+n` for the others, else the count alone. */
|
|
44
|
+
const questionText = (note: string): string => {
|
|
45
|
+
const question = noteQuestion(note)
|
|
46
|
+
return question
|
|
47
|
+
? TEXTS.restAsks(ellipsize(question, QUESTION_WIDTH), noteQuestionCount(note) - 1)
|
|
48
|
+
: TEXTS.restQuestions(noteQuestionCount(note))
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
/**
|
|
32
|
-
* Why a resting lane (idle or done) rests: the open questions of its report wait on you,
|
|
33
|
-
*
|
|
52
|
+
* Why a resting lane (idle or done) rests: the open questions of its report wait on you, the first in its words and
|
|
53
|
+
* `+n` for the others, else a row of "Your turn" that unblocks it, else its starts-after cell, both cut
|
|
34
54
|
* to their short title, else nothing known, which is meant to stand out. Null for a lane that works or whose agent is
|
|
35
55
|
* blocked: the blocked word says it waits on you.
|
|
36
56
|
*/
|
|
@@ -43,7 +63,7 @@ export const laneReason = (
|
|
|
43
63
|
return {
|
|
44
64
|
kind: LANE_REASON.you,
|
|
45
65
|
label: TEXTS.restWaitsOnYou,
|
|
46
|
-
text:
|
|
66
|
+
text: questionText(restNote),
|
|
47
67
|
}
|
|
48
68
|
const row = rows.find(({ unblocks }) => unblocks.includes(lane.name))
|
|
49
69
|
if (row) return { kind: LANE_REASON.on, label: TEXTS.restWaitsOn, text: shortOf(row.text) }
|
|
@@ -153,7 +153,7 @@ export const subagentLine = (description: string, width: number, style: TaskStyl
|
|
|
153
153
|
const taskLook = ({ done }: ChecklistItem, active: ActiveTask | undefined, taskStyle: TaskStyle): TaskLook => {
|
|
154
154
|
if (active) {
|
|
155
155
|
const style = active.guessed ? PLAIN_TASK : taskStyle
|
|
156
|
-
return { mark: activeArrow(active.guessed), style, suffix: agentSuffix(active.agent) }
|
|
156
|
+
return { mark: activeArrow(active.guessed || active.fromPlan === true), style, suffix: agentSuffix(active.agent) }
|
|
157
157
|
}
|
|
158
158
|
return done
|
|
159
159
|
? { mark: paint.green('☑'), style: paint.dim, suffix: '' }
|
|
@@ -236,22 +236,6 @@ export const taskRowFrame = (
|
|
|
236
236
|
const deferredRow = ({ text, target }: DeferredItem, width: number): string =>
|
|
237
237
|
fit(paint.dim(`${DEFERRED_MARK} ${phaseLabel(target)} ${plain(text)}`), width)
|
|
238
238
|
|
|
239
|
-
/**
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
* no second fold row. The tasks in `shown` stand above already and are left out.
|
|
243
|
-
*/
|
|
244
|
-
export const phaseChildren = (
|
|
245
|
-
context: LaneContext,
|
|
246
|
-
phaseRowKey: string,
|
|
247
|
-
phase: PhaseProgress,
|
|
248
|
-
active: ReadonlyMap<number, ActiveTask>,
|
|
249
|
-
shown: ReadonlySet<number>,
|
|
250
|
-
): Frame[] => {
|
|
251
|
-
const frameOf = (item: ChecklistItem, index: number): Frame =>
|
|
252
|
-
taskRowFrame(context, phaseRowKey, { item, index, active: active.get(index) })
|
|
253
|
-
const framesWhere = (done: boolean): Frame[] =>
|
|
254
|
-
phase.items.flatMap((item, index) => (item.done === done && !shown.has(index) ? [frameOf(item, index)] : []))
|
|
255
|
-
const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, context.width)]))
|
|
256
|
-
return [...framesWhere(false), ...deferred, ...framesWhere(true)]
|
|
257
|
-
}
|
|
239
|
+
/** The checkboxes a phase moved to another phase, one dim row each, listed below the tasks of an opened phase. */
|
|
240
|
+
export const deferredFrames = (phase: PhaseProgress, width: number): Frame[] =>
|
|
241
|
+
(phase.deferred ?? []).map((item) => textFrame([deferredRow(item, width)]))
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { phaseLabel } from '../plan-format'
|
|
2
2
|
import { bar, fit, paint, spread, visibleLength } from '../terminal/text'
|
|
3
3
|
import { type Frame, indented, joinFrames, narrower, rowFrame, spaced, textFrame } from './frame'
|
|
4
|
-
import { type ActivePhase, type LaneSummary, type PlacedPhase
|
|
4
|
+
import { type ActivePhase, type LaneSummary, type PlacedPhase } from './lane-summary'
|
|
5
5
|
import {
|
|
6
6
|
activeArrow,
|
|
7
7
|
COLUMN_SEPARATOR,
|
|
8
|
+
deferredFrames,
|
|
8
9
|
type LaneContext,
|
|
9
10
|
MIN_TITLE_WIDTH,
|
|
10
11
|
modelMark,
|
|
11
12
|
nowLine,
|
|
12
|
-
phaseChildren,
|
|
13
13
|
phaseCountCell,
|
|
14
14
|
subagentLine,
|
|
15
15
|
TASK_INDENT,
|
|
@@ -20,16 +20,14 @@ import {
|
|
|
20
20
|
import { plain } from './markdown-text'
|
|
21
21
|
import { type PhaseModel, phaseModelKey } from './phase-models'
|
|
22
22
|
import { phaseRanges } from './phase-ranges'
|
|
23
|
-
import { finishedKey, nextKey, phaseKey } from './selection'
|
|
23
|
+
import { finishedKey, moreTasksKey, nextKey, phaseKey } from './selection'
|
|
24
24
|
import { chevronOf, PHASE_COUNT } from './symbols'
|
|
25
|
+
import { checklistRows, runningTaskIndexes } from './task-checklist'
|
|
25
26
|
import { type HerdTexts, TEXTS } from './texts'
|
|
26
27
|
|
|
27
28
|
/** The context of a lane's branches: the lane's, with the herd's words. */
|
|
28
29
|
type SummaryContext = LaneContext & { labels: HerdTexts }
|
|
29
30
|
|
|
30
|
-
/** What the row under a phase's tasks counts: the open tasks past the first ones and the done ones. */
|
|
31
|
-
type TaskTally = { more: number; done: number }
|
|
32
|
-
|
|
33
31
|
/** What an open lane shows above its phases, drawn at the width of its children: the session, its link chips and its pull requests. */
|
|
34
32
|
export type LaneHead = { session: Frame; chips: Frame; pullRequests: Frame }
|
|
35
33
|
|
|
@@ -39,11 +37,6 @@ const onlyGateOpen = ({ items }: ActivePhase['phase']): boolean => {
|
|
|
39
37
|
return open.length > 0 && open.every(({ gate }) => gate)
|
|
40
38
|
}
|
|
41
39
|
|
|
42
|
-
const taskTally = ({ items }: ActivePhase['phase']): TaskTally => {
|
|
43
|
-
const done = items.filter((item) => item.done).length
|
|
44
|
-
return { more: Math.max(items.length - done - SHOWN_OPEN_TASKS, 0), done }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
40
|
/**
|
|
48
41
|
* `· 6 min ago`, dim: when the running phase was last ticked or reported, nothing when the herd saw neither; `· gate`
|
|
49
42
|
* after it while only the review gate is open. Empty when there is neither.
|
|
@@ -93,7 +86,7 @@ const reportedTaskTexts = ({ phase, tasks }: ActivePhase, labels: HerdTexts): st
|
|
|
93
86
|
* In the order of the open lane: the tasks agents report on the phase, the reported ones no plan task names as
|
|
94
87
|
* `now: …`, then the descriptions of the subagents that name no phase as `Subagent: …`.
|
|
95
88
|
*/
|
|
96
|
-
const reportedTexts = (
|
|
89
|
+
export const reportedTexts = (
|
|
97
90
|
active: ActivePhase | undefined,
|
|
98
91
|
{ unplanned, subagentTasks }: Pick<LaneSummary, 'unplanned' | 'subagentTasks'>,
|
|
99
92
|
labels: HerdTexts,
|
|
@@ -123,57 +116,75 @@ export const foldedTaskLine = (
|
|
|
123
116
|
return `${indent}${lead}${style(fit(first, room))}${tail}`
|
|
124
117
|
}
|
|
125
118
|
|
|
126
|
-
/** The indexes of a phase's open tasks: the ones it is on first, then the others in the order of the plan. */
|
|
127
|
-
const openTaskOrder = ({ phase, tasks }: ActivePhase): number[] => {
|
|
128
|
-
const isOpen = (index: number) => phase.items[index]?.done === false
|
|
129
|
-
const running = tasks.map(({ index }) => index).filter(isOpen)
|
|
130
|
-
const others = phase.items.flatMap((item, index) => (!item.done && !running.includes(index) ? [index] : []))
|
|
131
|
-
return [...new Set(running), ...others]
|
|
132
|
-
}
|
|
133
|
-
|
|
134
119
|
/**
|
|
135
|
-
* The
|
|
136
|
-
*
|
|
120
|
+
* The tasks of a phase that run: the ones agents report, else, while the lane runs the phase, its first open task
|
|
121
|
+
* from the plan, which no agent reported: drawn `◌`, but shimmering like a reported task (`fromPlan`).
|
|
137
122
|
*/
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
123
|
+
const runningTasks = ({ phase, tasks }: ActivePhase, laneRuns: boolean): Map<number, ActivePhase['tasks'][number]> => {
|
|
124
|
+
const indexes = runningTaskIndexes(
|
|
125
|
+
phase.items,
|
|
126
|
+
tasks.map(({ index }) => index),
|
|
127
|
+
laneRuns,
|
|
128
|
+
)
|
|
129
|
+
const reported = new Map(tasks.map((task) => [task.index, task]))
|
|
130
|
+
return new Map(
|
|
131
|
+
indexes.map((index) => [
|
|
132
|
+
index,
|
|
133
|
+
reported.get(index) ?? { index, agent: null, guessed: false, startedAt: null, fromPlan: true as const },
|
|
134
|
+
]),
|
|
135
|
+
)
|
|
142
136
|
}
|
|
143
137
|
|
|
144
|
-
/** The
|
|
145
|
-
const
|
|
138
|
+
/** The dim row for the tasks a window leaves out, a row of its own under `key` when it opens the whole list. */
|
|
139
|
+
const countFrame = (key: string, text: string, open: boolean, context: SummaryContext): Frame =>
|
|
140
|
+
rowFrame(key, [fit(paint.dim(`${chevronOf(open)} ${text}`), context.width)], [], context)
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The checklist of a phase in the plan's order (`checklistRows`): ticked tasks, the running ones marked, the ones to
|
|
144
|
+
* come. The first count row of a window opens the whole list; opened, `▾ <n> tasks` stands first and folds it, and the
|
|
145
|
+
* checkboxes moved to another phase close it.
|
|
146
|
+
*/
|
|
147
|
+
const checklistFrames = (
|
|
146
148
|
context: SummaryContext,
|
|
147
149
|
key: string,
|
|
148
|
-
shown: readonly number[],
|
|
149
150
|
active: ActivePhase,
|
|
150
151
|
running: ReadonlyMap<number, ActivePhase['tasks'][number]>,
|
|
151
|
-
): Frame[] =>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
): Frame[] => {
|
|
153
|
+
const { items } = active.phase
|
|
154
|
+
const moreKey = moreTasksKey(key)
|
|
155
|
+
const { expanded } = context.state
|
|
156
|
+
const open = expanded.has(key) || expanded.has(moreKey)
|
|
157
|
+
const rows = checklistRows(items, [...running.keys()], open)
|
|
158
|
+
const windowed = checklistRows(items, [...running.keys()], false).some(({ kind }) => kind !== 'task')
|
|
159
|
+
const frames = rows.map((row): Frame => {
|
|
160
|
+
if (row.kind === 'task') {
|
|
161
|
+
const item = items[row.index]
|
|
162
|
+
return item
|
|
163
|
+
? taskRowFrame(context, key, { item, index: row.index, active: running.get(row.index) })
|
|
164
|
+
: textFrame([])
|
|
165
|
+
}
|
|
166
|
+
return countFrame(row.kind === 'above' ? key : moreKey, TEXTS.tasksMore(row.count), open, context)
|
|
155
167
|
})
|
|
168
|
+
const foldKey = expanded.has(key) ? key : moreKey
|
|
169
|
+
const folder = open && windowed ? [countFrame(foldKey, TEXTS.tasksListed(items.length), true, context)] : []
|
|
170
|
+
return [...folder, ...frames, ...(open ? deferredFrames(active.phase, context.width) : [])]
|
|
171
|
+
}
|
|
156
172
|
|
|
157
173
|
/**
|
|
158
|
-
* A phase the lane works on: its head with the bar, no row of its own; under it the
|
|
159
|
-
*
|
|
160
|
-
* its text and a `▾`, and the rest of the tasks (`phaseChildren`) stand below it, never above.
|
|
174
|
+
* A phase the lane works on: its head with the bar, no row of its own; under it the checklist of its tasks in the
|
|
175
|
+
* plan's order, the running one marked (`checklistFrames`). `laneRuns` is false for a phase that only waits.
|
|
161
176
|
*/
|
|
162
|
-
const activeFrame = (context: SummaryContext, active: ActivePhase): Frame => {
|
|
163
|
-
const { progress
|
|
177
|
+
const activeFrame = (context: SummaryContext, active: ActivePhase, laneRuns = true): Frame => {
|
|
178
|
+
const { progress } = context
|
|
164
179
|
const stepIndex = active.stepIndex ?? -1
|
|
165
180
|
const key = phaseKey(progress.lane.name, stepIndex, active.phase.id)
|
|
166
181
|
const current = progress.current?.stepIndex === stepIndex && progress.current.phaseId === active.phase.id
|
|
167
182
|
const model = context.models[phaseModelKey(stepIndex, active.phase.id)]
|
|
168
183
|
const inner = narrower(context, TASK_INDENT.length)
|
|
169
|
-
const running =
|
|
170
|
-
const open = state.expanded.has(key)
|
|
171
|
-
const shown = openTaskOrder(active).slice(0, SHOWN_OPEN_TASKS)
|
|
172
|
-
const more = moreTasksFrame(key, taskTally(active.phase), open, inner)
|
|
173
|
-
const rest = open ? phaseChildren(inner, key, active.phase, running, new Set(shown)) : []
|
|
184
|
+
const running = runningTasks(active, laneRuns)
|
|
174
185
|
return joinFrames([
|
|
175
186
|
textFrame([phaseHead(active, current, context, model)]),
|
|
176
|
-
indented(joinFrames(
|
|
187
|
+
indented(joinFrames(checklistFrames(inner, key, active, running)), TASK_INDENT),
|
|
177
188
|
])
|
|
178
189
|
}
|
|
179
190
|
|
|
@@ -219,7 +230,7 @@ const finishedText = (summary: LaneSummary, labels: HerdTexts): string => {
|
|
|
219
230
|
|
|
220
231
|
/** A phase that does not run, drawn like a running one: its head, its first open tasks and the line that lists them all. */
|
|
221
232
|
const restingFrame = (context: SummaryContext, { phase, stepIndex }: PlacedPhase): Frame =>
|
|
222
|
-
activeFrame(context, { phase, stepIndex, minutesAgo: null, tasks: [] })
|
|
233
|
+
activeFrame(context, { phase, stepIndex, minutesAgo: null, tasks: [] }, false)
|
|
223
234
|
|
|
224
235
|
/**
|
|
225
236
|
* `▸ <text>`: one line cut at the pane's edge, a row under `key`; no line when the text is empty. Open (`▾`), it
|