@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.export.test.tsx +2 -2
  4. package/src/App.plan-review.test.tsx +76 -247
  5. package/src/App.readonly.test.tsx +80 -35
  6. package/src/App.rendering.test.tsx +5 -6
  7. package/src/App.review-panel.test.tsx +29 -32
  8. package/src/App.submit-confirm.test.tsx +27 -12
  9. package/src/App.test.tsx +73 -49
  10. package/src/App.toast.test.tsx +17 -8
  11. package/src/App.tree.test.tsx +224 -0
  12. package/src/App.tsx +120 -84
  13. package/src/app-screens.tsx +27 -1
  14. package/src/app-view-model.ts +4 -96
  15. package/src/components/AnnotationCard.tsx +2 -2
  16. package/src/components/KeybindsDialog.tsx +61 -17
  17. package/src/components/ReviewPanel.stories.tsx +14 -0
  18. package/src/components/ReviewRail.stories.tsx +14 -0
  19. package/src/components/ReviewRail.tsx +13 -6
  20. package/src/components/ThreadView.selection.test.tsx +812 -0
  21. package/src/components/ThreadView.stories.tsx +26 -0
  22. package/src/components/ThreadView.tsx +1620 -0
  23. package/src/components/TreePane.stories.tsx +106 -0
  24. package/src/components/TreePane.tsx +156 -0
  25. package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
  26. package/src/diff-hunk-curate.ts +4 -104
  27. package/src/index.ts +1 -0
  28. package/src/intent-dispatch.test.ts +136 -3
  29. package/src/intent-dispatch.ts +92 -24
  30. package/src/key-bindings.test.ts +1 -1
  31. package/src/keymap.test.ts +4 -4
  32. package/src/keymap.ts +14 -7
  33. package/src/session-controller.curate.test.ts +89 -2
  34. package/src/session-controller.share.test.ts +195 -55
  35. package/src/session-controller.tree.test.ts +228 -0
  36. package/src/session-controller.ts +322 -68
  37. package/src/share.ts +114 -16
  38. package/src/test-support.test.ts +193 -1
  39. package/src/test-support.ts +64 -0
  40. package/src/thread-chords.test.ts +154 -0
  41. package/src/thread-chords.ts +144 -0
  42. package/src/thread-selection.test.ts +210 -0
  43. package/src/thread-selection.ts +243 -0
  44. package/src/tree-view.test.ts +128 -0
  45. package/src/tree-view.ts +148 -0
  46. package/src/view-plan.ts +29 -12
  47. package/src/components/PlanSheet.stories.tsx +0 -113
  48. package/src/components/PlanSheet.tsx +0 -419
  49. 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
+ });
@@ -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
+ }
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 entry = workEntries[resolved.blockIndex];
211
-
212
- if (!entry) continue;
213
- const marks = marksByIndex.get(entry.displayIndex) ?? [];
214
-
215
- marks.push({
216
- start: resolved.start,
217
- end: resolved.end,
218
- role: annotation.id === focusedId ? "mark-focus" : "mark-comment",
219
- annotationId: annotation.id,
220
- });
221
- marksByIndex.set(entry.displayIndex, marks);
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,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
- };