@cueloop/client 0.1.0-alpha.68 → 0.1.0-alpha.69
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 +7 -6
- package/src/App.appearance.test.tsx +8 -6
- package/src/App.diff.test.tsx +3 -3
- package/src/App.export.test.tsx +9 -3
- package/src/App.plan-review.test.tsx +10 -8
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +17 -28
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +3 -4
- package/src/App.test.tsx +14 -18
- package/src/App.tsx +117 -42
- package/src/App.walk.test.tsx +2 -2
- package/src/App.workbench.test.tsx +85 -14
- package/src/app-screens.tsx +26 -10
- package/src/app-view-model.ts +17 -3
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.tsx +6 -6
- package/src/components/AppShell.tsx +62 -15
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +69 -0
- package/src/components/ChangesColumn.tsx +3 -2
- package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
- package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
- package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
- package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
- package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tabs.test.tsx +17 -4
- package/src/components/EditorGrid.tsx +49 -19
- package/src/components/FileContentsView.test.tsx +3 -3
- package/src/components/FileContentsView.tsx +37 -17
- package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
- package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
- package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
- package/src/components/ProjectTreeView.tsx +3 -2
- package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
- package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.tsx +3 -2
- package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
- package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
- package/src/components/ThreadsSidebar.tsx +5 -4
- package/src/components/TreePane.tsx +3 -2
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
- package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
- package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
- package/src/config.test.ts +19 -6
- package/src/config.ts +26 -6
- package/src/intent-dispatch.test.ts +2 -0
- package/src/intent-dispatch.ts +1 -1
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +5 -1
- package/src/keymap.ts +1 -1
- package/src/prototype-browser.ts +8 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/session-controller.live-diff.test.ts +198 -0
- package/src/session-controller.ts +141 -26
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +32 -18
- package/src/test-support.ts +42 -3
- package/src/theme-presets.ts +20 -0
- package/src/theme.ts +18 -0
- package/src/thread-chords.ts +1 -1
- package/src/use-annotation-surface.tsx +12 -2
- package/src/use-settings-dialog.tsx +4 -4
- package/src/view-diff.test.ts +44 -1
- package/src/view-diff.ts +81 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
- package/src/components/AnnotationCard.stories.tsx +0 -94
- package/src/components/AnnotationCard.tsx +0 -250
- package/src/components/MarkerPopover.stories.tsx +0 -58
- package/src/components/MarkerPopover.tsx +0 -109
- package/src/components/WelcomeSurface.stories.tsx +0 -12
- package/src/components/WelcomeSurface.tsx +0 -52
package/src/app-screens.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { ScrollArea } from "./components/ScrollArea";
|
|
1
2
|
import React, { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
|
2
3
|
import type { DiffFileContents, ReviewSession, VerdictKind } from "@cueloop/schema";
|
|
3
4
|
import { returnPaneFor } from "@cueloop/schema";
|
|
4
5
|
import type { Theme } from "./theme";
|
|
6
|
+
import type { QuickAction } from "./config";
|
|
5
7
|
import type { Mode, TreeAsk } from "./intent-dispatch";
|
|
6
8
|
import type { Intent } from "./keymap";
|
|
7
9
|
import type { ReviewController, ToastState } from "./session-controller";
|
|
@@ -15,13 +17,13 @@ import { ThemeProvider } from "./components/theme-context";
|
|
|
15
17
|
import type { InboxRow } from "./components/session-tree";
|
|
16
18
|
import { SettingsDialog } from "./components/SettingsDialog";
|
|
17
19
|
import { CompletionOverlay } from "./components/CompletionOverlay";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
+
import { ThreadTree } from "./components/ThreadTree";
|
|
21
|
+
import { WelcomePlayground } from "./components/WelcomePlayground";
|
|
20
22
|
import { AppShell, type ProjectPanelMode } from "./components/AppShell";
|
|
21
23
|
import { EditorGrid } from "./components/EditorGrid";
|
|
22
24
|
import { ProjectTreeView } from "./components/ProjectTreeView";
|
|
23
25
|
import { ChangesFileTree } from "./components/ChangesColumn";
|
|
24
|
-
import {
|
|
26
|
+
import { BareWorkbenchFileView } from "./components/BareWorkbenchFileView";
|
|
25
27
|
import { useChangesWorkbench } from "./use-changes-workbench";
|
|
26
28
|
import { ConfirmDialog } from "./components/ConfirmDialog";
|
|
27
29
|
import { PromptDialog } from "./components/PromptDialog";
|
|
@@ -150,6 +152,10 @@ export function NoThreadShell(props: {
|
|
|
150
152
|
pinnedIds: ReadonlySet<string>;
|
|
151
153
|
onPin: (id: string) => void;
|
|
152
154
|
onRename: (id: string, title: string) => void;
|
|
155
|
+
/** Drives the "/" quick actions and skills in the welcome playground's composer. */
|
|
156
|
+
quickActions: QuickAction[];
|
|
157
|
+
/** Reports the welcome composer's open state, so the shell suspends its inbox keys while typing. */
|
|
158
|
+
onWelcomeComposingChange: (composing: boolean) => void;
|
|
153
159
|
}): React.ReactNode {
|
|
154
160
|
const {
|
|
155
161
|
rows,
|
|
@@ -165,6 +171,8 @@ export function NoThreadShell(props: {
|
|
|
165
171
|
pinnedIds,
|
|
166
172
|
onPin,
|
|
167
173
|
onRename,
|
|
174
|
+
quickActions,
|
|
175
|
+
onWelcomeComposingChange,
|
|
168
176
|
} = props;
|
|
169
177
|
const confirming = mode.type === "confirmDelete" ? mode : null;
|
|
170
178
|
// The bare-launch shell is the same four panes as a thread: the Thread pane waits in its empty state
|
|
@@ -179,8 +187,8 @@ export function NoThreadShell(props: {
|
|
|
179
187
|
onToggleSidebar={onToggleSidebar}
|
|
180
188
|
onOpenMenu={onOpenMenu}
|
|
181
189
|
threadsPanel={
|
|
182
|
-
<
|
|
183
|
-
<
|
|
190
|
+
<ScrollArea>
|
|
191
|
+
<ThreadTree
|
|
184
192
|
rows={rows}
|
|
185
193
|
cursor={inboxCursor}
|
|
186
194
|
pinnedIds={pinnedIds}
|
|
@@ -193,7 +201,7 @@ export function NoThreadShell(props: {
|
|
|
193
201
|
onRename={onRename}
|
|
194
202
|
theme={theme}
|
|
195
203
|
/>
|
|
196
|
-
</
|
|
204
|
+
</ScrollArea>
|
|
197
205
|
}
|
|
198
206
|
threadTitle=""
|
|
199
207
|
threadPanel={
|
|
@@ -205,7 +213,7 @@ export function NoThreadShell(props: {
|
|
|
205
213
|
justifyContent: "center",
|
|
206
214
|
}}
|
|
207
215
|
>
|
|
208
|
-
<text fg={theme.textDim}>Select a thread</text>
|
|
216
|
+
<text fg={theme.textDim}>{rows.length === 0 ? "no threads" : "Select a thread"}</text>
|
|
209
217
|
</box>
|
|
210
218
|
}
|
|
211
219
|
changesOpen={workbench.changesOpen}
|
|
@@ -227,11 +235,19 @@ export function NoThreadShell(props: {
|
|
|
227
235
|
zoomed={workbench.zoomed}
|
|
228
236
|
renderTab={(tab) =>
|
|
229
237
|
tab.kind === "welcome" ? (
|
|
230
|
-
<
|
|
238
|
+
<WelcomePlayground
|
|
239
|
+
version={CLIENT_VERSION}
|
|
240
|
+
quickActions={quickActions}
|
|
241
|
+
onComposingChange={onWelcomeComposingChange}
|
|
242
|
+
theme={theme}
|
|
243
|
+
/>
|
|
231
244
|
) : (
|
|
232
|
-
<
|
|
245
|
+
<BareWorkbenchFileView
|
|
233
246
|
path={tab.path ?? ""}
|
|
234
|
-
|
|
247
|
+
controller={controller}
|
|
248
|
+
quickActions={quickActions}
|
|
249
|
+
onComposingChange={onWelcomeComposingChange}
|
|
250
|
+
onExit={() => {}}
|
|
235
251
|
theme={theme}
|
|
236
252
|
/>
|
|
237
253
|
)
|
package/src/app-view-model.ts
CHANGED
|
@@ -29,6 +29,19 @@ export function isWalking(isDiff: boolean, walk: { index: number } | null): bool
|
|
|
29
29
|
return isDiff && walk !== null;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* A prototype renders as a kitty pixel mockup only in the opt-in experimental mode:
|
|
34
|
+
* the flag is on and the artifact carries an HTML entry. Otherwise it is the default
|
|
35
|
+
* markdown design doc, rendered through the thread/markdown path.
|
|
36
|
+
*/
|
|
37
|
+
export function isPixelPrototypeMode(
|
|
38
|
+
isPrototype: boolean,
|
|
39
|
+
prototypePixels: boolean,
|
|
40
|
+
prototypePath: string | undefined,
|
|
41
|
+
): boolean {
|
|
42
|
+
return isPrototype && prototypePixels && Boolean(prototypePath);
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
export function resolveOverlay(
|
|
33
46
|
mode: Mode,
|
|
34
47
|
completionPhase: Completion["phase"],
|
|
@@ -92,19 +105,20 @@ export function buildRenderFlags(params: {
|
|
|
92
105
|
session: ReviewSession;
|
|
93
106
|
isOwner: boolean;
|
|
94
107
|
isDiff: boolean;
|
|
95
|
-
|
|
108
|
+
isPixelPrototype: boolean;
|
|
96
109
|
resolved: boolean;
|
|
97
110
|
menuDialog: "keybinds" | "settings" | null;
|
|
98
111
|
resolvedIds: Set<string>;
|
|
99
112
|
}) {
|
|
100
|
-
const { session, isOwner, isDiff,
|
|
113
|
+
const { session, isOwner, isDiff, isPixelPrototype, resolved, menuDialog, resolvedIds } = params;
|
|
101
114
|
|
|
102
115
|
return {
|
|
103
116
|
showOwnerActions: isOwner && !isDiff && !resolved,
|
|
104
117
|
prototypeCanComment: isOwner && !resolved,
|
|
105
118
|
chromeHidden: menuDialog !== null,
|
|
106
119
|
prototypePath: session.artifact.meta.prototypePath ?? "",
|
|
107
|
-
|
|
120
|
+
// a markdown prototype interleaves resolved cards like a plan; only the pixel mockup opts out
|
|
121
|
+
railResolvedIds: isDiff || isPixelPrototype ? null : resolvedIds,
|
|
108
122
|
};
|
|
109
123
|
}
|
|
110
124
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { SCHEMA_VERSION, type ReviewSession } from "@cueloop/schema";
|
|
3
|
+
import { DARK } from "../theme";
|
|
4
|
+
import type { Story, StoryMeta } from "./story";
|
|
5
|
+
import { AnnotatableFileView } from "./AnnotatableFileView";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "Surfaces/AnnotatableFileView" };
|
|
8
|
+
|
|
9
|
+
const SAMPLE = `export function add(a: number, b: number): number {\n return a + b;\n}\n`;
|
|
10
|
+
|
|
11
|
+
const SESSION: ReviewSession = {
|
|
12
|
+
schemaVersion: SCHEMA_VERSION,
|
|
13
|
+
id: "ses_story",
|
|
14
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
15
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
16
|
+
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00Z" }],
|
|
17
|
+
annotations: [],
|
|
18
|
+
verdict: null,
|
|
19
|
+
status: "pending",
|
|
20
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const noop = (): void => {};
|
|
24
|
+
|
|
25
|
+
export const Loaded: Story = {
|
|
26
|
+
render: () => (
|
|
27
|
+
<AnnotatableFileView
|
|
28
|
+
path="src/add.ts"
|
|
29
|
+
loadContents={() => Promise.resolve(SAMPLE)}
|
|
30
|
+
session={SESSION}
|
|
31
|
+
quickActions={[]}
|
|
32
|
+
observer={false}
|
|
33
|
+
onAddComment={noop}
|
|
34
|
+
onReply={noop}
|
|
35
|
+
onUpdateAnnotation={noop}
|
|
36
|
+
onExit={noop}
|
|
37
|
+
theme={DARK}
|
|
38
|
+
/>
|
|
39
|
+
),
|
|
40
|
+
expectedColors: [DARK.textDim],
|
|
41
|
+
size: { width: 60, height: 12 },
|
|
42
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project file is its own annotation surface: selecting a line and typing leaves a comment whose
|
|
3
|
+
* anchor quotes that file line, handed up as an Anchor the app persists with a file target.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
7
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { SCHEMA_VERSION, type Anchor, type ReviewSession } from "@cueloop/schema";
|
|
10
|
+
import { AnnotatableFileView } from "./AnnotatableFileView";
|
|
11
|
+
import { DARK } from "../theme";
|
|
12
|
+
import { dragText, typeText, pressKey, waitForText } from "../test-support";
|
|
13
|
+
|
|
14
|
+
const SAMPLE = "export function add(a: number, b: number) {\n return a + b;\n}\n";
|
|
15
|
+
|
|
16
|
+
function planSession(): ReviewSession {
|
|
17
|
+
return {
|
|
18
|
+
schemaVersion: SCHEMA_VERSION,
|
|
19
|
+
id: "ses_plan",
|
|
20
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
21
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
22
|
+
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00Z" }],
|
|
23
|
+
annotations: [],
|
|
24
|
+
verdict: null,
|
|
25
|
+
status: "pending",
|
|
26
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const noop = (): void => {};
|
|
31
|
+
|
|
32
|
+
describe("AnnotatableFileView", () => {
|
|
33
|
+
test("commenting a file line hands up an anchor that quotes the line", async () => {
|
|
34
|
+
const onAddComment = mock((_anchor: Anchor, _body: string) => {});
|
|
35
|
+
const setup = await testRender(
|
|
36
|
+
<AnnotatableFileView
|
|
37
|
+
path="src/add.ts"
|
|
38
|
+
loadContents={() => Promise.resolve(SAMPLE)}
|
|
39
|
+
session={planSession()}
|
|
40
|
+
quickActions={[]}
|
|
41
|
+
observer={false}
|
|
42
|
+
onAddComment={onAddComment}
|
|
43
|
+
onReply={noop}
|
|
44
|
+
onUpdateAnnotation={noop}
|
|
45
|
+
onExit={noop}
|
|
46
|
+
theme={DARK}
|
|
47
|
+
/>,
|
|
48
|
+
{ width: 64, height: 12 },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Act - select the middle line, type a comment, send it
|
|
52
|
+
await waitForText(setup, "return a + b");
|
|
53
|
+
await dragText(setup, "return a + b", "return a + b", "return a + b".length);
|
|
54
|
+
await typeText(setup, "why not reduce?");
|
|
55
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
56
|
+
|
|
57
|
+
// Assert - the surface built an anchor quoting the file line and handed it up with the body
|
|
58
|
+
expect(onAddComment).toHaveBeenCalled();
|
|
59
|
+
const [anchor, body] = onAddComment.mock.calls[0]!;
|
|
60
|
+
expect(anchor.quote).toContain("return a + b");
|
|
61
|
+
expect(body).toContain("why not reduce?");
|
|
62
|
+
|
|
63
|
+
setup.renderer.destroy();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a head-side (deleted-line) note never rebinds onto the current file", async () => {
|
|
67
|
+
const session = planSession();
|
|
68
|
+
// a note left on the removed side of this file: the worktree view must not show it
|
|
69
|
+
session.annotations = [
|
|
70
|
+
{
|
|
71
|
+
id: "h1",
|
|
72
|
+
kind: "comment",
|
|
73
|
+
anchor: { quote: "return a + b", prefix: "", suffix: "" },
|
|
74
|
+
target: { kind: "file", path: "src/add.ts", rev: "head" },
|
|
75
|
+
body: "deleted-side note",
|
|
76
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
const setup = await testRender(
|
|
80
|
+
<AnnotatableFileView
|
|
81
|
+
path="src/add.ts"
|
|
82
|
+
loadContents={() => Promise.resolve(SAMPLE)}
|
|
83
|
+
session={session}
|
|
84
|
+
quickActions={[]}
|
|
85
|
+
observer={false}
|
|
86
|
+
onAddComment={noop}
|
|
87
|
+
onReply={noop}
|
|
88
|
+
onUpdateAnnotation={noop}
|
|
89
|
+
onExit={noop}
|
|
90
|
+
theme={DARK}
|
|
91
|
+
/>,
|
|
92
|
+
{ width: 64, height: 12 },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await waitForText(setup, "return a + b");
|
|
96
|
+
|
|
97
|
+
// Assert - the current file shows, but the head-side note is not attached to it
|
|
98
|
+
expect(setup.captureCharFrame()).not.toContain("deleted-side note");
|
|
99
|
+
|
|
100
|
+
setup.renderer.destroy();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// A project file shown as its own annotatable surface: the file's lines render through the diff
|
|
2
|
+
// sheet as context rows (one line-number gutter, no +/- sign), so selecting text leaves a comment
|
|
3
|
+
// anchored to the file exactly as a plan or diff comment is. Used where a review session exists to
|
|
4
|
+
// hold the notes; the bare-launch welcome shell uses the read-only FileContentsView instead.
|
|
5
|
+
|
|
6
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { makeAnchor, annotationTarget, type Anchor, type ReviewSession } from "@cueloop/schema";
|
|
8
|
+
import type { Theme } from "../theme";
|
|
9
|
+
import type { QuickAction } from "../config";
|
|
10
|
+
import { useComponentTheme } from "./theme-context";
|
|
11
|
+
import { DiffContentView } from "./DiffContentView";
|
|
12
|
+
import { diffRowBlocks, fileContentsRows, marksByRows } from "../view-diff";
|
|
13
|
+
|
|
14
|
+
export interface AnnotatableFileViewProps {
|
|
15
|
+
path: string;
|
|
16
|
+
loadContents: (path: string) => Promise<string | null>;
|
|
17
|
+
session: ReviewSession;
|
|
18
|
+
quickActions: QuickAction[];
|
|
19
|
+
observer: boolean;
|
|
20
|
+
resolved?: boolean;
|
|
21
|
+
suspended?: boolean;
|
|
22
|
+
focusedAnnotationId?: string;
|
|
23
|
+
onFocusAnnotation?: (annotationId: string | undefined) => void;
|
|
24
|
+
onComposingChange?: (composing: boolean) => void;
|
|
25
|
+
onCursorChange?: (rowIndex: number) => void;
|
|
26
|
+
onObserverBlocked?: (reason: "observer" | "resolved") => void;
|
|
27
|
+
/** Persist a comment on this file; the view built the anchor against the file's own lines. */
|
|
28
|
+
onAddComment: (anchor: Anchor, body: string) => void;
|
|
29
|
+
onReply: (rootAnnotationId: string, body: string) => void;
|
|
30
|
+
onUpdateAnnotation: (id: string, body: string) => void;
|
|
31
|
+
onExit: () => void;
|
|
32
|
+
theme?: Theme;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A finished read for a specific path; `lines` is null when the file could not be read. */
|
|
36
|
+
interface FileLoad {
|
|
37
|
+
path: string;
|
|
38
|
+
lines: string[] | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function AnnotatableFileView(props: AnnotatableFileViewProps): React.ReactNode {
|
|
42
|
+
const tokens = useComponentTheme(props.theme);
|
|
43
|
+
const [loaded, setLoaded] = useState<FileLoad | null>(null);
|
|
44
|
+
const loadRef = useRef(props.loadContents);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
loadRef.current = props.loadContents;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let alive = true;
|
|
51
|
+
void loadRef.current(props.path).then(
|
|
52
|
+
(contents) => {
|
|
53
|
+
if (alive)
|
|
54
|
+
setLoaded({ path: props.path, lines: contents === null ? null : contents.split("\n") });
|
|
55
|
+
},
|
|
56
|
+
() => {
|
|
57
|
+
if (alive) setLoaded({ path: props.path, lines: null });
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
alive = false;
|
|
63
|
+
};
|
|
64
|
+
}, [props.path]);
|
|
65
|
+
|
|
66
|
+
if (loaded === null || loaded.path !== props.path) {
|
|
67
|
+
return (
|
|
68
|
+
<box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
|
|
69
|
+
<text fg={tokens.textDim}>loading...</text>
|
|
70
|
+
</box>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (loaded.lines === null) {
|
|
74
|
+
return (
|
|
75
|
+
<box
|
|
76
|
+
style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", paddingBottom: 2 }}
|
|
77
|
+
>
|
|
78
|
+
<text fg={tokens.textDim}>File deleted</text>
|
|
79
|
+
</box>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rows = fileContentsRows(props.path, loaded.lines.join("\n"));
|
|
84
|
+
// the file's worktree notes: these rows are the current file, so a head-side (deleted-line) note
|
|
85
|
+
// must stay orphaned here rather than rebind to similar surviving text
|
|
86
|
+
const fileNotes = props.session.annotations.filter((annotation) => {
|
|
87
|
+
const target = annotationTarget(annotation);
|
|
88
|
+
|
|
89
|
+
return target.kind === "file" && target.path === props.path && target.rev === "worktree";
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<DiffContentView
|
|
94
|
+
rows={rows}
|
|
95
|
+
session={props.session}
|
|
96
|
+
marks={marksByRows(fileNotes, rows, props.focusedAnnotationId)}
|
|
97
|
+
quickActions={props.quickActions}
|
|
98
|
+
observer={props.observer}
|
|
99
|
+
commentsEnabled
|
|
100
|
+
resolved={props.resolved}
|
|
101
|
+
suspended={props.suspended}
|
|
102
|
+
fileView
|
|
103
|
+
onComposingChange={props.onComposingChange}
|
|
104
|
+
onObserverBlocked={props.onObserverBlocked}
|
|
105
|
+
onCursorChange={props.onCursorChange}
|
|
106
|
+
focusedAnnotationId={props.focusedAnnotationId}
|
|
107
|
+
onFocusAnnotation={props.onFocusAnnotation}
|
|
108
|
+
onAnnotate={(span, body) =>
|
|
109
|
+
props.onAddComment(
|
|
110
|
+
makeAnchor(
|
|
111
|
+
diffRowBlocks(rows),
|
|
112
|
+
span.start.blockIndex,
|
|
113
|
+
span.start.char,
|
|
114
|
+
span.end.char,
|
|
115
|
+
span.end.blockIndex,
|
|
116
|
+
),
|
|
117
|
+
body,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
onReply={props.onReply}
|
|
121
|
+
onUpdateAnnotation={props.onUpdateAnnotation}
|
|
122
|
+
onExit={props.onExit}
|
|
123
|
+
theme={props.theme}
|
|
124
|
+
/>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -116,19 +116,19 @@ function SlashList({
|
|
|
116
116
|
|
|
117
117
|
if (items.length === 0) return <text fg={tokens.textDim}>no matching actions</text>;
|
|
118
118
|
|
|
119
|
+
const nameColumn = Math.min(36, Math.max(16, ...visible.map((item) => item.name.length + 5)));
|
|
120
|
+
|
|
119
121
|
return (
|
|
120
122
|
<box style={{ flexDirection: "column" }}>
|
|
121
123
|
{visible.map((item, offset) => {
|
|
122
124
|
const index = start + offset;
|
|
123
125
|
const isSelected = index === selected;
|
|
126
|
+
const name = `${isSelected ? "→ " : " "}/${item.name}`.padEnd(nameColumn);
|
|
124
127
|
|
|
125
128
|
return (
|
|
126
|
-
<text key={item.name} style={{ flexShrink:
|
|
127
|
-
<span fg={isSelected ? tokens.accent : tokens.
|
|
128
|
-
<span fg={
|
|
129
|
-
{`action:${item.name}`.padEnd(34)}
|
|
130
|
-
</span>
|
|
131
|
-
<span fg={tokens.textDim}>{item.description.slice(0, 52)}</span>
|
|
129
|
+
<text key={item.name} style={{ flexShrink: 0, wrapMode: "none" }}>
|
|
130
|
+
<span fg={isSelected ? tokens.accent : tokens.textMuted}>{name}</span>
|
|
131
|
+
<span fg={tokens.textDim}>{item.description}</span>
|
|
132
132
|
</text>
|
|
133
133
|
);
|
|
134
134
|
})}
|
|
@@ -47,6 +47,34 @@ export interface AppShellProps {
|
|
|
47
47
|
projectWidth?: number;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** The right region's reserved width: the open project pane, nothing when the region is closed, else the collapsed rail. */
|
|
51
|
+
function rightRegionColumns(
|
|
52
|
+
projectOpen: boolean,
|
|
53
|
+
projectWidth: number,
|
|
54
|
+
rightRegionClosed: boolean,
|
|
55
|
+
collapsedRailWidth: number,
|
|
56
|
+
): number {
|
|
57
|
+
if (projectOpen) return projectWidth;
|
|
58
|
+
|
|
59
|
+
return rightRegionClosed ? 0 : collapsedRailWidth;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The Thread header's right cluster: the owner actions, plus the reopen control when the region is closed. */
|
|
63
|
+
function threadHeaderRight(
|
|
64
|
+
threadActions: React.ReactNode,
|
|
65
|
+
reopenControl: React.ReactNode,
|
|
66
|
+
showReopen: boolean,
|
|
67
|
+
): React.ReactNode {
|
|
68
|
+
if (!showReopen) return threadActions;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<box style={{ flexDirection: "row", alignItems: "center" }}>
|
|
72
|
+
{threadActions ? <box style={{ paddingRight: 2 }}>{threadActions}</box> : null}
|
|
73
|
+
{reopenControl}
|
|
74
|
+
</box>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
50
78
|
export function AppShell({
|
|
51
79
|
sidebarOpen,
|
|
52
80
|
onToggleSidebar,
|
|
@@ -72,15 +100,21 @@ export function AppShell({
|
|
|
72
100
|
}: AppShellProps): React.ReactNode {
|
|
73
101
|
const tokens = theme ?? DARK;
|
|
74
102
|
const { width: terminalWidth } = useTerminalDimensions();
|
|
103
|
+
// with both panels closed the right region collapses to nothing: the reopen toggle
|
|
104
|
+
// rides the thread header, and the middle reclaims the width the rail would have taken
|
|
105
|
+
const rightRegionClosed = !projectOpen && !changesOpen;
|
|
75
106
|
// Thread and Changes would otherwise split the middle as two flexBasis-0 items, and Yoga adds the
|
|
76
107
|
// Changes left border on top of its share: at even widths the halves come out fractional, the
|
|
77
108
|
// Changes side rounds up, and the row overflows a cell under the Project border. Sizing the Thread
|
|
78
109
|
// pane to a whole number leaves Changes the lone flex item, which lays out exactly.
|
|
79
110
|
const collapsedRailWidth = 4;
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
111
|
+
const rightRegionWidth = rightRegionColumns(
|
|
112
|
+
projectOpen,
|
|
113
|
+
projectWidth,
|
|
114
|
+
rightRegionClosed,
|
|
115
|
+
collapsedRailWidth,
|
|
116
|
+
);
|
|
117
|
+
const middleWidth = terminalWidth - (sidebarOpen ? threadsWidth : 0) - rightRegionWidth;
|
|
84
118
|
const threadPaneWidth =
|
|
85
119
|
changesOpen && !zoomHideThread ? Math.max(20, Math.floor(middleWidth / 2)) : undefined;
|
|
86
120
|
|
|
@@ -127,6 +161,21 @@ export function AppShell({
|
|
|
127
161
|
</box>
|
|
128
162
|
);
|
|
129
163
|
|
|
164
|
+
// the reopen control - a divider tick then the toggle - shared by the collapsed
|
|
165
|
+
// rail and, when the rail is suppressed, the thread header
|
|
166
|
+
const reopenRightControl = (
|
|
167
|
+
<>
|
|
168
|
+
<text fg={tokens.border}>{"│"}</text>
|
|
169
|
+
<IconButton
|
|
170
|
+
glyph={NERD.sidebarRightOff}
|
|
171
|
+
onPress={onToggleRight}
|
|
172
|
+
tip="Toggle Right Sidebar"
|
|
173
|
+
marginLeft={1}
|
|
174
|
+
theme={tokens}
|
|
175
|
+
/>
|
|
176
|
+
</>
|
|
177
|
+
);
|
|
178
|
+
|
|
130
179
|
return (
|
|
131
180
|
<box
|
|
132
181
|
style={{
|
|
@@ -153,7 +202,12 @@ export function AppShell({
|
|
|
153
202
|
{threadTitle ? <text fg={tokens.textDim}>{threadTitle}</text> : null}
|
|
154
203
|
</box>
|
|
155
204
|
}
|
|
156
|
-
|
|
205
|
+
// with the region closed, the reopen toggle rides the thread header instead of an empty column
|
|
206
|
+
headerRight={threadHeaderRight(
|
|
207
|
+
threadActions,
|
|
208
|
+
reopenRightControl,
|
|
209
|
+
rightRegionClosed,
|
|
210
|
+
)}
|
|
157
211
|
theme={tokens}
|
|
158
212
|
>
|
|
159
213
|
{threadPanel}
|
|
@@ -186,8 +240,8 @@ export function AppShell({
|
|
|
186
240
|
>
|
|
187
241
|
{projectPanel}
|
|
188
242
|
</PanelColumn>
|
|
189
|
-
) : (
|
|
190
|
-
//
|
|
243
|
+
) : rightRegionClosed ? null : (
|
|
244
|
+
// Changes open but Project closed: a divider tick above the continuous header underline holds the reopen toggle;
|
|
191
245
|
// the rule lives in the header only, never running the pane's full height. The extra column
|
|
192
246
|
// of right padding keeps the reopen icon off the terminal's last column, which squeezes it
|
|
193
247
|
<box style={{ flexDirection: "column", width: 4 }}>
|
|
@@ -204,14 +258,7 @@ export function AppShell({
|
|
|
204
258
|
borderColor: tokens.border,
|
|
205
259
|
}}
|
|
206
260
|
>
|
|
207
|
-
|
|
208
|
-
<IconButton
|
|
209
|
-
glyph={NERD.sidebarRightOff}
|
|
210
|
-
onPress={onToggleRight}
|
|
211
|
-
tip="Toggle Right Sidebar"
|
|
212
|
-
marginLeft={1}
|
|
213
|
-
theme={tokens}
|
|
214
|
-
/>
|
|
261
|
+
{reopenRightControl}
|
|
215
262
|
</box>
|
|
216
263
|
</box>
|
|
217
264
|
)}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { BareWorkbenchFileView, type WorkbenchCommenter } from "./BareWorkbenchFileView";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Surfaces/BareWorkbenchFileView" };
|
|
7
|
+
|
|
8
|
+
const SAMPLE = `export function add(a: number, b: number): number {\n return a + b;\n}\n`;
|
|
9
|
+
|
|
10
|
+
const controller: WorkbenchCommenter = {
|
|
11
|
+
repoReadFile: () => Promise.resolve(SAMPLE),
|
|
12
|
+
commentOnWorkbench: () => Promise.resolve(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const Loaded: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<BareWorkbenchFileView
|
|
18
|
+
path="src/add.ts"
|
|
19
|
+
controller={controller}
|
|
20
|
+
quickActions={[]}
|
|
21
|
+
onExit={() => {}}
|
|
22
|
+
theme={DARK}
|
|
23
|
+
/>
|
|
24
|
+
),
|
|
25
|
+
expectedColors: [DARK.textDim],
|
|
26
|
+
size: { width: 60, height: 12 },
|
|
27
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// A project file in the bare launch shell: annotatable like any file, but no thread exists yet. The
|
|
2
|
+
// first comment find-or-creates the per-repo workbench thread (controller.commentOnWorkbench), which
|
|
3
|
+
// opens it - so this ephemeral view lives only until that first note promotes it to a real thread.
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import {
|
|
7
|
+
SCHEMA_VERSION,
|
|
8
|
+
type Anchor,
|
|
9
|
+
type AnnotationTarget,
|
|
10
|
+
type ReviewSession,
|
|
11
|
+
} from "@cueloop/schema";
|
|
12
|
+
import type { Theme } from "../theme";
|
|
13
|
+
import type { QuickAction } from "../config";
|
|
14
|
+
import { AnnotatableFileView } from "./AnnotatableFileView";
|
|
15
|
+
|
|
16
|
+
/** The controller slice this view needs: read a file, and route a comment to the workbench thread. */
|
|
17
|
+
export interface WorkbenchCommenter {
|
|
18
|
+
repoReadFile(path: string): Promise<string | null>;
|
|
19
|
+
commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** An empty, unsaved thread: it holds no notes, just enough shape for the annotation surface. */
|
|
23
|
+
function draftThread(): ReviewSession {
|
|
24
|
+
return {
|
|
25
|
+
schemaVersion: SCHEMA_VERSION,
|
|
26
|
+
id: "workbench-draft",
|
|
27
|
+
workspace: { repoRoot: "", branch: "main" },
|
|
28
|
+
artifact: { type: "diff", content: "", meta: {} },
|
|
29
|
+
revisions: [],
|
|
30
|
+
annotations: [],
|
|
31
|
+
verdict: null,
|
|
32
|
+
status: "pending",
|
|
33
|
+
createdAt: "1970-01-01T00:00:00.000Z",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BareWorkbenchFileViewProps {
|
|
38
|
+
path: string;
|
|
39
|
+
controller: WorkbenchCommenter;
|
|
40
|
+
quickActions: QuickAction[];
|
|
41
|
+
/** Reports whether the composer is open, so the shell suspends its inbox keys while typing. */
|
|
42
|
+
onComposingChange?: (composing: boolean) => void;
|
|
43
|
+
onExit: () => void;
|
|
44
|
+
theme?: Theme;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function BareWorkbenchFileView(props: BareWorkbenchFileViewProps): React.ReactNode {
|
|
48
|
+
return (
|
|
49
|
+
<AnnotatableFileView
|
|
50
|
+
path={props.path}
|
|
51
|
+
loadContents={(path) => props.controller.repoReadFile(path)}
|
|
52
|
+
session={draftThread()}
|
|
53
|
+
quickActions={props.quickActions}
|
|
54
|
+
observer={false}
|
|
55
|
+
onComposingChange={props.onComposingChange}
|
|
56
|
+
onAddComment={(anchor: Anchor, body: string) =>
|
|
57
|
+
void props.controller.commentOnWorkbench(
|
|
58
|
+
anchor,
|
|
59
|
+
{ kind: "file", path: props.path, rev: "worktree" },
|
|
60
|
+
body,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
onReply={() => {}}
|
|
64
|
+
onUpdateAnnotation={() => {}}
|
|
65
|
+
onExit={props.onExit}
|
|
66
|
+
theme={props.theme}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|