@pmelab/gtd 51.0.1 → 52.1.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/.claude-plugin/plugin.json +1 -1
- package/README.md +46 -40
- package/dist/chunks/{reserved-CUkk9XgL.mjs → reserved-DxB5CROj.mjs} +73 -38
- package/dist/chunks/{review-BaCXlIT2.mjs → review-CKMSdvgW.mjs} +11 -9
- package/dist/chunks/{steering-DtUVUz9l.mjs → steering-CLhu8C7q.mjs} +262 -52
- package/dist/chunks/{tail-CX6Pdsql.mjs → tail-QZc7Uz2U.mjs} +1 -1
- package/dist/gtd.bundle.mjs +198 -43
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1 -1
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +510 -147
- package/dist/workflow-kit.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +228 -222
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +570 -317
- package/package.json +5 -4
- package/src/steering/FORMATS.md +4 -1
- package/src/steering/SteeringFormat.ts +7 -1
- package/src/steering/contract.test.ts +2 -1
- package/src/steering/fixtures/review/sectioned.md +31 -0
- package/src/steering/fixtures/review/sectioned.read.json +119 -0
- package/src/steering/index.ts +4 -0
- package/src/steering/qa.ts +1 -1
- package/src/steering/review.test.ts +270 -4
- package/src/steering/review.ts +328 -69
- package/src/testkit/steps.ts +13 -0
- package/src/workflows/feature/acceptance-criteria.feature +159 -3
- package/src/workflows/feature/continuation-cap.feature +4 -0
- package/src/workflows/feature/continuation-rebuild.feature +4 -0
- package/src/workflows/feature/continuation-slices.feature +4 -0
- package/src/workflows/feature/continuation.feature +4 -0
- package/src/workflows/feature/criteria.test.ts +60 -1
- package/src/workflows/feature/criteria.ts +87 -8
- package/src/workflows/feature/fast-path.feature +5 -6
- package/src/workflows/feature/feature.test.ts +6 -5
- package/src/workflows/feature/feature.ts +1 -2
- package/src/workflows/feature/flag-mode.feature +5 -6
- package/src/workflows/feature/green.ts +22 -0
- package/src/workflows/feature/hands-on-skip.feature +384 -0
- package/src/workflows/feature/lens-models.feature +4 -4
- package/src/workflows/feature/lens-review.feature +10 -8
- package/src/workflows/feature/naive-acceptance.feature +4 -0
- package/src/workflows/feature/one-step-land.feature +5 -6
- package/src/workflows/feature/planning.ts +2 -1
- package/src/workflows/feature/prototype-rounds.feature +5 -1
- package/src/workflows/feature/prototype.feature +18 -9
- package/src/workflows/feature/prototype.ts +48 -17
- package/src/workflows/feature/refactor-absorbs.feature +7 -8
- package/src/workflows/feature/refactor-first.feature +7 -8
- package/src/workflows/feature/refactor-lens-models.feature +4 -0
- package/src/workflows/feature/refactor-rebuild.feature +11 -12
- package/src/workflows/feature/refactor-review.feature +72 -72
- package/src/workflows/feature/review-answers.feature +94 -5
- package/src/workflows/feature/review-gate.feature +23 -53
- package/src/workflows/feature/reviewer-walkthrough.feature +33 -12
- package/src/workflows/feature/risk-tier.feature +32 -0
- package/src/workflows/feature/seam-rejection.feature +9 -6
- package/src/workflows/feature/seam.feature +9 -6
- package/src/workflows/feature/seam.test.ts +5 -3
- package/src/workflows/feature/seam.ts +2 -3
- package/src/workflows/feature/severity-budget.feature +11 -7
- package/src/workflows/feature/severity-sorted-review.feature +54 -54
- package/src/workflows/feature/slice-findings.feature +9 -15
- package/src/workflows/feature/slicing.feature +36 -24
- package/src/workflows/feature/standards.feature +4 -4
- package/src/workflows/feature/tier-pin.feature +4 -0
- package/src/workflows/shared/review.ts +26 -6
- package/src/workflows/shared/text.test.ts +85 -11
- package/src/workflows/shared/text.ts +95 -42
- package/src/workflows/feature/coverage.feature +0 -165
- package/src/workflows/feature/coverage.ts +0 -120
package/dist/web/main.js
CHANGED
|
@@ -31565,6 +31565,8 @@ const qaDescriptor = {
|
|
|
31565
31565
|
unansweredQuestions,
|
|
31566
31566
|
openThreads: openThreadsOf
|
|
31567
31567
|
};
|
|
31568
|
+
const ANSWER_FIX = "fix as suggested";
|
|
31569
|
+
const ANSWER_WONT_FIX = "won't fix";
|
|
31568
31570
|
/** A chunk's leading `_critical: security_` paragraph: the tier gtd ranked it with, and why. */
|
|
31569
31571
|
const SEVERITY_LINE_RE = /^_((?:critical|should|could)(?::[^_\n]*)?)_$/;
|
|
31570
31572
|
/**
|
|
@@ -31871,8 +31873,8 @@ const splitSeverity = (content, run) => {
|
|
|
31871
31873
|
};
|
|
31872
31874
|
};
|
|
31873
31875
|
/** Splits one chunk's body nodes into its file pointers (hunk pointers are task items, collected recursively at ANY nesting depth via `taskItems` — a nested hunk is the same kind of hunk as a top-level one) and description prose. The description is the chunk's own leading run and never a node that contains a hunk pointer: it stops at the first node whose `taskItems` include a real pointer (at any depth — a blockquote wrapping a pointer list stops it just as a top-level list would). Every node kind in what's left before that point contributes — a `footnoteDefinition` is the one exception, excluded by `blockNodesOfRun` itself (it's a note, never document content). */
|
|
31874
|
-
const parseChunkBody = (content, lines, title, body) => {
|
|
31875
|
-
const answer = answerListOf(content, body);
|
|
31876
|
+
const parseChunkBody = (content, lines, title, body, answerable) => {
|
|
31877
|
+
const answer = answerable ? answerListOf(content, body) : void 0;
|
|
31876
31878
|
const isAnswer = (n) => answer !== void 0 && (n === answer.list || n === body[answer.lead]);
|
|
31877
31879
|
const firstPointerIndex = body.findIndex((n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)));
|
|
31878
31880
|
const run = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter((n) => !isAnswer(n));
|
|
@@ -31894,45 +31896,78 @@ const parseChunkBody = (content, lines, title, body) => {
|
|
|
31894
31896
|
descriptionInline,
|
|
31895
31897
|
files,
|
|
31896
31898
|
errors,
|
|
31897
|
-
...answer === void 0 ? {} : {
|
|
31899
|
+
...answer === void 0 ? {} : {
|
|
31900
|
+
answers: answer.options,
|
|
31901
|
+
answerLine: toLspPosition(body[answer.lead].position.start).line
|
|
31902
|
+
},
|
|
31898
31903
|
...severity === void 0 ? {} : { severity },
|
|
31899
31904
|
boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0)
|
|
31900
31905
|
};
|
|
31901
31906
|
};
|
|
31902
|
-
/**
|
|
31903
|
-
const
|
|
31904
|
-
|
|
31905
|
-
|
|
31906
|
-
|
|
31907
|
-
|
|
31908
|
-
|
|
31909
|
-
|
|
31910
|
-
|
|
31911
|
-
|
|
31912
|
-
|
|
31913
|
-
|
|
31914
|
-
|
|
31915
|
-
|
|
31916
|
-
|
|
31917
|
-
|
|
31918
|
-
|
|
31919
|
-
|
|
31920
|
-
|
|
31907
|
+
/** The `## ` headings gtd groups the review's chunks under; any other `## ` is a chunk of its own. */
|
|
31908
|
+
const REVIEW_SECTIONS = {
|
|
31909
|
+
findings: "Findings",
|
|
31910
|
+
walkthrough: "Walkthrough"
|
|
31911
|
+
};
|
|
31912
|
+
const sectionOf = (content, node) => {
|
|
31913
|
+
if (node.type !== "heading" || node.depth !== 2) return void 0;
|
|
31914
|
+
const text = headingText$1(content, node);
|
|
31915
|
+
return Object.keys(REVIEW_SECTIONS).find((s) => REVIEW_SECTIONS[s] === text);
|
|
31916
|
+
};
|
|
31917
|
+
/** The nodes after `from` up to the next heading at `depth` or shallower. */
|
|
31918
|
+
const bodyFrom = (children, from, depth) => {
|
|
31919
|
+
const end = children.findIndex((n, i) => i >= from && n.type === "heading" && n.depth <= depth);
|
|
31920
|
+
return children.slice(from, end === -1 ? void 0 : end);
|
|
31921
|
+
};
|
|
31922
|
+
/**
|
|
31923
|
+
* Splits the tree's top-level nodes into chunk blocks — a `## ` line found only
|
|
31924
|
+
* inside a fence never becomes a heading node at all. A document with a
|
|
31925
|
+
* `## Findings` or `## Walkthrough` heading nests `### ` chunks under them;
|
|
31926
|
+
* one without is the flat layout, every `## ` a chunk.
|
|
31927
|
+
*/
|
|
31928
|
+
const splitChunks = (tree, content) => {
|
|
31929
|
+
const sectioned = tree.children.some((n) => sectionOf(content, n) !== void 0);
|
|
31930
|
+
const chunks = [];
|
|
31931
|
+
const sections = [];
|
|
31932
|
+
let section;
|
|
31933
|
+
tree.children.forEach((node, i) => {
|
|
31934
|
+
if (node.type !== "heading") return;
|
|
31935
|
+
const headingLine = toLspPosition(node.position.start).line;
|
|
31936
|
+
if (sectioned && node.depth <= 2) {
|
|
31937
|
+
const s = sectionOf(content, node);
|
|
31938
|
+
section = s === void 0 ? void 0 : {
|
|
31939
|
+
heading: node,
|
|
31940
|
+
headingLine,
|
|
31941
|
+
section: s,
|
|
31942
|
+
body: []
|
|
31943
|
+
};
|
|
31944
|
+
if (section !== void 0) {
|
|
31945
|
+
sections.push(section);
|
|
31946
|
+
return;
|
|
31947
|
+
}
|
|
31921
31948
|
}
|
|
31922
|
-
|
|
31923
|
-
|
|
31949
|
+
const depth = section === void 0 ? 2 : 3;
|
|
31950
|
+
if (node.depth !== depth) return;
|
|
31951
|
+
const body = bodyFrom(tree.children, i + 1, depth);
|
|
31952
|
+
chunks.push({
|
|
31953
|
+
heading: node,
|
|
31924
31954
|
headingLine,
|
|
31925
|
-
body
|
|
31955
|
+
body,
|
|
31956
|
+
...section && { section: section.section }
|
|
31926
31957
|
});
|
|
31927
|
-
|
|
31928
|
-
|
|
31958
|
+
section?.body.push(node, ...body);
|
|
31959
|
+
});
|
|
31960
|
+
return {
|
|
31961
|
+
chunks,
|
|
31962
|
+
sections
|
|
31963
|
+
};
|
|
31929
31964
|
};
|
|
31930
31965
|
const parseChangesets = (tree, content, lines) => {
|
|
31931
31966
|
const changesets = [];
|
|
31932
31967
|
const errors = [];
|
|
31933
|
-
for (const { heading, headingLine, body } of splitChunks(tree)) {
|
|
31968
|
+
for (const { heading, headingLine, body, section } of splitChunks(tree, content).chunks) {
|
|
31934
31969
|
const title = headingText$1(content, heading);
|
|
31935
|
-
const { description, descriptionNodes, descriptionInline, files, answers, severity, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body);
|
|
31970
|
+
const { description, descriptionNodes, descriptionInline, files, answers, answerLine, severity, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body, section !== "walkthrough");
|
|
31936
31971
|
errors.push(...bodyErrors);
|
|
31937
31972
|
if (files.length === 0 && boxed) errors.push({
|
|
31938
31973
|
message: `Chunk "${title}" has no file pointers`,
|
|
@@ -31946,8 +31981,12 @@ const parseChangesets = (tree, content, lines) => {
|
|
|
31946
31981
|
descriptionInline,
|
|
31947
31982
|
files,
|
|
31948
31983
|
headingLine,
|
|
31949
|
-
...answers === void 0 ? {} : {
|
|
31950
|
-
|
|
31984
|
+
...answers === void 0 ? {} : {
|
|
31985
|
+
answers,
|
|
31986
|
+
answerLine
|
|
31987
|
+
},
|
|
31988
|
+
...severity === void 0 ? {} : { severity },
|
|
31989
|
+
...section === void 0 ? {} : { section }
|
|
31951
31990
|
});
|
|
31952
31991
|
}
|
|
31953
31992
|
if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" });
|
|
@@ -32029,7 +32068,7 @@ const isHunkPointerItem = (content, item) => {
|
|
|
32029
32068
|
*/
|
|
32030
32069
|
const reviewClearTicks = (content) => {
|
|
32031
32070
|
const tree = parseMarkdown(content);
|
|
32032
|
-
const answers = new Set(splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []));
|
|
32071
|
+
const answers = new Set(splitChunks(tree, content).chunks.flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []));
|
|
32033
32072
|
const offsets = taskItems(tree).filter((item) => item.checked === true && !answers.has(item) && isHunkPointerItem(content, item)).map((item) => checkboxOffset(content, item)).filter((o) => o !== void 0).sort((a, b) => a - b);
|
|
32034
32073
|
if (offsets.length === 0) return content;
|
|
32035
32074
|
let result = "";
|
|
@@ -32066,42 +32105,80 @@ const toggleFilePointer = (content, line) => {
|
|
|
32066
32105
|
const chunkEndLines = (content) => {
|
|
32067
32106
|
const tree = parseMarkdown(content);
|
|
32068
32107
|
const map = /* @__PURE__ */ new Map();
|
|
32069
|
-
for (const block of splitChunks(tree)) map.set(block.headingLine, blockEndLine(block));
|
|
32108
|
+
for (const block of splitChunks(tree, content).chunks) map.set(block.headingLine, blockEndLine(block));
|
|
32070
32109
|
return map;
|
|
32071
32110
|
};
|
|
32111
|
+
const CHOICE_NAMES = {
|
|
32112
|
+
fix: ANSWER_FIX,
|
|
32113
|
+
wontfix: ANSWER_WONT_FIX,
|
|
32114
|
+
answer: ""
|
|
32115
|
+
};
|
|
32116
|
+
/** A finding's decision as an outline node of its own; none for a chunk with no answer boxes. */
|
|
32117
|
+
const decisionNodes = (lines, chunk, end) => {
|
|
32118
|
+
if (chunk.answerLine === void 0) return [];
|
|
32119
|
+
const d = decisionOf(chunk);
|
|
32120
|
+
return [{
|
|
32121
|
+
name: `Decision: ${d === void 0 ? "open" : CHOICE_NAMES[d.choice] || d.text}`,
|
|
32122
|
+
range: spanRange(lines, chunk.answerLine, Math.max(chunk.answerLine, end)),
|
|
32123
|
+
selectionRange: lineRange(lines, chunk.answerLine)
|
|
32124
|
+
}];
|
|
32125
|
+
};
|
|
32126
|
+
const isOpenChunk = (chunk, markers) => chunk.files.some((file) => !file.checked) || markers > 0 || chunk.answers !== void 0 && decisionOf(chunk) === void 0;
|
|
32072
32127
|
/**
|
|
32073
|
-
* Document outline for `.gtd/REVIEW.md`: the
|
|
32074
|
-
*
|
|
32075
|
-
*
|
|
32076
|
-
*
|
|
32077
|
-
*
|
|
32078
|
-
* human's own comment vanish the moment they tick the last box. A chunk's
|
|
32079
|
-
* footnotes are `leaf: true` children; a chunk with none has no children.
|
|
32128
|
+
* Document outline for `.gtd/REVIEW.md`: the chunks still left to review — an
|
|
32129
|
+
* unchecked hunk, a footnote (so a human's comment never vanishes with the
|
|
32130
|
+
* last tick) or an open decision. A finding's decision is a node of its own.
|
|
32131
|
+
* In the sectioned layout `Findings` and `Walkthrough` are containers, and
|
|
32132
|
+
* every other `## ` (`Fixed automatically`, …) stays listed.
|
|
32080
32133
|
*/
|
|
32081
32134
|
const reviewOutline = (content) => {
|
|
32082
32135
|
const { changesets } = parseReviewDoc(content);
|
|
32136
|
+
const { sections } = splitChunks(parseMarkdown(content), content);
|
|
32083
32137
|
const { markers, definitions } = parseFootnotes(content);
|
|
32084
32138
|
const definitionByName = new Map(definitions.map((d) => [d.name, d.body]));
|
|
32085
32139
|
const lines = content.split(/\r?\n/);
|
|
32086
32140
|
const endLines = chunkEndLines(content);
|
|
32087
32141
|
const threads = threadOutlineNodes(content);
|
|
32088
32142
|
const nested = /* @__PURE__ */ new Set();
|
|
32089
|
-
|
|
32143
|
+
const chunkNodes = changesets.flatMap((chunk) => {
|
|
32090
32144
|
const start = chunk.headingLine;
|
|
32091
32145
|
const end = Math.max(start, endLines.get(start) ?? start);
|
|
32092
32146
|
const checkedCount = chunk.files.filter((file) => file.checked).length;
|
|
32093
32147
|
const chunkMarkers = markers.filter((m) => m.line >= start && m.line <= end);
|
|
32094
32148
|
const chunkThreads = threads.filter((t) => t.markerLine !== void 0 && t.markerLine >= start && t.markerLine <= end).map((t) => t.node);
|
|
32095
32149
|
chunkThreads.forEach((n) => nested.add(n));
|
|
32096
|
-
const children = [
|
|
32097
|
-
|
|
32098
|
-
|
|
32150
|
+
const children = [
|
|
32151
|
+
...decisionNodes(lines, chunk, end),
|
|
32152
|
+
...chunkMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)),
|
|
32153
|
+
...chunkThreads
|
|
32154
|
+
];
|
|
32155
|
+
if (!(sections.length > 0 && chunk.section === void 0) && !isOpenChunk(chunk, chunkMarkers.length)) return [];
|
|
32156
|
+
const node = {
|
|
32157
|
+
name: chunk.files.length === 0 ? chunk.title : `${chunk.title} (${checkedCount}/${chunk.files.length})`,
|
|
32099
32158
|
range: spanRange(lines, start, end),
|
|
32100
32159
|
selectionRange: lineRange(lines, start),
|
|
32101
|
-
...children.length > 0 ? { children } : {}
|
|
32102
|
-
unchecked: checkedCount < chunk.files.length || chunkMarkers.length > 0
|
|
32160
|
+
...children.length > 0 ? { children } : {}
|
|
32103
32161
|
};
|
|
32104
|
-
|
|
32162
|
+
return [{
|
|
32163
|
+
line: start,
|
|
32164
|
+
section: chunk.section,
|
|
32165
|
+
node
|
|
32166
|
+
}];
|
|
32167
|
+
});
|
|
32168
|
+
const containers = sections.map((section) => ({
|
|
32169
|
+
line: section.headingLine,
|
|
32170
|
+
section: void 0,
|
|
32171
|
+
node: {
|
|
32172
|
+
name: REVIEW_SECTIONS[section.section],
|
|
32173
|
+
range: spanRange(lines, section.headingLine, blockEndLine(section)),
|
|
32174
|
+
selectionRange: lineRange(lines, section.headingLine),
|
|
32175
|
+
children: chunkNodes.filter((c) => c.section === section.section && c.line > section.headingLine).filter((c) => {
|
|
32176
|
+
const next = sections.find((o) => o.headingLine > section.headingLine);
|
|
32177
|
+
return next === void 0 || c.line < next.headingLine;
|
|
32178
|
+
}).map((c) => c.node)
|
|
32179
|
+
}
|
|
32180
|
+
}));
|
|
32181
|
+
return [...[...chunkNodes.filter((c) => c.section === void 0), ...containers].sort((a, b) => a.line - b.line).map((c) => c.node), ...threads.map((t) => t.node).filter((n) => !nested.has(n))];
|
|
32105
32182
|
};
|
|
32106
32183
|
/**
|
|
32107
32184
|
* The target state a whole-chunk toggle drives every hunk to: `true` (check
|
|
@@ -32153,6 +32230,7 @@ const reviewActions = (content, range) => {
|
|
|
32153
32230
|
const tree = parseMarkdown(content);
|
|
32154
32231
|
const lines = content.split(/\r?\n/);
|
|
32155
32232
|
const cursorLine = range.start.line;
|
|
32233
|
+
const endLines = chunkEndLines(content);
|
|
32156
32234
|
const actions = [...threadReplyActions(content, range.start)];
|
|
32157
32235
|
changesets.forEach((chunk, i) => {
|
|
32158
32236
|
const hunk = chunk.files.find((file) => cursorLine >= file.sourceLine && cursorLine <= file.endLine);
|
|
@@ -32163,7 +32241,7 @@ const reviewActions = (content, range) => {
|
|
|
32163
32241
|
edits: [edit]
|
|
32164
32242
|
});
|
|
32165
32243
|
}
|
|
32166
|
-
const chunkEnd = Math.max(chunk.headingLine, (changesets[i + 1]?.headingLine ?? lines.length) - 1);
|
|
32244
|
+
const chunkEnd = chunk.section === void 0 ? Math.max(chunk.headingLine, (changesets[i + 1]?.headingLine ?? lines.length) - 1) : endLines.get(chunk.headingLine) ?? chunk.headingLine;
|
|
32167
32245
|
if (chunk.files.length > 0 && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) {
|
|
32168
32246
|
const edits = toggleChunkEdits(content, chunk.headingLine);
|
|
32169
32247
|
if (edits.length > 0) {
|
|
@@ -32231,7 +32309,7 @@ const hunkLinkFor = (content, item) => {
|
|
|
32231
32309
|
const reviewDocumentLinks = (content) => {
|
|
32232
32310
|
const tree = parseMarkdown(content);
|
|
32233
32311
|
const links = [];
|
|
32234
|
-
for (const block of splitChunks(tree)) for (const item of block.body.flatMap((n) => taskItems(n))) {
|
|
32312
|
+
for (const block of splitChunks(tree, content).chunks) for (const item of block.body.flatMap((n) => taskItems(n))) {
|
|
32235
32313
|
const link = hunkLinkFor(content, item);
|
|
32236
32314
|
if (link) links.push(link);
|
|
32237
32315
|
}
|
|
@@ -32253,6 +32331,19 @@ const reviewView = (content) => {
|
|
|
32253
32331
|
kind: "chunk",
|
|
32254
32332
|
index: chunkIndex
|
|
32255
32333
|
},
|
|
32334
|
+
...chunk.section === void 0 ? {} : { group: REVIEW_SECTIONS[chunk.section] },
|
|
32335
|
+
...chunk.answers === void 0 ? {} : {
|
|
32336
|
+
answered: decisionOf(chunk) !== void 0,
|
|
32337
|
+
decision: chunk.answers.map((option, index) => ({
|
|
32338
|
+
title: option.text,
|
|
32339
|
+
checked: option.checked,
|
|
32340
|
+
anchor: {
|
|
32341
|
+
kind: "option",
|
|
32342
|
+
questionIndex: chunkIndex,
|
|
32343
|
+
index
|
|
32344
|
+
}
|
|
32345
|
+
}))
|
|
32346
|
+
},
|
|
32256
32347
|
...noteAt(chunk.headingLine),
|
|
32257
32348
|
children: chunk.files.map((file, index) => ({
|
|
32258
32349
|
title: pointerDisplay(file),
|
|
@@ -32350,8 +32441,66 @@ const setFileTickEdits = (content, files, checked) => {
|
|
|
32350
32441
|
}
|
|
32351
32442
|
return edits;
|
|
32352
32443
|
};
|
|
32353
|
-
/**
|
|
32444
|
+
/**
|
|
32445
|
+
* A finding's answer boxes are radio: ticking one unticks the others, and
|
|
32446
|
+
* `text` replaces the ticked box's label in the same edit set — the phone's
|
|
32447
|
+
* free-text answer, normalized as `qa`'s is.
|
|
32448
|
+
*/
|
|
32449
|
+
const decisionEdits = (content, chunkIndex, index, opts) => {
|
|
32450
|
+
const items = answerItemsOf(content, chunkIndex);
|
|
32451
|
+
const target = items[index];
|
|
32452
|
+
if (target === void 0) return {
|
|
32453
|
+
ok: false,
|
|
32454
|
+
reason: "anchor-not-found"
|
|
32455
|
+
};
|
|
32456
|
+
const checked = opts.checked ?? true;
|
|
32457
|
+
const ticks = items.flatMap((item) => {
|
|
32458
|
+
const want = item === target ? checked : !checked && item.checked === true;
|
|
32459
|
+
return item.checked === true === want ? [] : tickEdit(content, item, want);
|
|
32460
|
+
});
|
|
32461
|
+
const text = opts.text === void 0 ? [] : labelEdit(content, target, opts.text);
|
|
32462
|
+
return {
|
|
32463
|
+
ok: true,
|
|
32464
|
+
edits: [...ticks, ...text]
|
|
32465
|
+
};
|
|
32466
|
+
};
|
|
32467
|
+
/** A finding's answer boxes; none for a walkthrough chunk or a stale index. */
|
|
32468
|
+
const answerItemsOf = (content, chunkIndex) => {
|
|
32469
|
+
const chunk = splitChunks(parseMarkdown(content), content).chunks[chunkIndex];
|
|
32470
|
+
if (chunk === void 0 || chunk.section === "walkthrough") return [];
|
|
32471
|
+
return answerListOf(content, chunk.body)?.list.children ?? [];
|
|
32472
|
+
};
|
|
32473
|
+
const tickEdit = (content, item, checked) => {
|
|
32474
|
+
const offset = checkboxOffset(content, item);
|
|
32475
|
+
if (offset === void 0) return [];
|
|
32476
|
+
const start = toLspPositionFromOffset(content, offset);
|
|
32477
|
+
return [{
|
|
32478
|
+
range: {
|
|
32479
|
+
start,
|
|
32480
|
+
end: {
|
|
32481
|
+
...start,
|
|
32482
|
+
character: start.character + 1
|
|
32483
|
+
}
|
|
32484
|
+
},
|
|
32485
|
+
newText: checked ? "x" : " "
|
|
32486
|
+
}];
|
|
32487
|
+
};
|
|
32488
|
+
/** Replaces a box's label, from after its marker to the end of its first paragraph. */
|
|
32489
|
+
const labelEdit = (content, item, text) => {
|
|
32490
|
+
const from = firstParagraphContentOffset(item);
|
|
32491
|
+
const to = item.children.find((c) => c.type === "paragraph")?.position?.end.offset;
|
|
32492
|
+
if (from === void 0 || to === void 0) return [];
|
|
32493
|
+
return [{
|
|
32494
|
+
range: {
|
|
32495
|
+
start: toLspPositionFromOffset(content, from),
|
|
32496
|
+
end: toLspPositionFromOffset(content, to)
|
|
32497
|
+
},
|
|
32498
|
+
newText: normalizeFreeTextAnswer(text)
|
|
32499
|
+
}];
|
|
32500
|
+
};
|
|
32501
|
+
/** A `hunk` anchor sets one tick; a `chunk` anchor sets every hunk beneath it to the SAME target state — never `toggleChunkEdits`'s majority-flip heuristic, since the caller already knows the state it wants. An `option` anchor is a finding's decision. */
|
|
32354
32502
|
const reviewApply = (content, anchor, opts) => {
|
|
32503
|
+
if (anchor.kind === "option") return decisionEdits(content, anchor.questionIndex, anchor.index, opts);
|
|
32355
32504
|
const { changesets } = parseReviewDoc(content);
|
|
32356
32505
|
const checked = opts.checked ?? true;
|
|
32357
32506
|
if (anchor.kind === "hunk") {
|
|
@@ -32381,10 +32530,8 @@ const reviewApply = (content, anchor, opts) => {
|
|
|
32381
32530
|
reason: "anchor-not-found"
|
|
32382
32531
|
};
|
|
32383
32532
|
};
|
|
32384
|
-
|
|
32385
|
-
|
|
32386
|
-
/** The built-in registry: mode name → its format, in registry order. The single place `qa` and `review` are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
|
|
32387
|
-
const REGISTRY = /* @__PURE__ */ new Map([["qa", qaDescriptor], ["review", {
|
|
32533
|
+
/** The `review` format: `.gtd/REVIEW.md`'s in-process format — validation, outline, code actions, footnote + hunk `pointerAt`/`documentLinks`, and a hunk-pointer-scoped `clearTicks` (never "every checked task item" — see `reviewClearTicks`). */
|
|
32534
|
+
const reviewDescriptor = {
|
|
32388
32535
|
sample: REVIEW_SAMPLE,
|
|
32389
32536
|
validate: (content) => [...parseReviewDoc(content).findings, ...parseFootnotes(content).findings],
|
|
32390
32537
|
outline: reviewOutline,
|
|
@@ -32396,7 +32543,30 @@ const REGISTRY = /* @__PURE__ */ new Map([["qa", qaDescriptor], ["review", {
|
|
|
32396
32543
|
apply: reviewApply,
|
|
32397
32544
|
clearTicks: reviewClearTicks,
|
|
32398
32545
|
openThreads: openThreadsOf
|
|
32399
|
-
}
|
|
32546
|
+
};
|
|
32547
|
+
const CHOICES = [
|
|
32548
|
+
"fix",
|
|
32549
|
+
"wontfix",
|
|
32550
|
+
"answer"
|
|
32551
|
+
];
|
|
32552
|
+
const decisionOf = (chunk) => {
|
|
32553
|
+
if (chunk.answers?.length !== 3) return void 0;
|
|
32554
|
+
const ticked = chunk.answers.flatMap((o, i) => o.checked ? [i] : []);
|
|
32555
|
+
if (ticked.length !== 1) return void 0;
|
|
32556
|
+
const i = ticked[0];
|
|
32557
|
+
const text = i === 2 ? chunk.answers[2].text : "";
|
|
32558
|
+
if (i === 2 && text === "") return void 0;
|
|
32559
|
+
return {
|
|
32560
|
+
anchor: chunk.title,
|
|
32561
|
+
body: chunk.description,
|
|
32562
|
+
choice: CHOICES[i],
|
|
32563
|
+
text
|
|
32564
|
+
};
|
|
32565
|
+
};
|
|
32566
|
+
//#endregion
|
|
32567
|
+
//#region src/steering/index.ts
|
|
32568
|
+
/** The built-in registry: mode name → its format, in registry order. The single place `qa` and `review` are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
|
|
32569
|
+
const REGISTRY = /* @__PURE__ */ new Map([["qa", qaDescriptor], ["review", reviewDescriptor]]);
|
|
32400
32570
|
[...REGISTRY.keys()];
|
|
32401
32571
|
/** The built-in `SteeringFormat` registered under `mode`'s name, or `undefined` when it isn't a built-in mode at all. The one place a bare mode-name string is ever looked up — every consumer takes the RESOLVED value this returns, never a name, so "mode is required, never inferred from a file's basename" is a type, not a rule to remember. */
|
|
32402
32572
|
const steeringFormatFor = (mode) => REGISTRY.get(mode);
|
|
@@ -33836,7 +34006,7 @@ const useScrollRestoration = () => {
|
|
|
33836
34006
|
//#endregion
|
|
33837
34007
|
//#region src/web/screens/Question.tsx
|
|
33838
34008
|
/** `""` for an untouched/placeholder-only answer (case-insensitive) — the SAME sentinel and the SAME normalization the completeness gate and the open-questions check both apply server-side (`OpenQuestions.ts#FREE_TEXT_PLACEHOLDER`), redone here so the client never has to round-trip through a write to know if it's answered. */
|
|
33839
|
-
const normalizeAnswerText = (text) => {
|
|
34009
|
+
const normalizeAnswerText$1 = (text) => {
|
|
33840
34010
|
const trimmed = text.trim();
|
|
33841
34011
|
return trimmed.toLowerCase() === "_your answer_".toLowerCase() ? "" : trimmed;
|
|
33842
34012
|
};
|
|
@@ -33946,7 +34116,7 @@ const Question = ({ node, filePath, onCommitAnswer, noteOverrides, onOpenNote })
|
|
|
33946
34116
|
const openAnswerSheet = () => setAnswerSheetOpen(true);
|
|
33947
34117
|
/** Fired only by a deliberate Save tap — both fields go in one write, one cell. */
|
|
33948
34118
|
const commitFreeText = (text) => {
|
|
33949
|
-
const current = normalizeAnswerText(text);
|
|
34119
|
+
const current = normalizeAnswerText$1(text);
|
|
33950
34120
|
const anchor = freeTextAnchor();
|
|
33951
34121
|
if (anchor === void 0) return;
|
|
33952
34122
|
const checked = current.length > 0;
|
|
@@ -33967,7 +34137,7 @@ const Question = ({ node, filePath, onCommitAnswer, noteOverrides, onOpenNote })
|
|
|
33967
34137
|
};
|
|
33968
34138
|
const answered = isAnswered(options.map((option, index) => ({
|
|
33969
34139
|
checked: selected === index,
|
|
33970
|
-
text: index === lastIndex ? normalizeAnswerText(freeText) : option.title,
|
|
34140
|
+
text: index === lastIndex ? normalizeAnswerText$1(freeText) : option.title,
|
|
33971
34141
|
freeText: index === lastIndex
|
|
33972
34142
|
})));
|
|
33973
34143
|
const freeTextOptionAnchor = freeTextAnchor();
|
|
@@ -34612,6 +34782,19 @@ const Hunk = ({ node, diff, index, total, checked, hasNote, note, thread, onTogg
|
|
|
34612
34782
|
//#region src/web/screens/Review.tsx
|
|
34613
34783
|
/** Every hunk-anchored descendant of a chunk node, at any depth. */
|
|
34614
34784
|
const hunksOf = (node) => (node.children ?? []).flatMap((child) => [...child.anchor.kind === "hunk" ? [child] : [], ...hunksOf(child)]);
|
|
34785
|
+
/** The free-text slot's index among a finding's three boxes. */
|
|
34786
|
+
const ANSWER_SLOT = 2;
|
|
34787
|
+
const defaultDecisionFor = ({ decision = [] }) => {
|
|
34788
|
+
const ticked = decision.flatMap((option, i) => option.checked === true ? [i] : []);
|
|
34789
|
+
return {
|
|
34790
|
+
selected: ticked.length === 1 ? ticked[0] : void 0,
|
|
34791
|
+
freeText: decision[ANSWER_SLOT]?.title ?? ""
|
|
34792
|
+
};
|
|
34793
|
+
};
|
|
34794
|
+
const normalizeAnswerText = (text) => {
|
|
34795
|
+
const trimmed = text.trim();
|
|
34796
|
+
return trimmed.toLowerCase() === "_your answer_".toLowerCase() ? "" : trimmed;
|
|
34797
|
+
};
|
|
34615
34798
|
/**
|
|
34616
34799
|
* All of `ReviewView`'s local/optimistic UI state plus the derived helpers
|
|
34617
34800
|
* every branch below needs. `ticked`/notes read straight off the write
|
|
@@ -34619,17 +34802,56 @@ const hunksOf = (node) => (node.children ?? []).flatMap((child) => [...child.anc
|
|
|
34619
34802
|
* falls back to the node's own last-server-confirmed value automatically —
|
|
34620
34803
|
* no local `useState`, no per-hunk seq guard of its own.
|
|
34621
34804
|
*/
|
|
34622
|
-
const useReviewState = (filePath, scrollRef, onSaveNote, onDoneNote, onSetValue) => {
|
|
34805
|
+
const useReviewState = (filePath, scrollRef, onSaveNote, onDoneNote, onSetValue, onCommitDecision) => {
|
|
34623
34806
|
const overlay = useWriteStore((s) => s.overlay);
|
|
34624
34807
|
const save = useWriteStore((s) => s.save);
|
|
34625
34808
|
const shadow = useWriteStore((s) => s.shadow);
|
|
34626
34809
|
const [openChunkIndex, setOpenChunkIndex] = (0, import_react.useState)(void 0);
|
|
34627
34810
|
const [deckIndex, setDeckIndex] = (0, import_react.useState)(0);
|
|
34628
34811
|
const [noteSheet, setNoteSheet] = (0, import_react.useState)(void 0);
|
|
34812
|
+
const [answerSheet, setAnswerSheet] = (0, import_react.useState)(void 0);
|
|
34629
34813
|
const scroll = useScrollRestoration();
|
|
34630
34814
|
const checkedCell = (anchor) => cellKey(filePath, anchor, "checked");
|
|
34631
34815
|
const noteCell = (anchor) => cellKey(filePath, anchor, "note");
|
|
34632
34816
|
const doneCell = (anchor) => cellKey(filePath, anchor, "done");
|
|
34817
|
+
const decisionCell = (chunk) => cellKey(filePath, chunk.anchor, "decision");
|
|
34818
|
+
const decisionOf = (chunk) => overlay[decisionCell(chunk)]?.value ?? defaultDecisionFor(chunk);
|
|
34819
|
+
/** The same rule the landing's `answer-completeness` check applies: three boxes, one ticked, a ticked answer filled in. */
|
|
34820
|
+
const isDecided = (chunk) => {
|
|
34821
|
+
const options = chunk.decision ?? [];
|
|
34822
|
+
const { selected, freeText } = decisionOf(chunk);
|
|
34823
|
+
return options.length === 3 && isAnswered(options.map((option, i) => ({
|
|
34824
|
+
checked: selected === i,
|
|
34825
|
+
text: i === ANSWER_SLOT ? normalizeAnswerText(freeText) : option.title,
|
|
34826
|
+
freeText: i === ANSWER_SLOT
|
|
34827
|
+
})));
|
|
34828
|
+
};
|
|
34829
|
+
const writeDecision = (chunk, index, optimistic, opts) => {
|
|
34830
|
+
const anchor = chunk.decision?.[index]?.anchor;
|
|
34831
|
+
if (anchor === void 0) return;
|
|
34832
|
+
save({
|
|
34833
|
+
cell: decisionCell(chunk),
|
|
34834
|
+
optimistic,
|
|
34835
|
+
write: onCommitDecision === void 0 ? void 0 : onCommitDecision(anchor, opts)
|
|
34836
|
+
}).catch(() => {});
|
|
34837
|
+
};
|
|
34838
|
+
const chooseDecision = (chunk, index) => writeDecision(chunk, index, {
|
|
34839
|
+
...decisionOf(chunk),
|
|
34840
|
+
selected: index
|
|
34841
|
+
}, { checked: true });
|
|
34842
|
+
/** A Save in the answer sheet: an empty answer unticks the slot rather than ticking a blank one. */
|
|
34843
|
+
const commitAnswer = (chunk, text) => {
|
|
34844
|
+
setAnswerSheet(void 0);
|
|
34845
|
+
const answer = normalizeAnswerText(text);
|
|
34846
|
+
const { selected } = decisionOf(chunk);
|
|
34847
|
+
writeDecision(chunk, ANSWER_SLOT, {
|
|
34848
|
+
selected: answer === "" ? selected === ANSWER_SLOT ? void 0 : selected : ANSWER_SLOT,
|
|
34849
|
+
freeText: answer
|
|
34850
|
+
}, {
|
|
34851
|
+
checked: answer !== "",
|
|
34852
|
+
text: answer
|
|
34853
|
+
});
|
|
34854
|
+
};
|
|
34633
34855
|
const isChecked = (hunk) => overlay[checkedCell(hunk.anchor)]?.value ?? hunk.checked === true;
|
|
34634
34856
|
/** Reads BOTH a plain save's own `"note"` cell and that same anchor's `"done"` cell (a Save & Done) — only one of the two is ever written per interaction, so whichever is newer is what should show. */
|
|
34635
34857
|
const savedNoteOf = (node) => latestOverlayValue(overlay, [noteCell(node.anchor), doneCell(node.anchor)]);
|
|
@@ -34706,7 +34928,13 @@ const useReviewState = (filePath, scrollRef, onSaveNote, onDoneNote, onSetValue)
|
|
|
34706
34928
|
setDeckIndex,
|
|
34707
34929
|
noteSheet,
|
|
34708
34930
|
setNoteSheet,
|
|
34931
|
+
answerSheet,
|
|
34932
|
+
setAnswerSheet,
|
|
34709
34933
|
exitToChunkList,
|
|
34934
|
+
decisionOf,
|
|
34935
|
+
isDecided,
|
|
34936
|
+
chooseDecision,
|
|
34937
|
+
commitAnswer,
|
|
34710
34938
|
isChecked,
|
|
34711
34939
|
hasNoteText,
|
|
34712
34940
|
noteTextOf,
|
|
@@ -34744,7 +34972,7 @@ const hunkPropsFor = (hunk, index, hunks, state) => ({
|
|
|
34744
34972
|
onApprove: () => state.approveAndAdvance(hunks),
|
|
34745
34973
|
onOpenNote: () => state.openNoteSheet(hunk)
|
|
34746
34974
|
});
|
|
34747
|
-
const HunkDeck = ({ chunk, live, state, onDone, busy }) => {
|
|
34975
|
+
const HunkDeck = ({ chunk, live, state, onDone, busy, undecided }) => {
|
|
34748
34976
|
const hunks = hunksOf(chunk);
|
|
34749
34977
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
34750
34978
|
"data-testid": "hunk-deck",
|
|
@@ -34757,7 +34985,7 @@ const HunkDeck = ({ chunk, live, state, onDone, busy }) => {
|
|
|
34757
34985
|
...onDone !== void 0 ? {
|
|
34758
34986
|
onDone,
|
|
34759
34987
|
doneLabel: "Done",
|
|
34760
|
-
doneDisabled: busy
|
|
34988
|
+
doneDisabled: busy === true || undecided > 0
|
|
34761
34989
|
} : {},
|
|
34762
34990
|
renderItem: (hunk, i) => {
|
|
34763
34991
|
const props = hunkPropsFor(hunk, i, hunks, state);
|
|
@@ -34770,85 +34998,184 @@ const HunkDeck = ({ chunk, live, state, onDone, busy }) => {
|
|
|
34770
34998
|
})
|
|
34771
34999
|
});
|
|
34772
35000
|
};
|
|
35001
|
+
const DECISION_LABELS = [
|
|
35002
|
+
"Fix as suggested",
|
|
35003
|
+
"Won't fix",
|
|
35004
|
+
"Your answer"
|
|
35005
|
+
];
|
|
35006
|
+
/** A finding's decision: three radios, the last opening the answer sheet. */
|
|
35007
|
+
const DecisionControl = ({ chunk, chunkIndex, state }) => {
|
|
35008
|
+
const { selected, freeText } = state.decisionOf(chunk);
|
|
35009
|
+
const choose = (i) => i === ANSWER_SLOT ? state.setAnswerSheet(chunk) : state.chooseDecision(chunk, i);
|
|
35010
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("fieldset", {
|
|
35011
|
+
"data-testid": `chunk-decision-${chunkIndex}`,
|
|
35012
|
+
className: "m-0 mt-1 flex flex-col border-0 p-0 pl-13",
|
|
35013
|
+
children: [
|
|
35014
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("legend", {
|
|
35015
|
+
className: "sr-only",
|
|
35016
|
+
children: ["Decision for ", chunk.title]
|
|
35017
|
+
}),
|
|
35018
|
+
(chunk.decision ?? []).map((option, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", {
|
|
35019
|
+
className: "flex min-h-11 items-center gap-3",
|
|
35020
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
|
|
35021
|
+
type: "radio",
|
|
35022
|
+
name: `decision-${chunkIndex}`,
|
|
35023
|
+
"data-testid": `chunk-decision-${chunkIndex}-${i}`,
|
|
35024
|
+
checked: selected === i,
|
|
35025
|
+
onChange: () => choose(i)
|
|
35026
|
+
}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
|
|
35027
|
+
className: selected === i ? "font-medium" : void 0,
|
|
35028
|
+
children: DECISION_LABELS[i] ?? option.title
|
|
35029
|
+
})]
|
|
35030
|
+
}, i)),
|
|
35031
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
|
|
35032
|
+
variant: "ghost",
|
|
35033
|
+
"data-testid": `chunk-decision-answer-${chunkIndex}`,
|
|
35034
|
+
onClick: () => state.setAnswerSheet(chunk),
|
|
35035
|
+
className: "w-full px-0 pl-7 text-left text-small font-normal text-muted",
|
|
35036
|
+
children: freeText.trim().length > 0 ? freeText : "Tap to write your answer"
|
|
35037
|
+
})
|
|
35038
|
+
]
|
|
35039
|
+
});
|
|
35040
|
+
};
|
|
35041
|
+
/** A chunk with neither hunks nor a decision (`Fixed automatically`, …) is a note to read, not an item to tick. */
|
|
35042
|
+
const isPlainNote = (chunk) => chunk.decision === void 0 && hunksOf(chunk).length === 0;
|
|
35043
|
+
const ChunkSummary = ({ chunk, chunkIndex }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
35044
|
+
chunk.status !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
35045
|
+
"data-testid": `chunk-severity-${chunkIndex}`,
|
|
35046
|
+
className: cn("text-small font-semibold", chunk.status.startsWith("critical") ? "text-warning" : "text-muted"),
|
|
35047
|
+
children: chunk.status
|
|
35048
|
+
}),
|
|
35049
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
35050
|
+
className: "font-semibold",
|
|
35051
|
+
children: chunk.title
|
|
35052
|
+
}),
|
|
35053
|
+
chunk.detail !== void 0 && chunk.detail.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
35054
|
+
className: "text-small text-muted",
|
|
35055
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Inline, {
|
|
35056
|
+
inline: chunk.detailInline,
|
|
35057
|
+
fallback: chunk.detail
|
|
35058
|
+
})
|
|
35059
|
+
})
|
|
35060
|
+
] });
|
|
35061
|
+
/** A finding's done mark, a walkthrough chunk's tick-all box, nothing for a plain note. */
|
|
35062
|
+
const ChunkMark = ({ chunk, chunkIndex, state }) => {
|
|
35063
|
+
const hunks = hunksOf(chunk);
|
|
35064
|
+
if (chunk.decision !== void 0) {
|
|
35065
|
+
const decided = state.isDecided(chunk);
|
|
35066
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
35067
|
+
"data-testid": `chunk-decision-status-${chunkIndex}`,
|
|
35068
|
+
"aria-label": decided ? "Decided" : "Needs a decision",
|
|
35069
|
+
className: cn("flex min-h-11 min-w-11 shrink-0 items-center justify-center font-semibold", decided ? "text-heading-c" : "text-warning"),
|
|
35070
|
+
children: decided ? "✓" : "●"
|
|
35071
|
+
});
|
|
35072
|
+
}
|
|
35073
|
+
if (hunks.length === 0) return null;
|
|
35074
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", {
|
|
35075
|
+
"data-testid": `chunk-check-all-label-${chunkIndex}`,
|
|
35076
|
+
className: "flex min-h-11 min-w-11 shrink-0 items-center justify-center",
|
|
35077
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
|
|
35078
|
+
type: "checkbox",
|
|
35079
|
+
"data-testid": `chunk-check-all-${chunkIndex}`,
|
|
35080
|
+
checked: hunks.every(state.isChecked),
|
|
35081
|
+
onChange: () => state.toggleChunk(chunk)
|
|
35082
|
+
})
|
|
35083
|
+
});
|
|
35084
|
+
};
|
|
34773
35085
|
const ChunkRow = ({ chunk, chunkIndex, state }) => {
|
|
34774
35086
|
const hunks = hunksOf(chunk);
|
|
34775
|
-
const allChecked = hunks.length > 0 && hunks.every(state.isChecked);
|
|
34776
35087
|
const footnoteKeepsRoundOpen = state.hasNoteText(chunk);
|
|
34777
35088
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
34778
35089
|
"data-testid": `chunk-card-${chunkIndex}`,
|
|
34779
35090
|
className: "border-b border-divider px-3 py-2.5",
|
|
34780
|
-
children: [
|
|
34781
|
-
|
|
34782
|
-
|
|
34783
|
-
|
|
34784
|
-
|
|
34785
|
-
|
|
34786
|
-
|
|
34787
|
-
|
|
34788
|
-
|
|
34789
|
-
|
|
35091
|
+
children: [
|
|
35092
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
35093
|
+
className: "flex items-start gap-2",
|
|
35094
|
+
children: [
|
|
35095
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChunkMark, {
|
|
35096
|
+
chunk,
|
|
35097
|
+
chunkIndex,
|
|
35098
|
+
state
|
|
35099
|
+
}),
|
|
35100
|
+
isPlainNote(chunk) ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
35101
|
+
"data-testid": `chunk-plain-note-${chunkIndex}`,
|
|
35102
|
+
className: "flex-1 py-1",
|
|
35103
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChunkSummary, {
|
|
35104
|
+
chunk,
|
|
35105
|
+
chunkIndex
|
|
35106
|
+
})
|
|
35107
|
+
}) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button, {
|
|
35108
|
+
variant: "ghost",
|
|
35109
|
+
"data-testid": `chunk-open-${chunkIndex}`,
|
|
35110
|
+
onClick: () => state.openChunk(chunkIndex),
|
|
34790
35111
|
disabled: hunks.length === 0,
|
|
34791
|
-
|
|
34792
|
-
|
|
34793
|
-
|
|
34794
|
-
|
|
34795
|
-
|
|
34796
|
-
|
|
34797
|
-
|
|
34798
|
-
|
|
34799
|
-
|
|
34800
|
-
|
|
34801
|
-
|
|
34802
|
-
|
|
34803
|
-
|
|
34804
|
-
|
|
34805
|
-
}),
|
|
34806
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
34807
|
-
className: "font-semibold",
|
|
34808
|
-
children: chunk.title
|
|
34809
|
-
}),
|
|
34810
|
-
chunk.detail !== void 0 && chunk.detail.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
34811
|
-
className: "text-small text-muted",
|
|
34812
|
-
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Inline, {
|
|
34813
|
-
inline: chunk.detailInline,
|
|
34814
|
-
fallback: chunk.detail
|
|
35112
|
+
className: "flex-1 text-left",
|
|
35113
|
+
children: [
|
|
35114
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChunkSummary, {
|
|
35115
|
+
chunk,
|
|
35116
|
+
chunkIndex
|
|
35117
|
+
}),
|
|
35118
|
+
hunks.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
35119
|
+
className: "mt-0.5 text-small text-muted",
|
|
35120
|
+
children: "No file pointers"
|
|
35121
|
+
}),
|
|
35122
|
+
footnoteKeepsRoundOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
35123
|
+
"data-testid": `chunk-footnote-badge-${chunkIndex}`,
|
|
35124
|
+
className: "mt-1 text-small text-warning",
|
|
35125
|
+
children: "Note keeps this round open"
|
|
34815
35126
|
})
|
|
34816
|
-
|
|
34817
|
-
|
|
34818
|
-
|
|
34819
|
-
|
|
34820
|
-
}
|
|
34821
|
-
|
|
34822
|
-
|
|
34823
|
-
|
|
34824
|
-
|
|
34825
|
-
|
|
34826
|
-
|
|
34827
|
-
|
|
34828
|
-
|
|
34829
|
-
|
|
34830
|
-
|
|
34831
|
-
|
|
34832
|
-
|
|
34833
|
-
}
|
|
34834
|
-
|
|
34835
|
-
|
|
34836
|
-
|
|
34837
|
-
|
|
34838
|
-
|
|
34839
|
-
|
|
34840
|
-
|
|
34841
|
-
|
|
34842
|
-
testId: `chunk-thread-${chunkIndex}`
|
|
34843
|
-
}) : state.noteTextOf(chunk)
|
|
34844
|
-
})]
|
|
35127
|
+
]
|
|
35128
|
+
}),
|
|
35129
|
+
!footnoteKeepsRoundOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
|
|
35130
|
+
variant: "secondary",
|
|
35131
|
+
"data-testid": `chunk-note-${chunkIndex}`,
|
|
35132
|
+
onClick: () => state.openNoteSheet(chunk),
|
|
35133
|
+
children: "Note"
|
|
35134
|
+
})
|
|
35135
|
+
]
|
|
35136
|
+
}),
|
|
35137
|
+
chunk.decision !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DecisionControl, {
|
|
35138
|
+
chunk,
|
|
35139
|
+
chunkIndex,
|
|
35140
|
+
state
|
|
35141
|
+
}),
|
|
35142
|
+
footnoteKeepsRoundOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
|
|
35143
|
+
variant: "ghost",
|
|
35144
|
+
"data-testid": `chunk-note-${chunkIndex}`,
|
|
35145
|
+
onClick: () => state.openNoteSheet(chunk),
|
|
35146
|
+
className: "w-full rounded border border-divider px-2 py-1 text-left text-small font-normal text-muted",
|
|
35147
|
+
children: state.threadOf(chunk) !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Thread, {
|
|
35148
|
+
thread: state.threadOf(chunk),
|
|
35149
|
+
testId: `chunk-thread-${chunkIndex}`
|
|
35150
|
+
}) : state.noteTextOf(chunk)
|
|
35151
|
+
})
|
|
35152
|
+
]
|
|
34845
35153
|
});
|
|
34846
35154
|
};
|
|
34847
|
-
|
|
35155
|
+
/** A finding is done once decided, any other chunk once every hunk is ticked; a plain note never counts. */
|
|
35156
|
+
const isChunkDone = (chunk, state) => {
|
|
35157
|
+
if (chunk.decision !== void 0) return state.isDecided(chunk);
|
|
34848
35158
|
const hunks = hunksOf(chunk);
|
|
34849
35159
|
return hunks.length > 0 && hunks.every(state.isChecked);
|
|
34850
|
-
}
|
|
34851
|
-
const
|
|
35160
|
+
};
|
|
35161
|
+
const undecidedCount = (nodes, state) => nodes.filter((chunk) => chunk.decision !== void 0 && !state.isDecided(chunk)).length;
|
|
35162
|
+
/** Consecutive chunks sharing a `group` (the review's Findings / Walkthrough), chunk indexes kept for their anchors. */
|
|
35163
|
+
const groupsOf = (nodes) => nodes.reduce((groups, chunk, chunkIndex) => {
|
|
35164
|
+
const last = groups.at(-1);
|
|
35165
|
+
if (last !== void 0 && last.title === chunk.group) last.chunks.push({
|
|
35166
|
+
chunk,
|
|
35167
|
+
chunkIndex
|
|
35168
|
+
});
|
|
35169
|
+
else groups.push({
|
|
35170
|
+
title: chunk.group,
|
|
35171
|
+
chunks: [{
|
|
35172
|
+
chunk,
|
|
35173
|
+
chunkIndex
|
|
35174
|
+
}]
|
|
35175
|
+
});
|
|
35176
|
+
return groups;
|
|
35177
|
+
}, []);
|
|
35178
|
+
const ChunkList = ({ nodes, state, scrollRef, onDone, busy, undecided }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
34852
35179
|
"data-testid": "review-screen",
|
|
34853
35180
|
className: "flex h-full min-h-0 flex-1 flex-col",
|
|
34854
35181
|
children: [
|
|
@@ -34861,9 +35188,10 @@ const ChunkList = ({ nodes, state, scrollRef, onDone, busy }) => /* @__PURE__ */
|
|
|
34861
35188
|
}), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
34862
35189
|
className: "text-small text-muted tabular-nums",
|
|
34863
35190
|
children: [
|
|
34864
|
-
|
|
34865
|
-
" /
|
|
34866
|
-
|
|
35191
|
+
nodes.filter((chunk) => isChunkDone(chunk, state)).length,
|
|
35192
|
+
" /",
|
|
35193
|
+
" ",
|
|
35194
|
+
nodes.filter((chunk) => !isPlainNote(chunk)).length,
|
|
34867
35195
|
" chunks approved"
|
|
34868
35196
|
]
|
|
34869
35197
|
})]
|
|
@@ -34872,22 +35200,32 @@ const ChunkList = ({ nodes, state, scrollRef, onDone, busy }) => /* @__PURE__ */
|
|
|
34872
35200
|
ref: scrollRef,
|
|
34873
35201
|
"data-testid": "review-scroll-container",
|
|
34874
35202
|
className: "min-h-0 flex-1 overflow-auto",
|
|
34875
|
-
children:
|
|
34876
|
-
|
|
34877
|
-
|
|
34878
|
-
|
|
34879
|
-
|
|
35203
|
+
children: groupsOf(nodes).map((group) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", {
|
|
35204
|
+
...group.title === void 0 ? {} : { "data-testid": `review-section-${group.title.toLowerCase()}` },
|
|
35205
|
+
children: [group.title !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", {
|
|
35206
|
+
className: "m-0 border-b border-divider bg-surface px-3 py-1.5 text-small font-semibold text-muted",
|
|
35207
|
+
children: group.title
|
|
35208
|
+
}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CardList, { children: group.chunks.map(({ chunk, chunkIndex }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChunkRow, {
|
|
35209
|
+
chunk,
|
|
35210
|
+
chunkIndex,
|
|
35211
|
+
state
|
|
35212
|
+
}, chunkIndex)) })]
|
|
35213
|
+
}, group.chunks[0].chunkIndex))
|
|
34880
35214
|
}),
|
|
34881
|
-
onDone !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.
|
|
35215
|
+
onDone !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
34882
35216
|
"data-testid": "review-done-row",
|
|
34883
|
-
className: "flex shrink-0 items-center justify-end border-t border-border p-3",
|
|
34884
|
-
children: /* @__PURE__ */ (0, import_jsx_runtime.
|
|
35217
|
+
className: "flex shrink-0 items-center justify-end gap-3 border-t border-border p-3",
|
|
35218
|
+
children: [undecided > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
35219
|
+
"data-testid": "review-done-blocked",
|
|
35220
|
+
className: "text-small text-warning",
|
|
35221
|
+
children: [undecided === 1 ? "1 finding needs" : `${undecided} findings need`, " a decision"]
|
|
35222
|
+
}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
|
|
34885
35223
|
variant: "primary",
|
|
34886
35224
|
"data-testid": "review-done",
|
|
34887
35225
|
onClick: onDone,
|
|
34888
|
-
disabled: busy,
|
|
35226
|
+
disabled: busy === true || undecided > 0,
|
|
34889
35227
|
children: "Done"
|
|
34890
|
-
})
|
|
35228
|
+
})]
|
|
34891
35229
|
})
|
|
34892
35230
|
]
|
|
34893
35231
|
});
|
|
@@ -34895,10 +35233,10 @@ const ChunkList = ({ nodes, state, scrollRef, onDone, busy }) => /* @__PURE__ */
|
|
|
34895
35233
|
* Presentational review screen — chunk list drilling into a per-chunk deck
|
|
34896
35234
|
* of hunks, mirroring `Plan.tsx`'s `PlanView`/`Plan` split.
|
|
34897
35235
|
*/
|
|
34898
|
-
const ReviewView = ({ view, filePath, isLoading, readError, live, onSaveNote, onDoneNote, onDone, onSetValue, busy }) => {
|
|
35236
|
+
const ReviewView = ({ view, filePath, isLoading, readError, live, onSaveNote, onDoneNote, onDone, onSetValue, onCommitDecision, busy }) => {
|
|
34899
35237
|
const scrollRef = (0, import_react.useRef)(null);
|
|
34900
35238
|
const save = useWriteStore((s) => s.save);
|
|
34901
|
-
const state = useReviewState(filePath, scrollRef, onSaveNote, onDoneNote, onSetValue);
|
|
35239
|
+
const state = useReviewState(filePath, scrollRef, onSaveNote, onDoneNote, onSetValue, onCommitDecision);
|
|
34902
35240
|
if (view === void 0) {
|
|
34903
35241
|
const refusal = readError !== void 0 ? readRefusalFrom(readError) : void 0;
|
|
34904
35242
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Notice, {
|
|
@@ -34906,8 +35244,19 @@ const ReviewView = ({ view, filePath, isLoading, readError, live, onSaveNote, on
|
|
|
34906
35244
|
children: isLoading ? "Loading the review…" : refusal !== void 0 ? messageForReadRefusal(refusal) : "Could not load the review."
|
|
34907
35245
|
});
|
|
34908
35246
|
}
|
|
35247
|
+
const answerChunk = state.answerSheet;
|
|
35248
|
+
const answerAnchor = answerChunk?.decision?.[ANSWER_SLOT]?.anchor;
|
|
35249
|
+
const answerSheet = answerChunk !== void 0 && answerAnchor !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NoteSheet, {
|
|
35250
|
+
anchor: answerAnchor,
|
|
35251
|
+
title: "Your answer",
|
|
35252
|
+
label: "Answer text",
|
|
35253
|
+
note: state.decisionOf(answerChunk).freeText,
|
|
35254
|
+
onSave: (_anchor, text) => state.commitAnswer(answerChunk, text),
|
|
35255
|
+
onDismiss: () => state.setAnswerSheet(void 0)
|
|
35256
|
+
}) : null;
|
|
35257
|
+
const undecided = undecidedCount(view.nodes, state);
|
|
34909
35258
|
const noteSheet = (() => {
|
|
34910
|
-
if (state.noteSheet === void 0) return
|
|
35259
|
+
if (state.noteSheet === void 0) return answerSheet;
|
|
34911
35260
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NoteSheet, {
|
|
34912
35261
|
anchor: state.noteSheet.anchor,
|
|
34913
35262
|
...state.noteSheet.initialNote !== void 0 ? { note: state.noteSheet.initialNote } : {},
|
|
@@ -34933,14 +35282,16 @@ const ReviewView = ({ view, filePath, isLoading, readError, live, onSaveNote, on
|
|
|
34933
35282
|
live: live === true,
|
|
34934
35283
|
state,
|
|
34935
35284
|
onDone: onDone === void 0 ? void 0 : runDone,
|
|
34936
|
-
busy
|
|
35285
|
+
busy,
|
|
35286
|
+
undecided
|
|
34937
35287
|
}), noteSheet] });
|
|
34938
35288
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChunkList, {
|
|
34939
35289
|
nodes: view.nodes,
|
|
34940
35290
|
state,
|
|
34941
35291
|
scrollRef,
|
|
34942
35292
|
onDone: onDone === void 0 ? void 0 : runDone,
|
|
34943
|
-
busy
|
|
35293
|
+
busy,
|
|
35294
|
+
undecided
|
|
34944
35295
|
}), noteSheet] });
|
|
34945
35296
|
};
|
|
34946
35297
|
/** The terminal panel after `done` resolves. */
|
|
@@ -34972,6 +35323,16 @@ const useReviewMutations = (filePath, onFormatNotice) => {
|
|
|
34972
35323
|
onFormatNotice(result.formatNotice);
|
|
34973
35324
|
return result;
|
|
34974
35325
|
});
|
|
35326
|
+
const onCommitDecision = (anchor, opts) => (cas) => setValue.mutateAsync({
|
|
35327
|
+
filePath,
|
|
35328
|
+
...cas,
|
|
35329
|
+
mode: "review",
|
|
35330
|
+
anchor,
|
|
35331
|
+
...opts
|
|
35332
|
+
}).then((result) => {
|
|
35333
|
+
onFormatNotice(result.formatNotice);
|
|
35334
|
+
return result;
|
|
35335
|
+
});
|
|
34975
35336
|
const onSaveNote = (anchor, text) => (cas) => writeNote.mutateAsync({
|
|
34976
35337
|
filePath,
|
|
34977
35338
|
...cas,
|
|
@@ -34995,6 +35356,7 @@ const useReviewMutations = (filePath, onFormatNotice) => {
|
|
|
34995
35356
|
onDoneNote,
|
|
34996
35357
|
onDone,
|
|
34997
35358
|
onSetValue,
|
|
35359
|
+
onCommitDecision,
|
|
34998
35360
|
isDone: done.isSuccess
|
|
34999
35361
|
};
|
|
35000
35362
|
};
|
|
@@ -35003,7 +35365,7 @@ const ReviewInner = ({ filePath, data, isLoading, error }) => {
|
|
|
35003
35365
|
const indicatorProps = useSaveIndicatorProps();
|
|
35004
35366
|
const pending = useWriteStore((s) => s.pendingCount);
|
|
35005
35367
|
const [formatNotice, setFormatNotice] = (0, import_react.useState)(void 0);
|
|
35006
|
-
const { onSaveNote, onDoneNote, onDone, onSetValue, isDone } = useReviewMutations(filePath, setFormatNotice);
|
|
35368
|
+
const { onSaveNote, onDoneNote, onDone, onSetValue, onCommitDecision, isDone } = useReviewMutations(filePath, setFormatNotice);
|
|
35007
35369
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
35008
35370
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaveIndicator, { ...indicatorProps }),
|
|
35009
35371
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(FormatNoticeBanner, {
|
|
@@ -35020,6 +35382,7 @@ const ReviewInner = ({ filePath, data, isLoading, error }) => {
|
|
|
35020
35382
|
onDoneNote,
|
|
35021
35383
|
onDone,
|
|
35022
35384
|
onSetValue,
|
|
35385
|
+
onCommitDecision,
|
|
35023
35386
|
busy: pending > 0
|
|
35024
35387
|
})
|
|
35025
35388
|
] });
|