@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
@@ -2,7 +2,7 @@ import React, { type Dispatch, type SetStateAction } from "react";
2
2
  import type { ReviewSession, VerdictKind } from "@cueloop/schema";
3
3
  import { returnPaneFor } from "@cueloop/schema";
4
4
  import type { Theme } from "./theme";
5
- import type { Mode } from "./intent-dispatch";
5
+ import type { Mode, TreeAsk } from "./intent-dispatch";
6
6
  import type { Intent } from "./keymap";
7
7
  import type { ReviewController, ToastState } from "./session-controller";
8
8
  import { noteForFile } from "./walk";
@@ -195,6 +195,21 @@ export function CompletionScreen(props: {
195
195
  );
196
196
  }
197
197
 
198
+ /** The words each tree prompt uses; enter with an empty summary moves back without one. */
199
+ const TREE_PROMPTS: Record<TreeAsk, { title: string; label: string; placeholder: string }> = {
200
+ branch: { title: " Branch ", label: "Name for the new branch:", placeholder: "alt" },
201
+ label: {
202
+ title: " Checkpoint ",
203
+ label: "Name for this checkpoint:",
204
+ placeholder: "before the rewrite",
205
+ },
206
+ navigate: {
207
+ title: " Move back ",
208
+ label: "Summary of what you leave behind (optional):",
209
+ placeholder: "tried a shorter plan",
210
+ },
211
+ };
212
+
198
213
  export function TrailingOverlays(props: {
199
214
  walking: boolean;
200
215
  walk: { index: number } | null;
@@ -264,6 +279,17 @@ export function TrailingOverlays(props: {
264
279
  theme={theme}
265
280
  />
266
281
  ) : null}
282
+ {mode.type === "treePrompt" ? (
283
+ <PromptDialog
284
+ isOpen
285
+ title={TREE_PROMPTS[mode.ask].title}
286
+ label={TREE_PROMPTS[mode.ask].label}
287
+ value={mode.text}
288
+ placeholder={TREE_PROMPTS[mode.ask].placeholder}
289
+ onInput={(text) => setMode({ ...mode, text })}
290
+ theme={theme}
291
+ />
292
+ ) : null}
267
293
  {toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
268
294
  </>
269
295
  );
@@ -1,14 +1,11 @@
1
1
  import type { Dispatch, MutableRefObject, SetStateAction } from "react";
2
2
  import type { ReviewSession, VerdictKind } from "@cueloop/schema";
3
- import { activeSpanState, reviewerAnnotations, type Mode } from "./intent-dispatch";
4
- import { displayText, type DisplayBlock } from "./view-plan";
3
+ import { reviewerAnnotations, type Mode } from "./intent-dispatch";
5
4
  import type { DiffRow } from "./view-diff";
6
5
  import type { Intent, KeyState } from "./keymap";
7
- import type { Completion, ReviewController } from "./session-controller";
8
- import type { QuickAction } from "./config";
6
+ import type { Completion } from "./session-controller";
9
7
  import type { WalkFile } from "./walk";
10
8
  import { viewedCount } from "./walk";
11
- import type { PlanComposeState, PlanPopoverState } from "./components/PlanSheet";
12
9
  import type { DiffComposeState } from "./components/DiffSheet";
13
10
  import type { ConfirmCardProps } from "./components/ConfirmCard";
14
11
  import type { RailCardEdit } from "./components/ReviewRail";
@@ -44,7 +41,8 @@ export function resolveOverlay(
44
41
  if (mode.type === "compose" || mode.type === "railEdit") return "compose";
45
42
  if (mode.type === "submit") return "submit";
46
43
  if (mode.type === "confirmDelete") return "confirm";
47
- if (mode.type === "rename" || mode.type === "nameSelf") return "prompt";
44
+ if (mode.type === "rename" || mode.type === "nameSelf" || mode.type === "treePrompt")
45
+ return "prompt";
48
46
  if (mode.type === "spanActions") return "spanActions";
49
47
  if (completionPhase === "prompt") return "completion-prompt";
50
48
  if (completionPhase === "counting") return "completion-counting";
@@ -130,49 +128,12 @@ export function buildRenderFlags(params: {
130
128
  };
131
129
  }
132
130
 
133
- export function buildActiveSpan(
134
- mode: Mode,
135
- isDiff: boolean,
136
- ): { displayIndex: number; start: number; end: number } | null {
137
- const markedSpan = activeSpanState(mode);
138
-
139
- if (markedSpan)
140
- return { displayIndex: markedSpan.displayIndex, start: markedSpan.start, end: markedSpan.end };
141
- if (mode.type === "compose" && !isDiff)
142
- return { displayIndex: mode.displayIndex, start: mode.start, end: mode.end };
143
-
144
- return null;
145
- }
146
-
147
131
  interface DraftHandlerDeps {
148
132
  liveInput: MutableRefObject<string>;
149
133
  setMode: Dispatch<SetStateAction<Mode>>;
150
134
  dispatch: (intent: Intent) => void;
151
135
  }
152
136
 
153
- export function buildComposeState(
154
- deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; display: DisplayBlock[] },
155
- ): PlanComposeState | null {
156
- const { mode, isDiff, display, liveInput, setMode, dispatch } = deps;
157
-
158
- if (mode.type !== "compose" || isDiff) return null;
159
-
160
- return {
161
- kind: mode.kind,
162
- displayIndex: mode.displayIndex,
163
- quote: displayText(display[mode.displayIndex]!).slice(mode.start, mode.end),
164
- draft: {
165
- text: mode.text,
166
- onInput: (text: string) => {
167
- liveInput.current = text;
168
- setMode({ ...mode, text });
169
- },
170
- onSave: () => dispatch({ type: "saveCompose" }),
171
- onCancel: () => dispatch({ type: "closeOverlay" }),
172
- },
173
- };
174
- }
175
-
176
137
  export function buildDiffComposeState(
177
138
  deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; rows: DiffRow[] },
178
139
  ): DiffComposeState | null {
@@ -196,59 +157,6 @@ export function buildDiffComposeState(
196
157
  };
197
158
  }
198
159
 
199
- export function buildPopoverState(deps: {
200
- mode: Mode;
201
- isDiff: boolean;
202
- quickActions: QuickAction[];
203
- isOwner: boolean;
204
- observer: boolean;
205
- resolved: boolean;
206
- controller: ReviewController;
207
- dispatch: (intent: Intent) => void;
208
- }): PlanPopoverState | null {
209
- const { mode, isDiff, quickActions, isOwner, observer, resolved, controller, dispatch } = deps;
210
- const markedSpan = activeSpanState(mode);
211
-
212
- if (!markedSpan || isDiff) return null;
213
- const spanMutationBlock = (): string | null =>
214
- observer ? "observer - read-only" : resolved ? "review submitted - read-only" : null;
215
-
216
- return {
217
- displayIndex: markedSpan.displayIndex,
218
- view: mode.type === "spanActions" ? ("actions" as const) : ("toolbar" as const),
219
- actions: quickActions,
220
- actionIndex: mode.type === "spanActions" ? mode.index : 0,
221
- canCut: isOwner,
222
- onComment: () => {
223
- const blocked = spanMutationBlock();
224
-
225
- if (blocked) return controller.setStatus(blocked);
226
- dispatch({ type: "openCompose", kind: "comment", from: "span" });
227
- },
228
- onCut: () => {
229
- const blocked = spanMutationBlock();
230
-
231
- if (blocked) return controller.setStatus(blocked);
232
- if (!isOwner) return;
233
- dispatch({ type: "spanCut" });
234
- },
235
- onOpenActions: () => {
236
- const blocked = spanMutationBlock();
237
-
238
- if (blocked) return controller.setStatus(blocked);
239
- dispatch({ type: "openSpanActions" });
240
- },
241
- onClose: () => dispatch({ type: "closeOverlay" }),
242
- onPickAction: (index: number) => {
243
- const blocked = spanMutationBlock();
244
-
245
- if (blocked) return controller.setStatus(blocked);
246
- dispatch({ type: "pickSpanAction", index });
247
- },
248
- onBack: () => dispatch({ type: "closeSpanActions" }),
249
- };
250
- }
251
-
252
160
  export function buildSubmitConfirmState(
253
161
  deps: DraftHandlerDeps & {
254
162
  mode: Mode;
@@ -181,11 +181,11 @@ export function AnnotationCard({
181
181
  );
182
182
 
183
183
  if (draft) {
184
- const verb = "comment on";
184
+ const primitive = "comment on";
185
185
 
186
186
  return (
187
187
  <Card
188
- title={` ${verb} "${truncateToSingleLine(quote, 40)}" `}
188
+ title={` ${primitive} "${truncateToSingleLine(quote, 40)}" `}
189
189
  contentRows={editorRowCount + 1}
190
190
  borderColor={kindColor}
191
191
  backgroundColor="transparent"
@@ -10,13 +10,52 @@ export interface KeybindsDialogProps {
10
10
  theme?: Theme;
11
11
  }
12
12
 
13
+ /** Rows a section takes: its title, its entries, and the blank line after it. */
14
+ function sectionRows(section: CheatsheetSection): number {
15
+ return 1 + section.entries.length + 1;
16
+ }
17
+
18
+ /**
19
+ * Split the sections over `count` columns, filling each column to the
20
+ * balanced share of rows before starting the next, so a long cheatsheet
21
+ * reads top to bottom, then left to right.
22
+ */
23
+ function columnsOf(sections: CheatsheetSection[], count: number): CheatsheetSection[][] {
24
+ const total = sections.reduce((sum, section) => sum + sectionRows(section), 0);
25
+ const share = Math.ceil(total / count);
26
+ const columns: CheatsheetSection[][] = [[]];
27
+ let filled = 0;
28
+
29
+ for (const section of sections) {
30
+ const column = columns[columns.length - 1]!;
31
+
32
+ if (filled > 0 && filled + sectionRows(section) > share && columns.length < count) {
33
+ columns.push([section]);
34
+ filled = sectionRows(section);
35
+ } else {
36
+ column.push(section);
37
+ filled += sectionRows(section);
38
+ }
39
+ }
40
+
41
+ return columns;
42
+ }
43
+
44
+ const COLUMN_WIDTH = 48;
45
+
13
46
  export function KeybindsDialog({ sections, theme }: KeybindsDialogProps): React.ReactNode {
14
47
  const tokens = useComponentTheme(theme);
15
48
  const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
16
- const contentRows =
17
- sections.reduce((sum, section) => sum + 1 + section.entries.length + 1, 0) + 1;
18
- const width = Math.min(52, terminalWidth - 6);
19
- const height = Math.min(terminalHeight - 4, contentRows + 3);
49
+ const totalRows = sections.reduce((sum, section) => sum + sectionRows(section), 0) + 1;
50
+ // a cheatsheet taller than the terminal folds into a second column
51
+ const columnCount =
52
+ totalRows + 3 > terminalHeight - 4 && terminalWidth >= COLUMN_WIDTH * 2 + 6 ? 2 : 1;
53
+ const columns = columnsOf(sections, columnCount);
54
+ const tallest = Math.max(
55
+ ...columns.map((column) => column.reduce((sum, section) => sum + sectionRows(section), 0)),
56
+ );
57
+ const width = Math.min(COLUMN_WIDTH * columnCount + 4, terminalWidth - 6);
58
+ const height = Math.min(terminalHeight - 4, tallest + 1 + 3);
20
59
 
21
60
  return (
22
61
  <Dialog
@@ -36,19 +75,24 @@ export function KeybindsDialog({ sections, theme }: KeybindsDialogProps): React.
36
75
  paddingTop: 1,
37
76
  }}
38
77
  >
39
- {sections.map((section) => (
40
- <box key={section.title} style={{ flexDirection: "column" }}>
41
- <text fg={tokens.accent}>{section.title}</text>
42
- {section.entries.map((entry, index) => (
43
- <text key={index}>
44
- <span fg={tokens.text}>{entry.keys.padEnd(12)}</span>
45
- <span fg={tokens.textMuted}>{entry.label}</span>
46
- </text>
47
- ))}
48
- <text> </text>
49
- </box>
50
- ))}
51
- <box style={{ flexGrow: 1 }} />
78
+ <box style={{ flexDirection: "row", flexGrow: 1 }}>
79
+ {columns.map((column, columnIndex) => (
80
+ <box key={columnIndex} style={{ flexDirection: "column", width: COLUMN_WIDTH }}>
81
+ {column.map((section) => (
82
+ <box key={section.title} style={{ flexDirection: "column" }}>
83
+ <text fg={tokens.accent}>{section.title}</text>
84
+ {section.entries.map((entry, index) => (
85
+ <text key={index}>
86
+ <span fg={tokens.text}>{entry.keys.padEnd(12)}</span>
87
+ <span fg={tokens.textMuted}>{entry.label}</span>
88
+ </text>
89
+ ))}
90
+ <text> </text>
91
+ </box>
92
+ ))}
93
+ </box>
94
+ ))}
95
+ </box>
52
96
  <box style={{ flexDirection: "row" }}>
53
97
  <box style={{ flexGrow: 1 }} />
54
98
  <text fg={tokens.textDim}>[esc]</text>
@@ -7,8 +7,22 @@ import { FIXTURE_ANNOTATIONS, fixturePlanSession } from "./story-fixtures";
7
7
 
8
8
  export const meta: StoryMeta = { title: "ReviewPanel" };
9
9
 
10
+ /** An empty Tree tab: the stories here show the review tab. */
11
+ const tree = {
12
+ rows: [],
13
+ canMove: true,
14
+ canFork: true,
15
+ onSelect: () => {},
16
+ onGo: () => {},
17
+ onBranch: () => {},
18
+ onLabel: () => {},
19
+ onFork: () => {},
20
+ onForkAndShare: () => {},
21
+ };
22
+
10
23
  const rail = {
11
24
  session: fixturePlanSession(),
25
+ tree,
12
26
  authorNames: {},
13
27
  selectedId: FIXTURE_ANNOTATIONS[0]!.id,
14
28
  resolvedIds: new Set(FIXTURE_ANNOTATIONS.map((annotation) => annotation.id)),
@@ -6,8 +6,22 @@ import { FIXTURE_ANNOTATIONS, fixtureDiffSession, fixturePlanSession } from "./s
6
6
 
7
7
  export const meta: StoryMeta = { title: "ReviewRail" };
8
8
 
9
+ /** An empty Tree tab: the stories here show the review tab. */
10
+ const tree = {
11
+ rows: [],
12
+ canMove: true,
13
+ canFork: true,
14
+ onSelect: () => {},
15
+ onGo: () => {},
16
+ onBranch: () => {},
17
+ onLabel: () => {},
18
+ onFork: () => {},
19
+ onForkAndShare: () => {},
20
+ };
21
+
9
22
  const callbacks = {
10
23
  curationItems: [],
24
+ tree,
11
25
  onTabChange: () => {},
12
26
  onSelectCard: () => {},
13
27
  onActivateCard: () => {},
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The right review rail: tab strip (Review/Agent), the scrollable annotation
2
+ * The right review rail: tab strip (Review/Tree/Agent), the scrollable annotation
3
3
  * stack, and the pinned submit affordance. The confirm card sits OUTSIDE the
4
4
  * scrollbox so the stack scrolls while the card stays at the rail bottom.
5
5
  * Card reveal goes through the scrollbox's own scrollChildIntoView - no
@@ -18,11 +18,12 @@ import { Toolbar } from "./primitives/Toolbar";
18
18
  import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
19
19
  import { ConfirmCard, type ConfirmCardProps } from "./ConfirmCard";
20
20
  import { AgentLauncher, type AgentTerminalHandle } from "./agent-launcher";
21
+ import { TreePane, type TreePaneProps } from "./TreePane";
21
22
  import { Card } from "./primitives/Card";
22
23
  import { truncateToSingleLine } from "./truncate-text";
23
24
  import { resolveDisplayName } from "../attribution";
24
25
 
25
- export type RailTab = "review" | "agent";
26
+ export type RailTab = "review" | "agent" | "tree";
26
27
 
27
28
  export interface RailCardEdit extends AnnotationDraft {
28
29
  id: string;
@@ -65,6 +66,8 @@ export interface ReviewRailProps {
65
66
  onLaunchHarness: (command: string, seedText?: string) => void;
66
67
  /** In-tab agent terminal handle (embedded path); null when detached. Lets the app route keys to it. */
67
68
  onAgentTerminal?: (handle: AgentTerminalHandle | null) => void;
69
+ /** The Tree tab: the session's history rows and the primitives that move it. */
70
+ tree: Omit<TreePaneProps, "theme">;
68
71
  /** Rail column width; the app derives it from the persisted review layout. */
69
72
  width?: number;
70
73
  /**
@@ -95,6 +98,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
95
98
  onSubmitRequest,
96
99
  onLaunchHarness,
97
100
  onAgentTerminal,
101
+ tree,
98
102
  width = 34,
99
103
  onCollapse,
100
104
  theme,
@@ -188,16 +192,19 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
188
192
  <Tabs
189
193
  selectedKey={railTab}
190
194
  onSelectionChange={(key) => {
191
- if (key === "review" || key === "agent") onTabChange(key);
195
+ if (key === "review" || key === "agent" || key === "tree") onTabChange(key);
192
196
  }}
193
197
  theme={theme}
194
198
  >
195
199
  <TabList>
196
200
  <Tab id="review">Review</Tab>
201
+ <Tab id="tree">Tree</Tab>
197
202
  <Tab id="agent">Agent</Tab>
198
203
  </TabList>
199
204
  </Tabs>
200
- {railTab === "agent" ? (
205
+ {railTab === "tree" ? (
206
+ <TreePane {...tree} theme={theme} />
207
+ ) : railTab === "agent" ? (
201
208
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
202
209
  <AgentLauncher
203
210
  session={session}
@@ -223,7 +230,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
223
230
  </box>
224
231
  )}
225
232
  {/* the confirm card keeps full width, above the footer row */}
226
- {railTab !== "agent" && submitConfirm ? (
233
+ {railTab === "review" && submitConfirm ? (
227
234
  <ConfirmCard {...submitConfirm} theme={theme} />
228
235
  ) : null}
229
236
  {/* footer row: collapse chevron left-bound, the submit affordance inline
@@ -234,7 +241,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
234
241
  <text fg={tokens.textDim}>{">"}</text>
235
242
  </box>
236
243
  ) : null}
237
- {railTab !== "agent" && !submitConfirm ? (
244
+ {railTab === "review" && !submitConfirm ? (
238
245
  session.status === "resolved" ? (
239
246
  <text fg={tokens.green}>resolved: {session.verdict!.kind.replace("_", " ")}</text>
240
247
  ) : (