@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.34

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 (101) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +131 -5
  4. package/src/App.export.test.tsx +23 -5
  5. package/src/App.plan-review.test.tsx +133 -32
  6. package/src/App.readonly.test.tsx +35 -13
  7. package/src/App.rendering.test.tsx +14 -4
  8. package/src/App.review-panel.test.tsx +27 -15
  9. package/src/App.submit-confirm.test.tsx +14 -13
  10. package/src/App.test.tsx +50 -13
  11. package/src/App.toast.test.tsx +75 -0
  12. package/src/App.tsx +505 -101
  13. package/src/App.walk.test.tsx +31 -11
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +29 -10
  17. package/src/components/AnnotationCard.tsx +57 -37
  18. package/src/components/Breadcrumb.tsx +7 -1
  19. package/src/components/CodeBlock.stories.tsx +19 -2
  20. package/src/components/CodeBlock.tsx +24 -9
  21. package/src/components/CompletionOverlay.tsx +13 -3
  22. package/src/components/ConfirmCard.stories.tsx +22 -3
  23. package/src/components/ConfirmCard.tsx +23 -5
  24. package/src/components/ConfirmDialog.tsx +10 -2
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +35 -1
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +213 -101
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.stories.tsx +31 -0
  32. package/src/components/KeybindsDialog.tsx +58 -0
  33. package/src/components/MarkerPopover.stories.tsx +58 -0
  34. package/src/components/MarkerPopover.tsx +102 -0
  35. package/src/components/MenuBar.stories.tsx +37 -0
  36. package/src/components/MenuBar.tsx +74 -0
  37. package/src/components/PlanSheet.stories.tsx +35 -3
  38. package/src/components/PlanSheet.tsx +160 -100
  39. package/src/components/PromptDialog.tsx +19 -3
  40. package/src/components/ReviewPanel.stories.tsx +17 -19
  41. package/src/components/ReviewPanel.tsx +17 -32
  42. package/src/components/ReviewRail.stories.tsx +159 -47
  43. package/src/components/ReviewRail.tsx +206 -62
  44. package/src/components/SettingsDialog.stories.tsx +51 -3
  45. package/src/components/SettingsDialog.tsx +11 -5
  46. package/src/components/SettingsRows.stories.tsx +1 -1
  47. package/src/components/SettingsRows.tsx +19 -3
  48. package/src/components/Toast.stories.tsx +12 -0
  49. package/src/components/Toast.tsx +44 -0
  50. package/src/components/WalkWizard.stories.tsx +13 -2
  51. package/src/components/WalkWizard.tsx +9 -3
  52. package/src/components/__snapshots__/stories.test.tsx.snap +853 -623
  53. package/src/components/diff-sheet-layout.test.ts +127 -0
  54. package/src/components/diff-sheet-layout.ts +167 -0
  55. package/src/components/primitives/Button.stories.tsx +2 -2
  56. package/src/components/primitives/Button.tsx +5 -1
  57. package/src/components/primitives/Dialog.tsx +12 -1
  58. package/src/components/primitives/Tabs.tsx +40 -39
  59. package/src/components/stories-app.tsx +34 -6
  60. package/src/components/story-fixtures.ts +11 -3
  61. package/src/components/story.ts +3 -1
  62. package/src/components/syntax-highlight.ts +47 -12
  63. package/src/components/theme-context.stories.tsx +6 -1
  64. package/src/components/theme-context.tsx +7 -1
  65. package/src/config.test.ts +178 -2
  66. package/src/config.ts +121 -23
  67. package/src/diff-hunk-curate.test.ts +315 -0
  68. package/src/diff-hunk-curate.ts +233 -0
  69. package/src/diff-intraline.test.ts +164 -0
  70. package/src/diff-intraline.ts +132 -0
  71. package/src/diff-syntax.test.ts +96 -0
  72. package/src/diff-syntax.ts +154 -0
  73. package/src/editor.test.ts +13 -4
  74. package/src/editor.ts +31 -5
  75. package/src/herdr.ts +8 -2
  76. package/src/index.ts +9 -1
  77. package/src/integrations.ts +3 -1
  78. package/src/intent-dispatch.test.ts +193 -9
  79. package/src/intent-dispatch.ts +151 -18
  80. package/src/key-bindings.test.ts +19 -9
  81. package/src/key-bindings.ts +77 -16
  82. package/src/keymap.test.ts +171 -26
  83. package/src/keymap.ts +83 -16
  84. package/src/review-panel.test.ts +3 -1
  85. package/src/review-panel.ts +9 -2
  86. package/src/serve.test.ts +13 -5
  87. package/src/session-controller.curate.test.ts +253 -0
  88. package/src/session-controller.share.test.ts +77 -16
  89. package/src/session-controller.ts +319 -26
  90. package/src/share.ts +41 -8
  91. package/src/test-support.ts +97 -23
  92. package/src/theme-presets.ts +168 -0
  93. package/src/theme.ts +1 -3
  94. package/src/version.ts +5 -0
  95. package/src/view-diff.ts +15 -4
  96. package/src/view-plan.test.ts +40 -6
  97. package/src/view-plan.ts +71 -28
  98. package/src/walk.test.ts +7 -2
  99. package/src/walk.ts +7 -2
  100. package/src/components/ComposeBar.stories.tsx +0 -18
  101. package/src/components/ComposeBar.tsx +0 -54
