@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66
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/package.json +5 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +8 -73
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +60 -412
- package/src/App.prototype.test.tsx +1 -8
- package/src/App.readonly.test.tsx +85 -62
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.submit-confirm.test.tsx +32 -70
- package/src/App.test.tsx +136 -66
- package/src/App.toast.test.tsx +17 -8
- package/src/App.tsx +243 -284
- package/src/App.walk.test.tsx +1 -1
- package/src/app-screens.tsx +67 -52
- package/src/app-view-model.ts +6 -100
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/AppShell.stories.tsx +84 -0
- package/src/components/AppShell.tsx +127 -0
- package/src/components/Breadcrumb.stories.tsx +1 -1
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +147 -0
- package/src/components/CodeBlock.stories.tsx +1 -1
- package/src/components/CompletionOverlay.stories.tsx +1 -1
- package/src/components/ConfirmCard.stories.tsx +1 -1
- package/src/components/ConfirmCard.tsx +3 -4
- package/src/components/ConfirmDialog.stories.tsx +1 -1
- package/src/components/DiffSheet.stories.tsx +1 -1
- package/src/components/InboxList.stories.tsx +23 -8
- package/src/components/InboxList.tsx +45 -19
- package/src/components/KeybindsDialog.stories.tsx +1 -1
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/MarkerPopover.stories.tsx +1 -1
- package/src/components/MenuBar.stories.tsx +3 -12
- package/src/components/MenuBar.tsx +25 -56
- package/src/components/PromptDialog.stories.tsx +1 -1
- package/src/components/PrototypeSheet.stories.tsx +1 -1
- package/src/components/ReviewPanel.stories.tsx +15 -1
- package/src/components/ReviewRail.stories.tsx +15 -1
- package/src/components/ReviewRail.tsx +13 -6
- package/src/components/SettingsDialog.stories.tsx +7 -1
- package/src/components/SettingsDialog.tsx +112 -66
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.tsx +49 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +113 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1708 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +57 -0
- package/src/components/Toast.stories.tsx +1 -1
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/WalkWizard.stories.tsx +1 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
- package/src/components/agent-launcher.stories.tsx +1 -1
- package/src/components/file-tree.test.ts +45 -0
- package/src/components/file-tree.ts +57 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +1 -1
- package/src/components/primitives/Card.stories.tsx +1 -1
- package/src/components/primitives/Dialog.stories.tsx +1 -1
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +43 -0
- package/src/components/primitives/StatusBar.stories.tsx +1 -1
- package/src/components/primitives/Tabs.stories.tsx +1 -1
- package/src/components/primitives/Toolbar.stories.tsx +1 -1
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +87 -0
- package/src/components/primitives/icons.ts +38 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +1 -1
- package/src/components/session-tree.test.ts +114 -0
- package/src/components/session-tree.ts +94 -0
- package/src/components/stories-app.tsx +142 -82
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +55 -0
- package/src/components/theme-context.stories.tsx +1 -1
- package/src/diff-hunk-curate.ts +4 -104
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +136 -3
- package/src/intent-dispatch.ts +92 -24
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +4 -4
- package/src/keymap.ts +14 -7
- package/src/session-controller.curate.test.ts +89 -2
- package/src/session-controller.share.test.ts +195 -55
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +331 -72
- package/src/share.ts +114 -16
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +64 -0
- package/src/theme.ts +4 -4
- package/src/thread-chords.test.ts +154 -0
- package/src/thread-chords.ts +144 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-settings-dialog.tsx +9 -0
- package/src/view-plan.ts +29 -12
- package/src/App.review-panel.test.tsx +0 -143
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -419
- package/src/components/plan-sheet-run-style.ts +0 -88
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
appendEntry,
|
|
4
|
+
createBranch,
|
|
5
|
+
historyFromLinear,
|
|
6
|
+
labelTip,
|
|
7
|
+
navigateTo,
|
|
8
|
+
switchBranch,
|
|
9
|
+
tipOf,
|
|
10
|
+
type SessionHistory,
|
|
11
|
+
} from "@cueloop/schema";
|
|
12
|
+
import { entryTarget, treeRows } from "./tree-view";
|
|
13
|
+
|
|
14
|
+
const AT = "2026-09-01T10:00:00.000Z";
|
|
15
|
+
const LATER = "2026-09-01T11:00:00.000Z";
|
|
16
|
+
|
|
17
|
+
/** rev1 -> comment a1 on main; a branch "alt" off a1 with its own comment; main continues with rev2. */
|
|
18
|
+
function forked(): SessionHistory {
|
|
19
|
+
let history = historyFromLinear({
|
|
20
|
+
id: "ses_1",
|
|
21
|
+
revisions: [{ revision: 1, content: "v1", submittedAt: AT }],
|
|
22
|
+
annotations: [],
|
|
23
|
+
verdict: null,
|
|
24
|
+
createdAt: AT,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
history = appendEntry(history, { type: "comment", annotationId: "a1", createdAt: AT }).history;
|
|
28
|
+
history = labelTip(history, "start");
|
|
29
|
+
history = createBranch(history, "alt");
|
|
30
|
+
history = appendEntry(history, { type: "comment", annotationId: "b1", createdAt: AT }).history;
|
|
31
|
+
history = switchBranch(history, "main");
|
|
32
|
+
history = appendEntry(history, {
|
|
33
|
+
type: "revision",
|
|
34
|
+
by: "agent",
|
|
35
|
+
content: "v2",
|
|
36
|
+
createdAt: LATER,
|
|
37
|
+
}).history;
|
|
38
|
+
|
|
39
|
+
return history;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("treeRows", () => {
|
|
43
|
+
test("the current branch is the trunk; other segments indent under the entry they grew from", () => {
|
|
44
|
+
// Act
|
|
45
|
+
const rows = treeRows(forked());
|
|
46
|
+
|
|
47
|
+
// Assert: rev1, a1 (labelled, alt hangs off it), the alt comment indented, then rev2 as main's tip
|
|
48
|
+
expect(rows.map((row) => [row.depth, row.text, row.onPath])).toEqual([
|
|
49
|
+
[0, "revision 1", true],
|
|
50
|
+
[0, "comment", true],
|
|
51
|
+
[1, "comment", false],
|
|
52
|
+
[0, "revision 2", true],
|
|
53
|
+
]);
|
|
54
|
+
expect(rows[1]!.label).toBe("start");
|
|
55
|
+
expect(rows[2]!.tips).toEqual(["alt"]);
|
|
56
|
+
expect(rows[3]!.tips).toEqual(["main"]);
|
|
57
|
+
expect(rows[3]!.isCurrentTip).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("on the other branch the trunk follows it and main's continuation indents", () => {
|
|
61
|
+
// Act
|
|
62
|
+
const rows = treeRows(switchBranch(forked(), "alt"));
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
expect(rows.map((row) => [row.depth, row.text, row.onPath])).toEqual([
|
|
66
|
+
[0, "revision 1", true],
|
|
67
|
+
[0, "comment", true],
|
|
68
|
+
[1, "revision 2", false],
|
|
69
|
+
[0, "comment", true],
|
|
70
|
+
]);
|
|
71
|
+
expect(rows[3]!.isCurrentTip).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a branch summary reads as a return with its text", () => {
|
|
75
|
+
// Arrange
|
|
76
|
+
const history = navigateTo(forked(), forked().entries[0]!.id, { summary: "too early" });
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
const rows = treeRows(history);
|
|
80
|
+
|
|
81
|
+
// Assert: the summary hangs under rev1 as the trunk, the abandoned segment indents
|
|
82
|
+
expect(rows.find((row) => row.glyph === "↩")?.text).toBe('"too early"');
|
|
83
|
+
expect(rows.find((row) => row.glyph === "↩")?.isCurrentTip).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("entryTarget", () => {
|
|
88
|
+
test("the tip is here, another tip is a switch, an ancestor is a navigate on its branch", () => {
|
|
89
|
+
// Arrange
|
|
90
|
+
const history = forked();
|
|
91
|
+
const [rev1, a1, altComment, rev2] = history.entries;
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
94
|
+
expect(entryTarget(history, rev2!.id)).toEqual({ kind: "here" });
|
|
95
|
+
expect(entryTarget(history, altComment!.id)).toEqual({ kind: "switch", branch: "alt" });
|
|
96
|
+
expect(entryTarget(history, a1!.id)).toEqual({
|
|
97
|
+
kind: "navigate",
|
|
98
|
+
entryId: a1!.id,
|
|
99
|
+
branch: "main",
|
|
100
|
+
});
|
|
101
|
+
expect(entryTarget(history, rev1!.id)?.kind).toBe("navigate");
|
|
102
|
+
expect(tipOf(history)).toBe(rev2!.id);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("an entry only another branch reaches is a navigate on that branch", () => {
|
|
106
|
+
// Arrange: alt gets a second comment so its first is no longer a tip
|
|
107
|
+
let history = switchBranch(forked(), "alt");
|
|
108
|
+
|
|
109
|
+
history = appendEntry(history, {
|
|
110
|
+
type: "comment",
|
|
111
|
+
annotationId: "b2",
|
|
112
|
+
createdAt: LATER,
|
|
113
|
+
}).history;
|
|
114
|
+
const altFirst = history.entries.find(
|
|
115
|
+
(entry) => entry.type === "comment" && entry.annotationId === "b1",
|
|
116
|
+
)!;
|
|
117
|
+
|
|
118
|
+
history = switchBranch(history, "main");
|
|
119
|
+
|
|
120
|
+
// Assert
|
|
121
|
+
expect(entryTarget(history, altFirst.id)).toEqual({
|
|
122
|
+
kind: "navigate",
|
|
123
|
+
entryId: altFirst.id,
|
|
124
|
+
branch: "alt",
|
|
125
|
+
});
|
|
126
|
+
expect(entryTarget(history, "e_nope")).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/tree-view.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session tree as rows for the rail's Tree tab. The trunk is the current
|
|
3
|
+
* branch's path (falling back to main), drawn at depth 0; every other segment
|
|
4
|
+
* indents under the entry it grew from. Rows know whether they sit on the
|
|
5
|
+
* active path, which branch tips rest on them, and the checkpoint name they
|
|
6
|
+
* carry, so the pane only paints. Pure: no controller, no daemon.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
MAIN_BRANCH,
|
|
11
|
+
pathOf,
|
|
12
|
+
tipOf,
|
|
13
|
+
type SessionEntry,
|
|
14
|
+
type SessionHistory,
|
|
15
|
+
} from "@cueloop/schema";
|
|
16
|
+
|
|
17
|
+
export interface TreeRow {
|
|
18
|
+
entryId: string;
|
|
19
|
+
/** Branch nesting: 0 is the trunk, each segment off it one deeper. */
|
|
20
|
+
depth: number;
|
|
21
|
+
/** One glyph naming the entry kind, for the left column. */
|
|
22
|
+
glyph: string;
|
|
23
|
+
/** What the entry is, in a few words. */
|
|
24
|
+
text: string;
|
|
25
|
+
/** On the current branch's path: painted bright; everything else dim. */
|
|
26
|
+
onPath: boolean;
|
|
27
|
+
/** Branches whose tip rests on this entry. */
|
|
28
|
+
tips: string[];
|
|
29
|
+
/** The current branch's tip: where the next entry lands. */
|
|
30
|
+
isCurrentTip: boolean;
|
|
31
|
+
/** The checkpoint name, when labelled. */
|
|
32
|
+
label?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What going to an entry means from where the reviewer stands. */
|
|
36
|
+
export type EntryTarget =
|
|
37
|
+
| { kind: "here" }
|
|
38
|
+
| { kind: "switch"; branch: string }
|
|
39
|
+
| { kind: "navigate"; entryId: string; branch: string };
|
|
40
|
+
|
|
41
|
+
/** The one-glyph, few-words reading of an entry a row shows. */
|
|
42
|
+
export interface EntryDescription {
|
|
43
|
+
glyph: string;
|
|
44
|
+
text: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The entry's kind, said briefly; agent revisions count their round along the path. */
|
|
48
|
+
export function describeEntry(entry: SessionEntry, round?: number): EntryDescription {
|
|
49
|
+
switch (entry.type) {
|
|
50
|
+
case "revision":
|
|
51
|
+
return entry.by === "agent"
|
|
52
|
+
? { glyph: "◉", text: round === undefined ? "revision" : `revision ${round}` }
|
|
53
|
+
: { glyph: "○", text: "edit" };
|
|
54
|
+
case "comment":
|
|
55
|
+
return { glyph: "·", text: "comment" };
|
|
56
|
+
case "comment-removed":
|
|
57
|
+
return { glyph: "·", text: "comment removed" };
|
|
58
|
+
case "verdict":
|
|
59
|
+
return {
|
|
60
|
+
glyph: entry.verdict.kind === "approve" ? "✓" : "✗",
|
|
61
|
+
text: entry.verdict.kind.replace("_", " "),
|
|
62
|
+
};
|
|
63
|
+
case "branch-summary":
|
|
64
|
+
return { glyph: "↩", text: entry.text ? `"${entry.text}"` : "returned" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Rows in drawing order: trunk first at each fork, older segments before newer. */
|
|
69
|
+
export function treeRows(history: SessionHistory): TreeRow[] {
|
|
70
|
+
const children = new Map<string | null, SessionEntry[]>();
|
|
71
|
+
|
|
72
|
+
for (const entry of history.entries) {
|
|
73
|
+
const siblings = children.get(entry.parentId) ?? [];
|
|
74
|
+
|
|
75
|
+
siblings.push(entry);
|
|
76
|
+
children.set(entry.parentId, siblings);
|
|
77
|
+
}
|
|
78
|
+
for (const siblings of children.values()) {
|
|
79
|
+
siblings.sort(
|
|
80
|
+
(left, right) =>
|
|
81
|
+
left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const onPath = new Set(pathOf(history).map((entry) => entry.id));
|
|
85
|
+
const onMain = new Set(pathOf(history, tipOf(history, MAIN_BRANCH)).map((entry) => entry.id));
|
|
86
|
+
const tipsByEntry = new Map<string, string[]>();
|
|
87
|
+
|
|
88
|
+
for (const [branch, tip] of Object.entries(history.tips)) {
|
|
89
|
+
tipsByEntry.set(tip, [...(tipsByEntry.get(tip) ?? []), branch]);
|
|
90
|
+
}
|
|
91
|
+
const currentTip = tipOf(history);
|
|
92
|
+
const rows: TreeRow[] = [];
|
|
93
|
+
// the trunk continues through the child on the active path, else on main, else the oldest
|
|
94
|
+
const trunkChild = (siblings: SessionEntry[]): SessionEntry =>
|
|
95
|
+
siblings.find((entry) => onPath.has(entry.id)) ??
|
|
96
|
+
siblings.find((entry) => onMain.has(entry.id)) ??
|
|
97
|
+
siblings[0]!;
|
|
98
|
+
const walk = (entry: SessionEntry, depth: number, round: number): void => {
|
|
99
|
+
const nextRound = entry.type === "revision" && entry.by === "agent" ? round + 1 : round;
|
|
100
|
+
const described = describeEntry(
|
|
101
|
+
entry,
|
|
102
|
+
entry.type === "revision" && entry.by === "agent" ? nextRound : undefined,
|
|
103
|
+
);
|
|
104
|
+
const label = history.labels[entry.id];
|
|
105
|
+
const row: TreeRow = {
|
|
106
|
+
entryId: entry.id,
|
|
107
|
+
depth,
|
|
108
|
+
glyph: described.glyph,
|
|
109
|
+
text: described.text,
|
|
110
|
+
onPath: onPath.has(entry.id),
|
|
111
|
+
tips: tipsByEntry.get(entry.id) ?? [],
|
|
112
|
+
isCurrentTip: entry.id === currentTip,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (label !== undefined) row.label = label;
|
|
116
|
+
rows.push(row);
|
|
117
|
+
const siblings = children.get(entry.id) ?? [];
|
|
118
|
+
|
|
119
|
+
if (siblings.length === 0) return;
|
|
120
|
+
const trunk = trunkChild(siblings);
|
|
121
|
+
|
|
122
|
+
for (const child of siblings) if (child !== trunk) walk(child, depth + 1, nextRound);
|
|
123
|
+
walk(trunk, depth, nextRound);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
for (const root of children.get(null) ?? []) walk(root, 0, 0);
|
|
127
|
+
|
|
128
|
+
return rows;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Going to an entry: nothing when it is the current tip; a switch when it is
|
|
133
|
+
* another branch's tip; otherwise a navigate on the branch whose path holds
|
|
134
|
+
* it - the current branch when it can, else the first branch that reaches it.
|
|
135
|
+
*/
|
|
136
|
+
export function entryTarget(history: SessionHistory, entryId: string): EntryTarget | null {
|
|
137
|
+
if (entryId === tipOf(history)) return { kind: "here" };
|
|
138
|
+
const tipBranch = Object.entries(history.tips).find(([, tip]) => tip === entryId);
|
|
139
|
+
|
|
140
|
+
if (tipBranch) return { kind: "switch", branch: tipBranch[0] };
|
|
141
|
+
const holds = (branch: string): boolean =>
|
|
142
|
+
pathOf(history, tipOf(history, branch)).some((entry) => entry.id === entryId);
|
|
143
|
+
const branch = holds(history.branch)
|
|
144
|
+
? history.branch
|
|
145
|
+
: Object.keys(history.tips).find((candidate) => holds(candidate));
|
|
146
|
+
|
|
147
|
+
return branch === undefined ? null : { kind: "navigate", entryId, branch };
|
|
148
|
+
}
|
|
@@ -191,6 +191,12 @@ export function useSettingsDialog(params: {
|
|
|
191
191
|
/>
|
|
192
192
|
),
|
|
193
193
|
},
|
|
194
|
+
{
|
|
195
|
+
id: "keybinds",
|
|
196
|
+
name: "Keybinds",
|
|
197
|
+
description: "keyboard reference",
|
|
198
|
+
rows: [],
|
|
199
|
+
},
|
|
194
200
|
];
|
|
195
201
|
const settingsValues = {
|
|
196
202
|
autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
|
|
@@ -219,6 +225,9 @@ export function useSettingsDialog(params: {
|
|
|
219
225
|
};
|
|
220
226
|
|
|
221
227
|
const onCategorySelect = (categoryId: string): void => {
|
|
228
|
+
// the nav tree carries a synthetic Settings group folder; clicking it must
|
|
229
|
+
// not become the active category, or the key handler dereferences nothing
|
|
230
|
+
if (!settingsCategories.some((category) => category.id === categoryId)) return;
|
|
222
231
|
setActionsExpandedIndex(null);
|
|
223
232
|
setSettingsNav({ categoryId, rowIndex: 0, zone: "body" });
|
|
224
233
|
};
|
package/src/view-plan.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type Block,
|
|
18
18
|
} from "@cueloop/schema";
|
|
19
19
|
import { wordLevelChanges } from "./diff-intraline";
|
|
20
|
+
import { spanRangeInBlock, type TextSpan } from "./thread-selection";
|
|
20
21
|
|
|
21
22
|
// ── display model ─────────────────────────────
|
|
22
23
|
|
|
@@ -182,6 +183,8 @@ export interface Mark {
|
|
|
182
183
|
end: number;
|
|
183
184
|
role: RunRole;
|
|
184
185
|
annotationId?: string;
|
|
186
|
+
/** The whole anchored stretch in display coordinates; one thread per span. */
|
|
187
|
+
span?: TextSpan;
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
/**
|
|
@@ -207,18 +210,32 @@ export function marksByDisplay(
|
|
|
207
210
|
const resolved = resolveAnchor(annotation.anchor, workBlocks);
|
|
208
211
|
|
|
209
212
|
if (!resolved) continue;
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
213
|
+
const startEntry = workEntries[resolved.blockIndex];
|
|
214
|
+
const endEntry = workEntries[resolved.endBlockIndex];
|
|
215
|
+
|
|
216
|
+
if (!startEntry || !endEntry) continue;
|
|
217
|
+
const span: TextSpan = {
|
|
218
|
+
start: { blockIndex: startEntry.displayIndex, char: resolved.start },
|
|
219
|
+
end: { blockIndex: endEntry.displayIndex, char: resolved.end },
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// a span paints on every block it covers; the mark carries the whole span
|
|
223
|
+
for (let workIndex = resolved.blockIndex; workIndex <= resolved.endBlockIndex; workIndex++) {
|
|
224
|
+
const entry = workEntries[workIndex]!;
|
|
225
|
+
const range = spanRangeInBlock(span, entry.displayIndex, entry.block.work!.text.length);
|
|
226
|
+
|
|
227
|
+
if (!range) continue;
|
|
228
|
+
const marks = marksByIndex.get(entry.displayIndex) ?? [];
|
|
229
|
+
|
|
230
|
+
marks.push({
|
|
231
|
+
start: range.start,
|
|
232
|
+
end: range.end,
|
|
233
|
+
role: annotation.id === focusedId ? "mark-focus" : "mark-comment",
|
|
234
|
+
annotationId: annotation.id,
|
|
235
|
+
span,
|
|
236
|
+
});
|
|
237
|
+
marksByIndex.set(entry.displayIndex, marks);
|
|
238
|
+
}
|
|
222
239
|
}
|
|
223
240
|
|
|
224
241
|
return marksByIndex;
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
/** The review panel end to end: the b key cycles expanded -> compact -> hidden -> expanded on the live App, and the collapse mode persists to the user config so it survives a restart. Rendering details are locked by the ReviewPanel stories; this suite proves the App wiring and the persistence. */
|
|
2
|
-
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import React from "react";
|
|
8
|
-
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
-
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
-
import type { ReviewSession } from "@cueloop/schema";
|
|
11
|
-
import { App } from "./App";
|
|
12
|
-
import { loadConfig } from "./config";
|
|
13
|
-
import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
|
|
14
|
-
|
|
15
|
-
const PLAN = "# Plan\n\nShip the thing.\n";
|
|
16
|
-
|
|
17
|
-
let home: string;
|
|
18
|
-
let server: DaemonServer;
|
|
19
|
-
let session: ReviewSession;
|
|
20
|
-
let configPath: string;
|
|
21
|
-
let restoreUserConfig: () => void;
|
|
22
|
-
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
home = mkdtempSync(join(tmpdir(), "cueloop-panel-"));
|
|
25
|
-
configPath = join(home, "config.toml");
|
|
26
|
-
restoreUserConfig = isolateUserConfig(home, "config.toml");
|
|
27
|
-
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
28
|
-
server.start();
|
|
29
|
-
session = server.core.sessionCreate({
|
|
30
|
-
workspace: { repoRoot: "/repo", branch: "main" },
|
|
31
|
-
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
afterEach(() => {
|
|
35
|
-
restoreUserConfig();
|
|
36
|
-
server.stop();
|
|
37
|
-
rmSync(home, { recursive: true, force: true });
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
async function renderApp() {
|
|
41
|
-
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
42
|
-
width: 120,
|
|
43
|
-
height: 30,
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
await waitForText(setup, "Review");
|
|
47
|
-
|
|
48
|
-
return setup;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe("review panel", () => {
|
|
52
|
-
test("b cycles expanded -> compact -> hidden -> expanded", async () => {
|
|
53
|
-
// Arrange
|
|
54
|
-
const setup = await renderApp();
|
|
55
|
-
// the plan frame widens as the rail shrinks: expanded < compact < hidden.
|
|
56
|
-
// its width is the column of its top-right corner (the first ╮ on the row).
|
|
57
|
-
const planWidth = () =>
|
|
58
|
-
setup
|
|
59
|
-
.captureCharFrame()
|
|
60
|
-
.split("\n")
|
|
61
|
-
.find((line) => line.includes("╮"))
|
|
62
|
-
?.indexOf("╮") ?? 0;
|
|
63
|
-
|
|
64
|
-
// Assert: expanded shows the submit button and leaves room for the rail
|
|
65
|
-
expect(setup.captureCharFrame()).toContain("Submit review");
|
|
66
|
-
const expandedWidth = planWidth();
|
|
67
|
-
|
|
68
|
-
// Act
|
|
69
|
-
await press(setup, "b");
|
|
70
|
-
|
|
71
|
-
// Assert: compact drops the submit button and widens the plan (rail is a strip)
|
|
72
|
-
await waitForTextGone(setup, "Submit review");
|
|
73
|
-
const compactWidth = planWidth();
|
|
74
|
-
|
|
75
|
-
expect(compactWidth).toBeGreaterThan(expandedWidth);
|
|
76
|
-
|
|
77
|
-
// Act
|
|
78
|
-
await press(setup, "b");
|
|
79
|
-
await settle(setup);
|
|
80
|
-
|
|
81
|
-
// Assert: hidden spans the plan full width, no rail column
|
|
82
|
-
const hiddenWidth = planWidth();
|
|
83
|
-
|
|
84
|
-
expect(hiddenWidth).toBeGreaterThan(compactWidth);
|
|
85
|
-
expect(hiddenWidth).toBeGreaterThan(115);
|
|
86
|
-
|
|
87
|
-
// Act
|
|
88
|
-
await press(setup, "b");
|
|
89
|
-
|
|
90
|
-
// Assert
|
|
91
|
-
await waitForText(setup, "Submit review"); // back to expanded
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("the collapse mode persists to the user config", async () => {
|
|
95
|
-
// Arrange
|
|
96
|
-
const setup = await renderApp();
|
|
97
|
-
|
|
98
|
-
// Act
|
|
99
|
-
await press(setup, "b"); // -> compact
|
|
100
|
-
|
|
101
|
-
// Assert
|
|
102
|
-
expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("] widens the rail and persists the new width", async () => {
|
|
106
|
-
// Arrange
|
|
107
|
-
const setup = await renderApp();
|
|
108
|
-
const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
109
|
-
|
|
110
|
-
// Act
|
|
111
|
-
await press(setup, "]");
|
|
112
|
-
|
|
113
|
-
// Assert
|
|
114
|
-
const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
115
|
-
|
|
116
|
-
expect(widened).toBeGreaterThan(startWidth);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test("submitting from a hidden panel force-opens the rail so the confirm card is reachable", async () => {
|
|
120
|
-
// Arrange
|
|
121
|
-
const setup = await renderApp();
|
|
122
|
-
|
|
123
|
-
await press(setup, "b"); // -> compact
|
|
124
|
-
await press(setup, "b"); // -> hidden
|
|
125
|
-
|
|
126
|
-
// Assert
|
|
127
|
-
await waitForTextGone(setup, "Submit review"); // rail is gone
|
|
128
|
-
expect(setup.captureCharFrame()).not.toContain("Submit review");
|
|
129
|
-
|
|
130
|
-
// Act
|
|
131
|
-
await press(setup, "enter"); // submit while hidden must not trap in an invisible modal
|
|
132
|
-
|
|
133
|
-
// Assert
|
|
134
|
-
await waitForText(setup, "submit review");
|
|
135
|
-
const frame = setup.captureCharFrame();
|
|
136
|
-
|
|
137
|
-
expect(frame).toContain("submit review"); // the confirm card is visible again
|
|
138
|
-
expect(frame).toContain("[Approve]");
|
|
139
|
-
|
|
140
|
-
// the reveal is live-only: the saved panel preference stays as the user left it
|
|
141
|
-
expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("hidden");
|
|
142
|
-
});
|
|
143
|
-
});
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { DARK } from "../theme";
|
|
3
|
-
import type { Story, StoryMeta } from "./story";
|
|
4
|
-
import { buildDisplay } from "../view-plan";
|
|
5
|
-
import { PlanSheet } from "./PlanSheet";
|
|
6
|
-
import { FIXTURE_PLAN, fixtureDisplay, fixtureMarks, fixturePlanSession } from "./story-fixtures";
|
|
7
|
-
|
|
8
|
-
export const meta: StoryMeta = { title: "PlanSheet" };
|
|
9
|
-
|
|
10
|
-
const callbacks = { onLineActivate: () => {} };
|
|
11
|
-
|
|
12
|
-
// a working copy with the context paragraph cut, so it renders as a del block
|
|
13
|
-
const CUT_WORKING = FIXTURE_PLAN.replace("The daemon persists sessions to disk atomically.\n", "");
|
|
14
|
-
|
|
15
|
-
export const AnnotatedPlan: Story = {
|
|
16
|
-
render: () => (
|
|
17
|
-
<PlanSheet
|
|
18
|
-
session={fixturePlanSession()}
|
|
19
|
-
display={fixtureDisplay()}
|
|
20
|
-
marks={fixtureMarks()}
|
|
21
|
-
cursor={2}
|
|
22
|
-
activeSpan={null}
|
|
23
|
-
compose={null}
|
|
24
|
-
popover={null}
|
|
25
|
-
editOrphanCount={0}
|
|
26
|
-
{...callbacks}
|
|
27
|
-
/>
|
|
28
|
-
),
|
|
29
|
-
expectedColors: [DARK.markCommentBackground, DARK.cursorBackground],
|
|
30
|
-
size: { width: 90, height: 28 },
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export const SpanSelection: Story = {
|
|
34
|
-
render: () => (
|
|
35
|
-
<PlanSheet
|
|
36
|
-
session={fixturePlanSession({ annotations: [] })}
|
|
37
|
-
display={fixtureDisplay()}
|
|
38
|
-
marks={new Map()}
|
|
39
|
-
cursor={2}
|
|
40
|
-
activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
|
|
41
|
-
compose={null}
|
|
42
|
-
popover={null}
|
|
43
|
-
editOrphanCount={0}
|
|
44
|
-
{...callbacks}
|
|
45
|
-
/>
|
|
46
|
-
),
|
|
47
|
-
expectedColors: [DARK.accent],
|
|
48
|
-
size: { width: 90, height: 28 },
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
export const InlineCompose: Story = {
|
|
52
|
-
render: () => (
|
|
53
|
-
<PlanSheet
|
|
54
|
-
session={fixturePlanSession({ annotations: [] })}
|
|
55
|
-
display={fixtureDisplay()}
|
|
56
|
-
marks={new Map()}
|
|
57
|
-
cursor={2}
|
|
58
|
-
activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
|
|
59
|
-
compose={{
|
|
60
|
-
kind: "comment",
|
|
61
|
-
displayIndex: 2,
|
|
62
|
-
quote: "The daemon",
|
|
63
|
-
draft: { text: "Which daemon?", onInput: () => {}, onSave: () => {}, onCancel: () => {} },
|
|
64
|
-
}}
|
|
65
|
-
popover={null}
|
|
66
|
-
editOrphanCount={0}
|
|
67
|
-
{...callbacks}
|
|
68
|
-
/>
|
|
69
|
-
),
|
|
70
|
-
size: { width: 90, height: 30 },
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/** Span mode shows the marker-actions toolbar inline below the marked block. */
|
|
74
|
-
// The marker popover floats as an absolute overlay; the virtual terminal used
|
|
75
|
-
// for story snapshots cannot composite it, so it is covered by the standalone
|
|
76
|
-
// MarkerPopover stories and a functional App test rather than a PlanSheet snapshot.
|
|
77
|
-
|
|
78
|
-
/** A cut block renders struck-through and grayed inline - no red, no [cut] tag. */
|
|
79
|
-
export const WithCutBlock: Story = {
|
|
80
|
-
render: () => (
|
|
81
|
-
<PlanSheet
|
|
82
|
-
session={fixturePlanSession()}
|
|
83
|
-
display={buildDisplay(FIXTURE_PLAN, CUT_WORKING)}
|
|
84
|
-
marks={new Map()}
|
|
85
|
-
cursor={0}
|
|
86
|
-
activeSpan={null}
|
|
87
|
-
compose={null}
|
|
88
|
-
popover={null}
|
|
89
|
-
editOrphanCount={0}
|
|
90
|
-
{...callbacks}
|
|
91
|
-
/>
|
|
92
|
-
),
|
|
93
|
-
expectedColors: [DARK.textDim],
|
|
94
|
-
size: { width: 90, height: 28 },
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
export const OrphanBanner: Story = {
|
|
98
|
-
render: () => (
|
|
99
|
-
<PlanSheet
|
|
100
|
-
session={fixturePlanSession()}
|
|
101
|
-
display={fixtureDisplay()}
|
|
102
|
-
marks={new Map()}
|
|
103
|
-
cursor={0}
|
|
104
|
-
activeSpan={null}
|
|
105
|
-
compose={null}
|
|
106
|
-
popover={null}
|
|
107
|
-
editOrphanCount={1}
|
|
108
|
-
{...callbacks}
|
|
109
|
-
/>
|
|
110
|
-
),
|
|
111
|
-
expectedColors: [DARK.red],
|
|
112
|
-
size: { width: 90, height: 28 },
|
|
113
|
-
};
|