@cueloop/client 0.1.0-alpha.66 → 0.1.0-alpha.68

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 (117) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +71 -39
  4. package/src/App.plan-review.test.tsx +20 -21
  5. package/src/App.readonly.test.tsx +7 -3
  6. package/src/App.test.tsx +73 -14
  7. package/src/App.tsx +375 -129
  8. package/src/App.walk.test.tsx +14 -15
  9. package/src/App.workbench.test.tsx +390 -0
  10. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  11. package/src/annotation-palette.ts +61 -0
  12. package/src/app-screens.tsx +176 -45
  13. package/src/app-view-model.ts +7 -65
  14. package/src/components/AnnotationCard.tsx +1 -1
  15. package/src/components/AnnotationCards.stories.tsx +87 -0
  16. package/src/components/AnnotationCards.tsx +303 -0
  17. package/src/components/AppHeader.stories.tsx +50 -0
  18. package/src/components/AppHeader.tsx +83 -0
  19. package/src/components/AppShell.stories.tsx +47 -42
  20. package/src/components/AppShell.tsx +212 -100
  21. package/src/components/AppShell.zoom.test.tsx +94 -0
  22. package/src/components/ChangesColumn.tsx +49 -26
  23. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  24. package/src/components/DiffSheet.split.test.tsx +140 -0
  25. package/src/components/DiffSheet.stories.tsx +55 -53
  26. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  27. package/src/components/DiffSheet.tsx +607 -276
  28. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  29. package/src/components/DiscussionMarkerRail.tsx +181 -0
  30. package/src/components/EditorGrid.stories.tsx +59 -0
  31. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  32. package/src/components/EditorGrid.tsx +338 -0
  33. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  34. package/src/components/FileContentsView.stories.tsx +16 -0
  35. package/src/components/FileContentsView.test.tsx +29 -0
  36. package/src/components/FileContentsView.tsx +78 -0
  37. package/src/components/InboxList.tsx +159 -30
  38. package/src/components/PaneGrid.stories.tsx +84 -0
  39. package/src/components/PaneGrid.tsx +128 -0
  40. package/src/components/PanelColumn.stories.tsx +25 -0
  41. package/src/components/PanelColumn.tsx +95 -0
  42. package/src/components/ProjectTreeView.stories.tsx +25 -0
  43. package/src/components/ProjectTreeView.test.tsx +32 -0
  44. package/src/components/ProjectTreeView.tsx +82 -0
  45. package/src/components/PromptDialog.tsx +8 -1
  46. package/src/components/RootOverlay.stories.tsx +44 -0
  47. package/src/components/RootOverlay.tsx +35 -0
  48. package/src/components/SettingsDialog.tsx +2 -1
  49. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  50. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  51. package/src/components/SurfaceScrollbar.tsx +93 -0
  52. package/src/components/ThreadFooter.stories.tsx +2 -2
  53. package/src/components/ThreadFooter.test.tsx +31 -0
  54. package/src/components/ThreadFooter.tsx +12 -7
  55. package/src/components/ThreadView.tsx +62 -1440
  56. package/src/components/ThreadsSidebar.tsx +14 -1
  57. package/src/components/Tooltip.stories.tsx +26 -0
  58. package/src/components/Tooltip.tsx +72 -0
  59. package/src/components/WelcomeSurface.stories.tsx +12 -0
  60. package/src/components/WelcomeSurface.tsx +52 -0
  61. package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
  62. package/src/components/diff-sheet-layout.test.ts +125 -55
  63. package/src/components/diff-sheet-layout.ts +3 -103
  64. package/src/components/editor-grid.test.ts +86 -0
  65. package/src/components/editor-grid.ts +138 -0
  66. package/src/components/fade-title.test.ts +49 -0
  67. package/src/components/fade-title.ts +61 -0
  68. package/src/components/file-tree.test.ts +15 -0
  69. package/src/components/file-tree.ts +59 -4
  70. package/src/components/pages/Workspace.stories.tsx +2 -2
  71. package/src/components/primitives/IconButton.tsx +21 -2
  72. package/src/components/primitives/Tree.tsx +13 -4
  73. package/src/components/primitives/icons.ts +27 -4
  74. package/src/components/session-tree.test.ts +20 -0
  75. package/src/components/session-tree.ts +16 -2
  76. package/src/components/stories-app.tsx +2 -2
  77. package/src/config.test.ts +20 -49
  78. package/src/config.ts +26 -28
  79. package/src/diff-fold.test.ts +78 -0
  80. package/src/diff-fold.ts +126 -0
  81. package/src/diff-syntax.test.ts +16 -0
  82. package/src/diff-syntax.ts +6 -3
  83. package/src/discussions.ts +79 -0
  84. package/src/ghostty-terminal.test.ts +2 -1
  85. package/src/ghostty-terminal.ts +27 -4
  86. package/src/intent-dispatch.test.ts +31 -11
  87. package/src/intent-dispatch.ts +93 -48
  88. package/src/key-bindings.test.ts +1 -1
  89. package/src/keymap.test.ts +3 -25
  90. package/src/keymap.ts +17 -18
  91. package/src/mark-runs.ts +120 -0
  92. package/src/pty.ts +10 -2
  93. package/src/run.ts +17 -7
  94. package/src/serve.test.ts +4 -8
  95. package/src/session-controller.curate.test.ts +75 -0
  96. package/src/session-controller.share.test.ts +1 -0
  97. package/src/session-controller.tree.test.ts +26 -0
  98. package/src/session-controller.ts +153 -29
  99. package/src/slash-palette.ts +83 -0
  100. package/src/split-diff.test.ts +110 -0
  101. package/src/split-diff.ts +149 -0
  102. package/src/test-support.test.ts +15 -3
  103. package/src/test-support.ts +66 -15
  104. package/src/thread-chords.test.ts +25 -9
  105. package/src/thread-chords.ts +33 -8
  106. package/src/use-annotation-surface.tsx +837 -0
  107. package/src/use-changes-workbench.ts +151 -0
  108. package/src/use-frame-measure.ts +38 -0
  109. package/src/use-settings-dialog.tsx +14 -24
  110. package/src/use-terminal-virtualizer.ts +233 -0
  111. package/src/view-diff.ts +114 -0
  112. package/src/components/ReviewPanel.stories.tsx +0 -100
  113. package/src/components/ReviewPanel.tsx +0 -103
  114. package/src/components/ReviewRail.stories.tsx +0 -232
  115. package/src/components/ReviewRail.tsx +0 -327
  116. package/src/review-panel.test.ts +0 -113
  117. package/src/review-panel.ts +0 -83