@@ -0,0 +1,123 @@
1
+ /** Keyboard scroll is smooth: cursor-follow never drifts past wrapped cards. */
2
+
3
+ import { expect, test } from "bun:test";
4
+ import React, { useState } from "react";
5
+ import { testRender } from "@opentui/react/test-utils";
6
+ import { useKeyboard } from "@opentui/react";
7
+ import type { Renderable, ScrollBoxRenderable } from "@opentui/core";
8
+ import type { Annotation } from "@cueloop/schema";
9
+ import { DiffSheet } from "./DiffSheet";
10
+ import { diffRows, type DiffRow } from "../view-diff";
11
+ import { DARK } from "../theme";
12
+ import { press } from "../test-support";
13
+
14
+ /** Find a renderable by id anywhere in the tree (getRenderable is not recursive). */
15
+ function findById(node: Renderable, id: string): Renderable | undefined {
16
+ if (node.id === id) return node;
17
+ for (const child of node.getChildren()) {
18
+ const found = findById(child, id);
19
+ if (found) return found;
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ function hex(color: { toInts(): [number, number, number, number] }): string {
25
+ const [red, green, blue] = color.toInts();
26
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
27
+ }
28
+
29
+ /** A single-hunk patch of `count` added lines, taller than any test viewport. */
30
+ function tallPatch(count: number): string {
31
+ const adds = Array.from(
32
+ { length: count },
33
+ (_, index) => `+line ${String(index).padStart(2, "0")}`,
34
+ );
35
+ return [
36
+ "diff --git a/f.ts b/f.ts",
37
+ "--- a/f.ts",
38
+ "+++ b/f.ts",
39
+ `@@ -0,0 +1,${count} @@`,
40
+ ...adds,
41
+ "",
42
+ ].join("\n");
43
+ }
44
+
45
+ /** DiffSheet with j-driven cursor, mirroring the App's down-navigation. */
46
+ function ScrollHarness({
47
+ rows,
48
+ annotations,
49
+ }: {
50
+ rows: DiffRow[];
51
+ annotations: Annotation[];
52
+ }): React.ReactNode {
53
+ const [cursor, setCursor] = useState(0);
54
+ useKeyboard((key) => {
55
+ if (key.name === "j") setCursor((current) => Math.min(rows.length - 1, current + 1));
56
+ });
57
+ return <DiffSheet rows={rows} cursor={cursor} annotations={annotations} />;
58
+ }
59
+
60
+ test("scrolling down past wrapped annotation cards keeps the cursor pinned and the scroll monotonic", async () => {
61
+ // Arrange - a tall diff whose annotation bodies would wrap at this width; the
62
+ // wrapped card is exactly the layout the cursor-follow model used to mis-measure
63
+ const rows = diffRows(tallPatch(40));
64
+ const longBody =
65
+ "this is a deliberately long annotation body that would wrap across the narrow test viewport";
66
+ const annotations: Annotation[] = [8, 16, 24].map((rowIndex) => ({
67
+ id: `a${rowIndex}`,
68
+ kind: "comment",
69
+ body: longBody,
70
+ anchor: { quote: rows[rowIndex]!.text, prefix: "", suffix: "" },
71
+ createdAt: "2026-01-01T00:00:00Z",
72
+ }));
73
+ const setup = await testRender(<ScrollHarness rows={rows} annotations={annotations} />, {
74
+ width: 60,
75
+ height: 12,
76
+ });
77
+ await setup.waitForVisualIdle();
78
+ const scrollbox = findById(setup.renderer.root, "diff-scroll") as ScrollBoxRenderable;
79
+
80
+ // the cursor row is the one painted with the cursor background
81
+ const cursorScreenRow = (): number => {
82
+ const lines = setup.captureSpans().lines;
83
+ for (let index = 0; index < lines.length; index++) {
84
+ if (lines[index]!.spans.some((span) => hex(span.bg) === DARK.cursorBackground)) return index;
85
+ }
86
+ return -1;
87
+ };
88
+
89
+ // Act - walk the cursor down through the whole diff, sampling the scroll each step
90
+ const scrollTops: number[] = [];
91
+ const screenRows: number[] = [];
92
+ for (let step = 0; step < rows.length; step++) {
93
+ scrollTops.push(scrollbox.scrollTop);
94
+ screenRows.push(cursorScreenRow());
95
+ await press(setup, "j");
96
+ }
97
+
98
+ // Assert - scrollTop only ever grows going down (never rebounds/jitters)
99
+ for (let step = 1; step < scrollTops.length; step++) {
100
+ expect(scrollTops[step]!).toBeGreaterThanOrEqual(scrollTops[step - 1]!);
101
+ }
102
+
103
+ // Assert - the cursor never scrolls off screen (the pre-fix bug walked it off
104
+ // the viewport as it passed each wrapped card)
105
+ expect(screenRows.every((row) => row >= 0)).toBe(true);
106
+
107
+ // Assert - through the steady scroll region (not the initial fill, not the
108
+ // final clamp at the content end) the cursor holds ONE stable row near the
109
+ // bottom; pre-fix it drifted down a row per wrapped card
110
+ const maxScroll = scrollbox.scrollHeight - scrollbox.height;
111
+ const steadyRows = screenRows.filter(
112
+ (_, step) => scrollTops[step]! > 0 && scrollTops[step]! < maxScroll,
113
+ );
114
+ expect(steadyRows.length).toBeGreaterThan(0);
115
+ expect(new Set(steadyRows).size).toBe(1);
116
+ const pinnedRow = steadyRows[0]!;
117
+ const viewportBottom = scrollbox.height;
118
+ // the cursor sits a couple of rows inside the bottom edge, per the follow rule
119
+ expect(viewportBottom - pinnedRow).toBeGreaterThanOrEqual(2);
120
+ expect(viewportBottom - pinnedRow).toBeLessThanOrEqual(3);
121
+
122
+ setup.renderer.destroy();
123
+ }, 25000);
@@ -12,12 +12,43 @@ export const SignedRows: Story = {
12
12
  size: { width: 90, height: 16 },
13
13
  };
14
14
 
15
+ // A modified line keeps the diff color on the changed words and dims the
16
+ // unchanged prefix to the dim token, so the intra-line change stands out.
17
+ export const IntralineWordDiff: Story = {
18
+ render: () => <DiffSheet rows={fixtureDiffRows()} cursor={0} annotations={[]} />,
19
+ expectedColors: [DARK.insertedForeground, DARK.deletedForeground, DARK.textDim],
20
+ size: { width: 90, height: 16 },
21
+ };
22
+
15
23
  export const CursorOnAddedLine: Story = {
16
24
  render: () => <DiffSheet rows={fixtureDiffRows()} cursor={4} annotations={[]} />,
17
25
  expectedColors: [DARK.cursorBackground],
18
26
  size: { width: 90, height: 16 },
19
27
  };
20
28
 
29
+ export const ComposingOnLine: Story = {
30
+ render: () => (
31
+ <DiffSheet
32
+ rows={fixtureDiffRows()}
33
+ cursor={4}
34
+ annotations={[]}
35
+ compose={{
36
+ kind: "comment",
37
+ rowIndex: 4,
38
+ quote: " private items = new Map();",
39
+ draft: {
40
+ text: "Map needs an eviction story.",
41
+ onInput: () => {},
42
+ onSave: () => {},
43
+ onCancel: () => {},
44
+ },
45
+ }}
46
+ />
47
+ ),
48
+ expectedColors: [DARK.accent],
49
+ size: { width: 90, height: 16 },
50
+ };
51
+
21
52
  export const AnnotatedLine: Story = {
22
53
  render: () => (
23
54
  <DiffSheet
@@ -29,7 +60,10 @@ export const AnnotatedLine: Story = {
29
60
  kind: "comment",
30
61
  anchor: {
31
62
  // diff anchors quote the row text verbatim, trailing newline included
32
- quote: FIXTURE_PATCH.split("\n").find((line) => line.startsWith("+ "))!.slice(1) + "\n",
63
+ quote:
64
+ FIXTURE_PATCH.split("\n")
65
+ .find((line) => line.startsWith("+ "))!
66
+ .slice(1) + "\n",
33
67
  prefix: "",
34
68
  suffix: "",
35
69
  },
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { testRender } from "@opentui/react/test-utils";
3
+ import type { RGBA } from "@opentui/core";
4
+ import React from "react";
5
+ import { DiffSheet } from "./DiffSheet";
6
+ import { diffRows } from "../view-diff";
7
+ import { DARK } from "../theme";
8
+ import { allowEventLoopUpdates } from "../test-support";
9
+
10
+ const PATCH = `diff --git a/src/x.ts b/src/x.ts
11
+ --- a/src/x.ts
12
+ +++ b/src/x.ts
13
+ @@ -1,3 +1,3 @@
14
+ export class Store {
15
+ - const limit = 100;
16
+ + const limit = 250;
17
+ }
18
+ `;
19
+
20
+ function hex(color: RGBA): string {
21
+ const [red, green, blue] = color.toInts();
22
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
23
+ }
24
+
25
+ beforeEach(() => allowEventLoopUpdates());
26
+ afterEach(() => allowEventLoopUpdates());
27
+
28
+ test("renders tree-sitter colors, with the changed word in the diff color on top", async () => {
29
+ // Arrange
30
+ const setup = await testRender(<DiffSheet rows={diffRows(PATCH)} cursor={0} annotations={[]} />, {
31
+ width: 60,
32
+ height: 12,
33
+ });
34
+
35
+ // Act
36
+ // syntax highlighting resolves off the render path; pump until a keyword paints
37
+ let keywordAccent = false;
38
+ for (let attempt = 0; attempt < 60 && !keywordAccent; attempt++) {
39
+ await setup.renderOnce();
40
+ await new Promise((resolve) => setTimeout(resolve, 20));
41
+ for (const line of setup.captureSpans().lines) {
42
+ for (const span of line.spans) {
43
+ if (span.text.includes("export") && hex(span.fg) === DARK.accent) keywordAccent = true;
44
+ }
45
+ }
46
+ }
47
+
48
+ // Assert
49
+ expect(keywordAccent).toBe(true);
50
+ // the intra-line changed number keeps the diff color over its syntax color
51
+ const changedColors = new Set<string>();
52
+ for (const line of setup.captureSpans().lines) {
53
+ for (const span of line.spans) {
54
+ if (span.text.includes("250")) changedColors.add(hex(span.fg));
55
+ if (span.text.includes("100")) changedColors.add(hex(span.fg));
56
+ }
57
+ }
58
+ expect(changedColors.has(DARK.insertedForeground)).toBe(true);
59
+ expect(changedColors.has(DARK.deletedForeground)).toBe(true);
60
+
61
+ setup.renderer.destroy();
62
+ }, 25000);
@@ -1,75 +1,101 @@
1
1
  /**
2
- * The diff review sheet: every row of the patch renders (no window cap)
3
- * inside a scrollbox, with file headers, hunk headers, and contiguous line
4
- * chunks. Line chunks draw through the native line-number gutter - custom
5
- * numbers per row (old numbers on deletions), the cursor as a gutter sign,
6
- * and annotation markers as line signs. Chunks split after an annotated row
7
- * so the annotation body renders directly under its line.
2
+ * The diff review sheet: a thin renderer over diff-sheet-layout. Rows render (no
3
+ * window cap) in a scrollbox with file/hunk headers and contiguous code chunks.
4
+ * A chunk draws through the native line-number gutter - custom numbers per row,
5
+ * the cursor as a gutter sign, annotation markers as line signs - and closes
6
+ * after an annotated row so the annotation body sits under its line. A
7
+ * curated-out row renders struck through.
8
8
  */
9
9
 
10
- import React, { useEffect, useMemo, useRef } from "react";
11
- import type { LineNumberRenderable, LineSign, ScrollBoxRenderable } from "@opentui/core";
10
+ import React, { useEffect, useMemo, useRef, useState } from "react";
11
+ import {
12
+ createTextAttributes,
13
+ type LineNumberRenderable,
14
+ type LineSign,
15
+ type ScrollBoxRenderable,
16
+ } from "@opentui/core";
12
17
  import type { Annotation } from "@cueloop/schema";
13
18
  import type { DiffRow } from "../view-diff";
14
19
  import type { Theme } from "../theme";
15
20
  import { useComponentTheme } from "./theme-context";
16
21
  import { truncateToSingleLine } from "./truncate-text";
22
+ import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
23
+ import { FRAME_BORDER_STYLE } from "./primitives/frame";
24
+ import { intralineRunsByRow, type IntralineRun } from "../diff-intraline";
25
+ import { highlightDiffRows, type SyntaxSpan } from "../diff-syntax";
26
+ import {
27
+ annotatedRowsByIndex,
28
+ coloredRowSpans,
29
+ rowContentOffsets,
30
+ rowLine,
31
+ segmentRows,
32
+ type DiffSegment,
33
+ } from "./diff-sheet-layout";
34
+
35
+ /** Shared empty map so an unresolved/stale highlight state is a stable value. */
36
+ const EMPTY_SYNTAX: Map<number, SyntaxSpan[]> = new Map();
37
+
38
+ /** Shared empty set so "nothing rejected" is a stable identity for renders. */
39
+ const EMPTY_REJECTED: Set<number> = new Set();
40
+
41
+ /** A rejected (curated-out) change row renders struck through and dimmed. */
42
+ const REJECTED_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
43
+
44
+ export interface DiffComposeState {
45
+ kind: "comment";
46
+ rowIndex: number;
47
+ quote: string;
48
+ draft: AnnotationDraft;
49
+ }
17
50
 
18
51
  export interface DiffSheetProps {
19
52
  rows: DiffRow[];
20
53
  cursor: number;
21
54
  annotations: Annotation[];
22
55
  focusedAnnotationId?: string;
56
+ /** Row indices the owner rejected during curation; drawn struck through. */
57
+ rejectedRows?: Set<number>;
58
+ compose?: DiffComposeState | null;
23
59
  theme?: Theme;
24
60
  }
25
61
 
26
- type DiffSegment =
27
- | { kind: "header"; rowIndex: number; row: DiffRow }
28
- | { kind: "chunk"; firstRowIndex: number; rows: DiffRow[]; annotation: Annotation | null };
29
-
30
- /** Row text carries the patch's trailing newline; rendering strips it. */
31
- function rowLine(row: DiffRow): string {
32
- return row.text.replace(/\n$/, "");
33
- }
34
-
35
- /** Chunks split after an annotated row so its card can sit directly below. */
36
- function segmentRows(rows: DiffRow[], annotatedByRow: Map<number, Annotation>): DiffSegment[] {
37
- const segments: DiffSegment[] = [];
38
- let chunk: DiffRow[] = [];
39
- let chunkStart = 0;
40
- const closeChunk = (annotation: Annotation | null): void => {
41
- if (chunk.length) segments.push({ kind: "chunk", firstRowIndex: chunkStart, rows: chunk, annotation });
42
- chunk = [];
43
- };
44
- for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
45
- const row = rows[rowIndex]!;
46
- if (row.kind === "file" || row.kind === "hunk") {
47
- closeChunk(null);
48
- segments.push({ kind: "header", rowIndex, row });
49
- chunkStart = rowIndex + 1;
50
- continue;
51
- }
52
- if (!chunk.length) chunkStart = rowIndex;
53
- chunk.push(row);
54
- const annotation = annotatedByRow.get(rowIndex);
55
- if (annotation) {
56
- closeChunk(annotation);
57
- chunkStart = rowIndex + 1;
58
- }
59
- }
60
- closeChunk(null);
61
- return segments;
62
+ /** The sign, base color, and background a row draws with. */
63
+ function rowStyle(
64
+ row: DiffRow,
65
+ isCursorRow: boolean,
66
+ isAnnotatedRow: boolean,
67
+ tokens: Theme,
68
+ ): { sign: string; baseColor: string; background: string | undefined } {
69
+ const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
70
+ const baseColor =
71
+ row.kind === "add"
72
+ ? tokens.insertedForeground
73
+ : row.kind === "del"
74
+ ? tokens.deletedForeground
75
+ : tokens.textMuted;
76
+ const background = isCursorRow
77
+ ? tokens.cursorBackground
78
+ : isAnnotatedRow
79
+ ? tokens.markCommentBackground
80
+ : undefined;
81
+ return { sign, baseColor, background };
62
82
  }
63
83
 
64
84
  function DiffChunk({
65
85
  segment,
66
86
  cursor,
67
87
  focusedAnnotationId,
88
+ intralineByRow,
89
+ syntaxByRow,
90
+ rejectedRows,
68
91
  theme,
69
92
  }: {
70
93
  segment: Extract<DiffSegment, { kind: "chunk" }>;
71
94
  cursor: number;
72
95
  focusedAnnotationId?: string;
96
+ intralineByRow: Map<number, IntralineRun[]>;
97
+ syntaxByRow: Map<number, SyntaxSpan[]>;
98
+ rejectedRows: Set<number>;
73
99
  theme?: Theme;
74
100
  }): React.ReactNode {
75
101
  const tokens = useComponentTheme(theme);
@@ -100,16 +126,21 @@ function DiffChunk({
100
126
  // the renderable's config parser requires the gutter key to be present
101
127
  const colors = new Map<number, { gutter: string | undefined; content: string }>();
102
128
  if (segment.annotation) {
103
- colors.set(segment.rows.length - 1, { gutter: undefined, content: tokens.markCommentBackground });
129
+ colors.set(segment.rows.length - 1, {
130
+ gutter: undefined,
131
+ content: tokens.markCommentBackground,
132
+ });
104
133
  }
105
134
  if (cursorInChunk >= 0 && cursorInChunk < segment.rows.length) {
106
- colors.set(cursorInChunk, { gutter: tokens.cursorBackground, content: tokens.cursorBackground });
135
+ colors.set(cursorInChunk, {
136
+ gutter: tokens.cursorBackground,
137
+ content: tokens.cursorBackground,
138
+ });
107
139
  }
108
140
  return colors;
109
141
  }, [segment, cursorInChunk, tokens]);
110
142
 
111
- // gutter state flows through the imperative surface: the renderable keeps
112
- // no prop setters for these maps, so updates land via effects
143
+ // the gutter renderable has no prop setters for these maps, so they land via an effect
113
144
  useEffect(() => {
114
145
  const gutter = gutterRef.current;
115
146
  if (!gutter) return;
@@ -132,24 +163,60 @@ function DiffChunk({
132
163
  >
133
164
  <text style={{ wrapMode: "none" }} selectable={false}>
134
165
  {segment.rows.map((row, lineIndex) => {
135
- const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
136
- const foreground = row.kind === "add" ? tokens.insertedForeground : row.kind === "del" ? tokens.deletedForeground : tokens.textMuted;
137
166
  const isCursorRow = lineIndex === cursorInChunk;
138
- const isAnnotatedRow = segment.annotation !== null && lineIndex === segment.rows.length - 1;
167
+ const isAnnotatedRow =
168
+ segment.annotation !== null && lineIndex === segment.rows.length - 1;
169
+ const { sign, baseColor, background } = rowStyle(
170
+ row,
171
+ isCursorRow,
172
+ isAnnotatedRow,
173
+ tokens,
174
+ );
175
+ const prefix = (lineIndex > 0 ? "\n" : "") + sign;
176
+ const absoluteRowIndex = segment.firstRowIndex + lineIndex;
177
+
178
+ // A rejected change collapses to one struck-through, dimmed span, so
179
+ // it reads as excluded regardless of its syntax or intra-line colors.
180
+ if (rejectedRows.has(absoluteRowIndex)) {
181
+ return (
182
+ <span
183
+ key={lineIndex}
184
+ fg={tokens.textDim}
185
+ bg={background}
186
+ attributes={REJECTED_ATTRIBUTES}
187
+ >
188
+ {prefix + rowLine(row)}
189
+ </span>
190
+ );
191
+ }
192
+
193
+ const spans = coloredRowSpans(
194
+ rowLine(row),
195
+ intralineByRow.get(absoluteRowIndex),
196
+ syntaxByRow.get(absoluteRowIndex),
197
+ baseColor,
198
+ tokens,
199
+ );
139
200
  return (
140
- <span
141
- key={lineIndex}
142
- fg={foreground}
143
- bg={isCursorRow ? tokens.cursorBackground : isAnnotatedRow ? tokens.markCommentBackground : undefined}
144
- >
145
- {(lineIndex > 0 ? "\n" : "") + sign + rowLine(row)}
146
- </span>
201
+ <React.Fragment key={lineIndex}>
202
+ <span fg={baseColor} bg={background}>
203
+ {prefix}
204
+ </span>
205
+ {spans.map((span, spanIndex) => (
206
+ <span key={spanIndex} fg={span.foreground} bg={background}>
207
+ {span.text}
208
+ </span>
209
+ ))}
210
+ </React.Fragment>
147
211
  );
148
212
  })}
149
213
  </text>
150
214
  </line-number>
215
+
151
216
  {segment.annotation ? (
152
- <text>
217
+ // one row exactly: wrapMode none keeps the model's 1-line-per-card offset
218
+ // honest, so the cursor-follow scroll never drifts past a wrapped card
219
+ <text style={{ wrapMode: "none" }}>
153
220
  <span fg={tokens.textDim}>{" "}</span>
154
221
  <span fg={segment.annotation.id === focusedAnnotationId ? tokens.text : tokens.accent}>
155
222
  ◆ {truncateToSingleLine(segment.annotation.body, 70)}
@@ -160,41 +227,52 @@ function DiffChunk({
160
227
  );
161
228
  }
162
229
 
163
- export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, theme }: DiffSheetProps): React.ReactNode {
230
+ /** Async tree-sitter highlights, discarded when they belong to superseded rows. */
231
+ function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
232
+ const [highlighted, setHighlighted] = useState<{
233
+ rows: DiffRow[];
234
+ byRow: Map<number, SyntaxSpan[]>;
235
+ }>({
236
+ rows,
237
+ byRow: EMPTY_SYNTAX,
238
+ });
239
+ useEffect(() => {
240
+ let active = true;
241
+ void highlightDiffRows(rows).then((byRow) => {
242
+ if (active) setHighlighted({ rows, byRow });
243
+ });
244
+ return () => {
245
+ active = false;
246
+ };
247
+ }, [rows]);
248
+ return highlighted.rows === rows ? highlighted.byRow : EMPTY_SYNTAX;
249
+ }
250
+
251
+ export function DiffSheet({
252
+ rows,
253
+ cursor,
254
+ annotations,
255
+ focusedAnnotationId,
256
+ rejectedRows = EMPTY_REJECTED,
257
+ compose,
258
+ theme,
259
+ }: DiffSheetProps): React.ReactNode {
164
260
  const tokens = useComponentTheme(theme);
165
261
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
166
262
 
167
- const annotatedByRow = useMemo(() => {
168
- const byRow = new Map<number, Annotation>();
169
- for (const annotation of annotations) {
170
- const rowIndex = rows.findIndex(
171
- (row) => row.text === annotation.anchor.quote && (row.kind === "ctx" || row.kind === "add" || row.kind === "del"),
172
- );
173
- if (rowIndex !== -1) byRow.set(rowIndex, annotation);
174
- }
175
- return byRow;
176
- }, [rows, annotations]);
177
-
178
- const segments = useMemo(() => segmentRows(rows, annotatedByRow), [rows, annotatedByRow]);
179
-
180
- // content y offset per row index, for cursor-following scroll
181
- const rowOffsets = useMemo(() => {
182
- const offsets: number[] = [];
183
- let contentY = 0;
184
- for (const segment of segments) {
185
- if (segment.kind === "header") {
186
- offsets[segment.rowIndex] = contentY;
187
- contentY += 1;
188
- } else {
189
- segment.rows.forEach((_, lineIndex) => {
190
- offsets[segment.firstRowIndex + lineIndex] = contentY + lineIndex;
191
- });
192
- contentY += segment.rows.length + (segment.annotation ? 1 : 0);
193
- }
194
- }
195
- return offsets;
196
- }, [segments]);
263
+ const annotatedByRow = useMemo(
264
+ () => annotatedRowsByIndex(rows, annotations),
265
+ [rows, annotations],
266
+ );
267
+ const segments = useMemo(
268
+ () => segmentRows(rows, annotatedByRow, compose?.rowIndex),
269
+ [rows, annotatedByRow, compose?.rowIndex],
270
+ );
271
+ const intralineByRow = useMemo(() => intralineRunsByRow(rows), [rows]);
272
+ const syntaxByRow = useSyntaxHighlights(rows);
273
+ const rowOffsets = useMemo(() => rowContentOffsets(segments), [segments]);
197
274
 
275
+ // follow the cursor: keep it a couple of rows inside the viewport
198
276
  useEffect(() => {
199
277
  const scrollbox = scrollRef.current;
200
278
  const cursorOffset = rowOffsets[cursor];
@@ -208,33 +286,67 @@ export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, them
208
286
  }, [cursor, rowOffsets]);
209
287
 
210
288
  return (
211
- <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
212
- <scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
289
+ <box
290
+ style={{
291
+ flexGrow: 1,
292
+ flexDirection: "column",
293
+ border: true,
294
+ borderStyle: FRAME_BORDER_STYLE,
295
+ borderColor: tokens.text,
296
+ paddingLeft: 1,
297
+ paddingTop: 0,
298
+ }}
299
+ >
300
+ <scrollbox id="diff-scroll" ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
213
301
  {segments.map((segment, segmentIndex) => {
214
302
  if (segment.kind === "header") {
215
303
  const isCursor = segment.rowIndex === cursor;
216
304
  if (segment.row.kind === "file") {
217
305
  return (
218
- <text key={segmentIndex} fg={tokens.text} bg={isCursor ? tokens.cursorBackground : tokens.panel}>
306
+ <text
307
+ key={segmentIndex}
308
+ fg={tokens.text}
309
+ bg={isCursor ? tokens.cursorBackground : tokens.panel}
310
+ style={{ wrapMode: "none" }}
311
+ >
219
312
  {isCursor ? "▎" : " "}■ {rowLine(segment.row)}
220
313
  </text>
221
314
  );
222
315
  }
223
316
  return (
224
- <text key={segmentIndex} fg={tokens.blue} bg={isCursor ? tokens.cursorBackground : undefined}>
317
+ <text
318
+ key={segmentIndex}
319
+ fg={tokens.blue}
320
+ bg={isCursor ? tokens.cursorBackground : undefined}
321
+ style={{ wrapMode: "none" }}
322
+ >
225
323
  {isCursor ? "▎" : " "}
226
324
  {rowLine(segment.row)}
227
325
  </text>
228
326
  );
229
327
  }
328
+
329
+ const lastRowIndex = segment.firstRowIndex + segment.rows.length - 1;
230
330
  return (
231
- <DiffChunk
232
- key={segmentIndex}
233
- segment={segment}
234
- cursor={cursor}
235
- focusedAnnotationId={focusedAnnotationId}
236
- theme={theme}
237
- />
331
+ <React.Fragment key={segmentIndex}>
332
+ <DiffChunk
333
+ segment={segment}
334
+ cursor={cursor}
335
+ focusedAnnotationId={focusedAnnotationId}
336
+ intralineByRow={intralineByRow}
337
+ syntaxByRow={syntaxByRow}
338
+ rejectedRows={rejectedRows}
339
+ theme={theme}
340
+ />
341
+ {compose && compose.rowIndex === lastRowIndex ? (
342
+ <AnnotationCard
343
+ kind={compose.kind}
344
+ quote={compose.quote}
345
+ draft={compose.draft}
346
+ theme={theme}
347
+ />
348
+ ) : null}
349
+ </React.Fragment>
238
350
  );
239
351
  })}
240
352
  </scrollbox>
@@ -6,13 +6,26 @@ import { fixtureDiffSession, fixturePlanSession } from "./story-fixtures";
6
6
 
7
7
  export const meta: StoryMeta = { title: "InboxList" };
8
8
 
9
+ /** InboxList is the body under App's header/menu chrome; a frame gives it height. */
10
+ function BodyFrame({ children }: { children: React.ReactNode }): React.ReactNode {
11
+ return <box style={{ flexDirection: "column", width: "100%", height: "100%" }}>{children}</box>;
12
+ }
13
+
9
14
  export const TwoPending: Story = {
10
- render: () => <InboxList inbox={[fixturePlanSession(), fixtureDiffSession()]} cursor={0} />,
11
- expectedColors: [DARK.accent, DARK.cursorBackground],
15
+ render: () => (
16
+ <BodyFrame>
17
+ <InboxList inbox={[fixturePlanSession(), fixtureDiffSession()]} cursor={0} />
18
+ </BodyFrame>
19
+ ),
20
+ expectedColors: [DARK.cursorBackground],
12
21
  size: { width: 80, height: 12 },
13
22
  };
14
23
 
15
24
  export const Empty: Story = {
16
- render: () => <InboxList inbox={[]} cursor={0} />,
25
+ render: () => (
26
+ <BodyFrame>
27
+ <InboxList inbox={[]} cursor={0} />
28
+ </BodyFrame>
29
+ ),
17
30
  size: { width: 80, height: 8 },
18
31
  };