@@ -0,0 +1,54 @@
1
+ import React from "react";
2
+ import type { Annotation } from "@cueloop/schema";
3
+ import { DARK } from "../theme";
4
+ import type { Discussion } from "../discussions";
5
+ import type { TextSpan } from "../thread-selection";
6
+ import { RootOverlayProvider } from "./RootOverlay";
7
+ import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
8
+ import type { Story, StoryMeta } from "./story";
9
+
10
+ export const meta: StoryMeta = { title: "Chrome/DiscussionMarkerRail" };
11
+
12
+ function comment(id: string, body: string): Annotation {
13
+ return {
14
+ id,
15
+ kind: "comment",
16
+ anchor: { quote: body, prefix: "", suffix: "" },
17
+ body,
18
+ createdAt: "2026-09-08T00:00:00.000Z",
19
+ };
20
+ }
21
+
22
+ function discussion(row: number, body: string): Discussion {
23
+ const span: TextSpan = { start: { blockIndex: row, char: 0 }, end: { blockIndex: row, char: 4 } };
24
+
25
+ return {
26
+ key: `${row}`,
27
+ rootId: `a${row}`,
28
+ blockIndex: row,
29
+ span,
30
+ annotations: [comment(`a${row}`, body)],
31
+ };
32
+ }
33
+
34
+ const discussions = [
35
+ discussion(2, "Map needs an eviction story."),
36
+ discussion(5, "why a Map here?"),
37
+ discussion(9, "name this constant"),
38
+ ];
39
+
40
+ // The centered dot column: one dot per discussion, drawn on a surface's right edge.
41
+ export const Dots: Story = {
42
+ render: () => (
43
+ <RootOverlayProvider>
44
+ <box
45
+ style={{ width: 24, height: 10, flexDirection: "row", backgroundColor: DARK.background }}
46
+ >
47
+ <box style={{ flexGrow: 1 }} />
48
+ <DiscussionMarkerRail discussions={discussions} spanQuote={() => "x"} onJump={() => {}} />
49
+ </box>
50
+ </RootOverlayProvider>
51
+ ),
52
+ expectedColors: [DARK.textDim],
53
+ size: { width: 24, height: 10 },
54
+ };
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The right-edge discussion navigator: a vertically-centered column of dots, one
3
+ * per discussion, that highlights on hover, shows a floating preview, and jumps
4
+ * on click. The plan thread view and each diff editor group render one on their
5
+ * right edge, so comment navigation reads the same on prose and on code. The
6
+ * preview draws at the app root (OpenTUI has no z-index) so no pane border slices it.
7
+ */
8
+
9
+ import React, { useEffect, useRef, useState, type RefObject } from "react";
10
+ import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
11
+ import type { Theme } from "../theme";
12
+ import type { TextSpan } from "../thread-selection";
13
+ import type { Discussion } from "../discussions";
14
+ import { useFrameMeasure } from "../use-frame-measure";
15
+ import { useComponentTheme } from "./theme-context";
16
+ import { useRootOverlay } from "./RootOverlay";
17
+ import { SurfaceScrollbar } from "./SurfaceScrollbar";
18
+
19
+ interface HoveredMarker {
20
+ key: string;
21
+ screenY: number;
22
+ anchorX: number;
23
+ }
24
+
25
+ function ScrollMarkers({
26
+ discussions,
27
+ hovered,
28
+ tokens,
29
+ onHover,
30
+ onJump,
31
+ }: {
32
+ discussions: Discussion[];
33
+ hovered: string | null;
34
+ tokens: Theme;
35
+ onHover: (marker: HoveredMarker | null) => void;
36
+ onJump: (key: string) => void;
37
+ }): React.ReactNode {
38
+ const railRef = useRef<BoxRenderable | null>(null);
39
+ const height = useFrameMeasure(
40
+ () => railRef.current?.height ?? 0,
41
+ (left, right) => left === right,
42
+ 0,
43
+ );
44
+
45
+ const firstRow = Math.max(0, Math.floor((height - discussions.length) / 2));
46
+ const rowFor = (index: number): number => firstRow + index;
47
+ const indexAt = (row: number): number | null => {
48
+ const index = row - firstRow;
49
+
50
+ return index >= 0 && index < discussions.length ? index : null;
51
+ };
52
+ const hoveredIndex = discussions.findIndex((discussion) => discussion.key === hovered);
53
+ const colorFor = (index: number): string => {
54
+ if (index === hoveredIndex) return tokens.text;
55
+ if (hoveredIndex >= 0 && discussions.length >= 5 && Math.abs(index - hoveredIndex) === 1) {
56
+ return tokens.textMuted;
57
+ }
58
+
59
+ return tokens.textDim;
60
+ };
61
+
62
+ return (
63
+ <box
64
+ ref={railRef}
65
+ style={{ width: 3, flexShrink: 0, flexDirection: "column" }}
66
+ onMouseMove={(event) => {
67
+ const railY = railRef.current?.y ?? 0;
68
+ const index = indexAt(event.y - railY);
69
+
70
+ onHover(
71
+ index === null
72
+ ? null
73
+ : {
74
+ key: discussions[index]!.key,
75
+ // screen coordinates so the preview can render at the app root, above every pane rule
76
+ screenY: railY + rowFor(index),
77
+ anchorX: railRef.current?.x ?? 0,
78
+ },
79
+ );
80
+ }}
81
+ onMouseOut={() => onHover(null)}
82
+ onMouseDown={(event) => {
83
+ const index = indexAt(event.y - (railRef.current?.y ?? 0));
84
+
85
+ if (index !== null) onJump(discussions[index]!.key);
86
+ }}
87
+ >
88
+ {discussions.map((discussion, index) => (
89
+ <text
90
+ key={discussion.key}
91
+ selectable={false}
92
+ style={{ position: "absolute", top: rowFor(index), left: 0 }}
93
+ fg={colorFor(index)}
94
+ >
95
+ {(index === hoveredIndex ? "●" : "○").padStart(2)}
96
+ </text>
97
+ ))}
98
+ </box>
99
+ );
100
+ }
101
+
102
+ export interface DiscussionMarkerRailProps {
103
+ discussions: Discussion[];
104
+ /** The text a discussion's span covers, for the hover preview. */
105
+ spanQuote: (span: TextSpan) => string;
106
+ onJump: (key: string) => void;
107
+ /** The surface's scrollbox; when given, its scrollbar draws past the dots as the panel's rightmost column. */
108
+ scrollbox?: RefObject<ScrollBoxRenderable | null>;
109
+ theme?: Theme;
110
+ }
111
+
112
+ export function DiscussionMarkerRail({
113
+ discussions,
114
+ spanQuote,
115
+ onJump,
116
+ scrollbox,
117
+ theme,
118
+ }: DiscussionMarkerRailProps): React.ReactNode {
119
+ const tokens = useComponentTheme(theme);
120
+ const [hovered, setHovered] = useState<HoveredMarker | null>(null);
121
+ const { setOverlay, clearOverlay } = useRootOverlay();
122
+
123
+ const preview = (): React.ReactNode => {
124
+ if (hovered === null) return null;
125
+ const discussion = discussions.find((candidate) => candidate.key === hovered.key);
126
+
127
+ if (!discussion) return null;
128
+ const quote = spanQuote(discussion.span);
129
+ const lastComment = discussion.annotations.at(-1)!;
130
+
131
+ return (
132
+ <box
133
+ style={{
134
+ position: "absolute",
135
+ top: Math.max(0, hovered.screenY - 1),
136
+ left: Math.max(0, hovered.anchorX - 48),
137
+ width: 48,
138
+ flexDirection: "column",
139
+ border: true,
140
+ borderStyle: "single",
141
+ borderColor: tokens.border,
142
+ backgroundColor: tokens.elevated,
143
+ paddingLeft: 1,
144
+ paddingRight: 1,
145
+ }}
146
+ >
147
+ <text fg={tokens.textDim}>{`"${quote.slice(0, 42)}"`}</text>
148
+ <text fg={tokens.text}>
149
+ {`${lastComment.author === undefined ? "●" : "○"} ${lastComment.body}`.slice(0, 44)}
150
+ </text>
151
+ <text fg={tokens.textDim}>
152
+ {`${discussion.annotations.length} comment${
153
+ discussion.annotations.length === 1 ? "" : "s"
154
+ } · click to jump`}
155
+ </text>
156
+ </box>
157
+ );
158
+ };
159
+
160
+ // render the preview at the app root (above every pane rule); OpenTUI has no z-index, so an
161
+ // absolute box inside this pane would be sliced by the next pane's border
162
+ useEffect(() => {
163
+ setOverlay(preview());
164
+
165
+ return () => clearOverlay();
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ }, [hovered, discussions, tokens]);
168
+
169
+ return (
170
+ <>
171
+ <ScrollMarkers
172
+ discussions={discussions}
173
+ hovered={hovered?.key ?? null}
174
+ tokens={tokens}
175
+ onHover={setHovered}
176
+ onJump={onJump}
177
+ />
178
+ {scrollbox ? <SurfaceScrollbar scrollbox={scrollbox} theme={theme} /> : null}
179
+ </>
180
+ );
181
+ }
@@ -0,0 +1,59 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { EditorGrid } from "./EditorGrid";
5
+ import { changesTab, fileTab, makeGroup, splitGroup, type EditorTab } from "./editor-grid";
6
+
7
+ export const meta: StoryMeta = { title: "Surfaces/EditorGrid" };
8
+
9
+ const noop = (): void => {};
10
+
11
+ function renderTab(tab: EditorTab): React.ReactNode {
12
+ return (
13
+ <box style={{ flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
14
+ <text fg={DARK.textMuted}>{tab.kind === "changes" ? "the whole diff" : tab.path}</text>
15
+ </box>
16
+ );
17
+ }
18
+
19
+ const oneGroup = makeGroup([changesTab(), fileTab("App.tsx", "src/App.tsx", "diff")]);
20
+ const sourceGroup = makeGroup([fileTab("plugin.json", ".claude-plugin/plugin.json", "contents")]);
21
+ const splitTree = splitGroup(sourceGroup, sourceGroup.id, "right").tree;
22
+
23
+ export const OneGroup: Story = {
24
+ render: () => (
25
+ <EditorGrid
26
+ tree={oneGroup}
27
+ focusedGroupId={oneGroup.id}
28
+ onFocusGroup={noop}
29
+ onActivateTab={noop}
30
+ onCloseTab={noop}
31
+ onSplit={noop}
32
+ onZoom={noop}
33
+ zoomed={false}
34
+ renderTab={renderTab}
35
+ theme={DARK}
36
+ />
37
+ ),
38
+ expectedColors: [DARK.textMuted, DARK.border],
39
+ size: { width: 80, height: 16 },
40
+ };
41
+
42
+ export const SplitGroups: Story = {
43
+ render: () => (
44
+ <EditorGrid
45
+ tree={splitTree}
46
+ focusedGroupId={null}
47
+ onFocusGroup={noop}
48
+ onActivateTab={noop}
49
+ onCloseTab={noop}
50
+ onSplit={noop}
51
+ onZoom={noop}
52
+ zoomed={false}
53
+ renderTab={renderTab}
54
+ theme={DARK}
55
+ />
56
+ ),
57
+ expectedColors: [DARK.textMuted, DARK.border],
58
+ size: { width: 100, height: 16 },
59
+ };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Regression: file tabs keep the width of their name and the strip scrolls horizontally
3
+ * behind the header controls when the row overflows, revealing the active tab - instead of
4
+ * squeezing every tab and truncating its label.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import React from "react";
10
+ import { EditorGrid, visibleTabWindow } from "./EditorGrid";
11
+ import { changesTab, fileTab, makeGroup, type EditorTab } from "./editor-grid";
12
+ import { NERD } from "./primitives/icons";
13
+ import { allowEventLoopUpdates } from "../test-support";
14
+
15
+ describe("visibleTabWindow", () => {
16
+ const tabs: EditorTab[] = ["aaaa", "bbbb", "cccc", "dddd", "eeee"].map((name) =>
17
+ fileTab(name, name, "diff"),
18
+ );
19
+
20
+ test("an unmeasured strip shows every tab", () => {
21
+ // Arrange / Act / Assert - width 0 means the header clips until it is measured
22
+ expect(visibleTabWindow(tabs, 0, 0, 0)).toEqual({ first: 0, end: tabs.length });
23
+ });
24
+
25
+ test("only the run that fits renders, and the active tab pulls the window to it", () => {
26
+ // Arrange - each tab is 9 cells (4 label + 5 chrome); a 30-cell strip fits about three
27
+ // Act - the last tab is active
28
+ const window = visibleTabWindow(tabs, 0, tabs.length - 1, 30);
29
+
30
+ // Assert - the window ends on the active tab and dropped earlier tabs
31
+ expect(window.end).toBe(tabs.length);
32
+ expect(window.first).toBeGreaterThan(0);
33
+ });
34
+
35
+ test("a tab wider than the strip still shows on its own", () => {
36
+ // Arrange
37
+ const wide = [fileTab("a-very-long-file-name.tsx", "x", "diff")];
38
+
39
+ // Act / Assert
40
+ expect(visibleTabWindow(wide, 0, 0, 10)).toEqual({ first: 0, end: 1 });
41
+ });
42
+ });
43
+
44
+ const NAMES = [
45
+ "AppShell.tsx",
46
+ "DiffSheet.tsx",
47
+ "IconButton.tsx",
48
+ "EditorGrid.zoom.test.tsx",
49
+ "ThreadView.tsx",
50
+ ];
51
+
52
+ async function headerRow(activeIndex: number): Promise<string> {
53
+ const group = makeGroup([
54
+ changesTab(),
55
+ ...NAMES.map((name) => fileTab(name, `src/${name}`, "diff")),
56
+ ]);
57
+ const tree = { ...group, activeTabId: group.tabs[activeIndex]!.id };
58
+ const setup = await testRender(
59
+ <EditorGrid
60
+ tree={tree}
61
+ focusedGroupId={tree.id}
62
+ onFocusGroup={() => {}}
63
+ onActivateTab={() => {}}
64
+ onCloseTab={() => {}}
65
+ onSplit={() => {}}
66
+ onZoom={() => {}}
67
+ zoomed={false}
68
+ renderTab={() => <text>body</text>}
69
+ />,
70
+ { width: 70, height: 4 },
71
+ );
72
+
73
+ allowEventLoopUpdates();
74
+ await setup.waitForVisualIdle();
75
+ // the strip reveals the active tab on the frame after layout
76
+ await setup.waitForVisualIdle();
77
+ const row = setup.captureCharFrame().split("\n")[0]!;
78
+
79
+ setup.renderer.destroy();
80
+
81
+ return row;
82
+ }
83
+
84
+ describe("editor tab strip", () => {
85
+ test("tabs keep their full names and the controls stay pinned when the row overflows", async () => {
86
+ // Act
87
+ const row = await headerRow(0);
88
+
89
+ // Assert - no squeezed labels: every visible tab reads in full up to the clip edge
90
+ expect(row).toContain("Changes ");
91
+ expect(row).toContain("AppShell.tsx ");
92
+ expect(row).toContain("DiffSheet.tsx ");
93
+ // and the header controls sit at the right edge, past the clipped strip
94
+ expect(row.trimEnd().endsWith(`search ${NERD.zoom}`)).toBe(true);
95
+ });
96
+
97
+ test("the strip scrolls to reveal the active tab", async () => {
98
+ // Act
99
+ const row = await headerRow(NAMES.length);
100
+
101
+ // Assert - the last tab is on screen, the first has scrolled out behind the left edge
102
+ expect(row).toContain("ThreadView.tsx ");
103
+ expect(row).not.toContain("Changes ");
104
+ });
105
+ });