@cueloop/client 0.1.0-alpha.8 → 0.1.0-alpha.81
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +40 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +176 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +428 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +222 -0
- package/src/App.test.tsx +333 -131
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1338 -608
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +701 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +574 -0
- package/src/app-view-model.ts +162 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +329 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +153 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +876 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +59 -0
- package/src/components/ProjectTreeView.tsx +111 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +899 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +472 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +142 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2238 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +56 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +517 -33
- package/src/config.ts +467 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +238 -0
- package/src/diff-intraline.ts +236 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +13 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +723 -0
- package/src/intent-dispatch.ts +692 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +461 -113
- package/src/keymap.ts +321 -79
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +52 -0
- package/src/launch-layout.ts +42 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +64 -0
- package/src/prototype-browser.ts +329 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +78 -13
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-controller.curate.test.ts +450 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +1893 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-annotation-surface.tsx +915 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-settings-dialog.tsx +320 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +137 -9
- package/src/view-diff.ts +269 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +456 -0
- package/src/view-plan.ts +719 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +81 -0
- package/src/workbench-snapshot.ts +29 -0
- package/src/session-controller.ts +0 -345
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -312
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
cutBlock,
|
|
4
|
+
parseBlocks,
|
|
5
|
+
restoreBlock,
|
|
6
|
+
SCHEMA_VERSION,
|
|
7
|
+
type DiffFileContents,
|
|
8
|
+
type HunkRejection,
|
|
9
|
+
type Thread,
|
|
10
|
+
} from "@cueloop/schema";
|
|
11
|
+
import { curateDiff } from "@cueloop/daemon/curate";
|
|
12
|
+
import type { ThreadClient } from "@cueloop/daemon/client";
|
|
13
|
+
import { createReviewController } from "./thread-controller";
|
|
14
|
+
|
|
15
|
+
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
16
|
+
index 111..222 100644
|
|
17
|
+
--- a/src/store.ts
|
|
18
|
+
+++ b/src/store.ts
|
|
19
|
+
@@ -1,4 +1,4 @@
|
|
20
|
+
export class Store {
|
|
21
|
+
- private items = [];
|
|
22
|
+
+ private items = new Map();
|
|
23
|
+
}
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const FILES: DiffFileContents[] = [
|
|
27
|
+
{
|
|
28
|
+
path: "src/store.ts",
|
|
29
|
+
oldContents: "export class Store {\n private items = [];\n}\n",
|
|
30
|
+
newContents: "export class Store {\n private items = new Map();\n}\n",
|
|
31
|
+
status: "modified",
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function diffSession(files?: DiffFileContents[]): Thread {
|
|
36
|
+
return {
|
|
37
|
+
schemaVersion: SCHEMA_VERSION,
|
|
38
|
+
id: "ses_diff",
|
|
39
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
40
|
+
artifact: { type: "diff", content: PATCH, meta: {}, files },
|
|
41
|
+
revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
42
|
+
annotations: [],
|
|
43
|
+
message: null,
|
|
44
|
+
status: "pending",
|
|
45
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface WorkingCopySink {
|
|
50
|
+
workingCopy?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const unimplemented = (member: string) => () =>
|
|
54
|
+
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
55
|
+
|
|
56
|
+
/** A fake client that records the working copy the controller writes. */
|
|
57
|
+
function fakeClient(initial: Thread, sink: WorkingCopySink): ThreadClient {
|
|
58
|
+
let session = initial;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
onEvent: () => () => {},
|
|
62
|
+
subscribe: async () => {},
|
|
63
|
+
sessionGet: async () => session,
|
|
64
|
+
sessionList: async () => [session],
|
|
65
|
+
sessionComment: unimplemented("sessionComment"),
|
|
66
|
+
sessionAnnotate: unimplemented("sessionAnnotate"),
|
|
67
|
+
sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
|
|
68
|
+
sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
|
|
69
|
+
sink.workingCopy = content;
|
|
70
|
+
|
|
71
|
+
return { ...session, workingCopy: content };
|
|
72
|
+
}),
|
|
73
|
+
// the daemon's block primitives, stood in for with the same pure helpers it uses
|
|
74
|
+
sessionCutBlock: mock(async (_id: string, blockIndex: number) => {
|
|
75
|
+
const working = session.workingCopy ?? session.artifact.content;
|
|
76
|
+
const content = cutBlock(working, parseBlocks(working)[blockIndex]!);
|
|
77
|
+
|
|
78
|
+
sink.workingCopy = content;
|
|
79
|
+
session = { ...session, workingCopy: content };
|
|
80
|
+
|
|
81
|
+
return session;
|
|
82
|
+
}),
|
|
83
|
+
sessionRestoreBlock: mock(async (_id: string, baseBlockIndex: number, line?: number) => {
|
|
84
|
+
const base = session.artifact.content;
|
|
85
|
+
const working = session.workingCopy ?? base;
|
|
86
|
+
const content = restoreBlock(
|
|
87
|
+
base,
|
|
88
|
+
working,
|
|
89
|
+
parseBlocks(base)[baseBlockIndex]!,
|
|
90
|
+
line ?? working.split("\n").length,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
sink.workingCopy = content;
|
|
94
|
+
session = { ...session, workingCopy: content };
|
|
95
|
+
if (content === undefined) delete session.workingCopy;
|
|
96
|
+
|
|
97
|
+
return session;
|
|
98
|
+
}),
|
|
99
|
+
// the daemon's curation, stood in for: the record carries the decisions and the patch they leave
|
|
100
|
+
sessionCurate: mock(async (_id: string, rejections: HunkRejection[]) => {
|
|
101
|
+
const content = rejections.length
|
|
102
|
+
? curateDiff(session.artifact.files!, rejections)
|
|
103
|
+
: undefined;
|
|
104
|
+
|
|
105
|
+
sink.workingCopy = content;
|
|
106
|
+
session = { ...session, workingCopy: content };
|
|
107
|
+
if (rejections.length) session.curation = rejections;
|
|
108
|
+
else delete session.curation;
|
|
109
|
+
|
|
110
|
+
return session;
|
|
111
|
+
}),
|
|
112
|
+
sessionSetAccess: unimplemented("sessionSetAccess"),
|
|
113
|
+
sessionNavigate: unimplemented("sessionNavigate"),
|
|
114
|
+
sessionBranch: unimplemented("sessionBranch"),
|
|
115
|
+
sessionSwitch: unimplemented("sessionSwitch"),
|
|
116
|
+
sessionLabel: unimplemented("sessionLabel"),
|
|
117
|
+
sessionFork: unimplemented("sessionFork"),
|
|
118
|
+
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
119
|
+
sessionSetTitle: unimplemented("sessionSetTitle"),
|
|
120
|
+
sessionSetShareId: unimplemented("sessionSetShareId"),
|
|
121
|
+
sessionSetShares: unimplemented("sessionSetShares"),
|
|
122
|
+
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
123
|
+
sessionDelete: unimplemented("sessionDelete"),
|
|
124
|
+
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
125
|
+
sessionSendMessage: unimplemented("sessionSendMessage"),
|
|
126
|
+
close: () => {},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
131
|
+
|
|
132
|
+
async function connected(session: Thread) {
|
|
133
|
+
const sink: WorkingCopySink = {};
|
|
134
|
+
const client = fakeClient(session, sink);
|
|
135
|
+
const controller = createReviewController({
|
|
136
|
+
sessionId: session.id,
|
|
137
|
+
openClient: async () => client,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
controller.connect();
|
|
141
|
+
await tick();
|
|
142
|
+
|
|
143
|
+
return { controller, client, sink };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// rows for PATCH: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
|
|
147
|
+
describe("diff hunk curation", () => {
|
|
148
|
+
test("rejecting the only change writes an empty curated working copy", async () => {
|
|
149
|
+
// Arrange
|
|
150
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
151
|
+
|
|
152
|
+
// Act - reject the change on the added line
|
|
153
|
+
controller.toggleRejectChange(4);
|
|
154
|
+
await tick();
|
|
155
|
+
|
|
156
|
+
// Assert
|
|
157
|
+
expect(controller.getSnapshot().status).toContain("change rejected");
|
|
158
|
+
expect(sink.workingCopy).toBe("");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("both the deletion and addition rows of the change dim", async () => {
|
|
162
|
+
// Arrange
|
|
163
|
+
const { controller } = await connected(diffSession(FILES));
|
|
164
|
+
|
|
165
|
+
// Act
|
|
166
|
+
controller.toggleRejectChange(4);
|
|
167
|
+
await tick();
|
|
168
|
+
|
|
169
|
+
// Assert - the change spans the del row and the add row
|
|
170
|
+
expect([...controller.rejectedRows()].sort((a, b) => a - b)).toEqual([3, 4]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("toggling the same change twice restores the full diff", async () => {
|
|
174
|
+
// Arrange
|
|
175
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
176
|
+
|
|
177
|
+
// Act - reject then restore
|
|
178
|
+
controller.toggleRejectChange(4);
|
|
179
|
+
await tick();
|
|
180
|
+
controller.toggleRejectChange(4);
|
|
181
|
+
await tick();
|
|
182
|
+
|
|
183
|
+
// Assert - no decisions left, so the working copy reverts to undefined
|
|
184
|
+
expect(controller.getSnapshot().status).toContain("change restored");
|
|
185
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
186
|
+
expect(controller.rejectedRows().size).toBe(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("whole-hunk reject drops the hunk from the working copy", async () => {
|
|
190
|
+
// Arrange
|
|
191
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
192
|
+
|
|
193
|
+
// Act - reject the whole hunk from any of its rows
|
|
194
|
+
controller.toggleRejectHunk(3);
|
|
195
|
+
await tick();
|
|
196
|
+
|
|
197
|
+
// Assert - the single hunk is the whole diff, so nothing remains
|
|
198
|
+
expect(controller.getSnapshot().status).toContain("hunk rejected");
|
|
199
|
+
expect(sink.workingCopy).toBe("");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a rejected change becomes a removal card with a source, id, and preview", async () => {
|
|
203
|
+
// Arrange
|
|
204
|
+
const { controller } = await connected(diffSession(FILES));
|
|
205
|
+
|
|
206
|
+
// Act
|
|
207
|
+
controller.toggleRejectChange(4);
|
|
208
|
+
await tick();
|
|
209
|
+
|
|
210
|
+
// Assert - one item: diff source, revealing the change's row, both lines previewed
|
|
211
|
+
const items = controller.curationItems();
|
|
212
|
+
|
|
213
|
+
expect(items.length).toBe(1);
|
|
214
|
+
expect(items[0]!.source).toBe("diff");
|
|
215
|
+
expect(items[0]!.id).toBe("diff:src/store.ts#0#1");
|
|
216
|
+
expect(items[0]!.revealIndex).toBe(3);
|
|
217
|
+
expect(items[0]!.preview).toEqual([
|
|
218
|
+
"- private items = [];",
|
|
219
|
+
"+ private items = new Map();",
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a rejected hunk becomes a removal card with the whole-hunk id", async () => {
|
|
224
|
+
// Arrange
|
|
225
|
+
const { controller } = await connected(diffSession(FILES));
|
|
226
|
+
|
|
227
|
+
// Act
|
|
228
|
+
controller.toggleRejectHunk(3);
|
|
229
|
+
await tick();
|
|
230
|
+
|
|
231
|
+
// Assert - the whole hunk, id diff:path#hunk#hunk
|
|
232
|
+
const items = controller.curationItems();
|
|
233
|
+
|
|
234
|
+
expect(items.length).toBe(1);
|
|
235
|
+
expect(items[0]!.source).toBe("diff");
|
|
236
|
+
expect(items[0]!.id).toBe("diff:src/store.ts#0#hunk");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("restoreCuration drops the diff removal and reverts the working copy", async () => {
|
|
240
|
+
// Arrange - one change rejected
|
|
241
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
242
|
+
|
|
243
|
+
controller.toggleRejectChange(4);
|
|
244
|
+
await tick();
|
|
245
|
+
const [item] = controller.curationItems();
|
|
246
|
+
|
|
247
|
+
// Act
|
|
248
|
+
controller.restoreCuration(item!.id);
|
|
249
|
+
await tick();
|
|
250
|
+
|
|
251
|
+
// Assert - the list empties and the working copy reverts to the full diff
|
|
252
|
+
expect(controller.getSnapshot().status).toContain("removal restored");
|
|
253
|
+
expect(controller.curationItems().length).toBe(0);
|
|
254
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
255
|
+
expect(controller.rejectedRows().size).toBe(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("restoreCuration ignores an unknown id", async () => {
|
|
259
|
+
// Arrange - one change rejected
|
|
260
|
+
const { controller } = await connected(diffSession(FILES));
|
|
261
|
+
|
|
262
|
+
controller.toggleRejectChange(4);
|
|
263
|
+
await tick();
|
|
264
|
+
|
|
265
|
+
// Act
|
|
266
|
+
controller.restoreCuration("diff:nope#0#0");
|
|
267
|
+
await tick();
|
|
268
|
+
|
|
269
|
+
// Assert - the item stays
|
|
270
|
+
expect(controller.curationItems().length).toBe(1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a second decision before the daemon answers builds on the first", async () => {
|
|
274
|
+
// Arrange
|
|
275
|
+
const { controller, client } = await connected(diffSession(FILES));
|
|
276
|
+
|
|
277
|
+
// Act - reject, then restore, with no answer in between
|
|
278
|
+
controller.toggleRejectChange(4);
|
|
279
|
+
controller.toggleRejectChange(4);
|
|
280
|
+
await tick();
|
|
281
|
+
|
|
282
|
+
// Assert - the second request restores; a stale snapshot would reject again
|
|
283
|
+
expect(client.sessionCurate).toHaveBeenCalledTimes(2);
|
|
284
|
+
expect(client.sessionCurate).toHaveBeenNthCalledWith(1, "ses_diff", [
|
|
285
|
+
{ path: "src/store.ts", hunkIndex: 0, changeIndex: 1 },
|
|
286
|
+
]);
|
|
287
|
+
expect(client.sessionCurate).toHaveBeenNthCalledWith(2, "ses_diff", []);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("curation is disabled without full file contents", async () => {
|
|
291
|
+
// Arrange - a legacy diff with no artifact.files
|
|
292
|
+
const { controller, sink } = await connected(diffSession(undefined));
|
|
293
|
+
|
|
294
|
+
// Act
|
|
295
|
+
controller.toggleRejectChange(4);
|
|
296
|
+
await tick();
|
|
297
|
+
|
|
298
|
+
// Assert
|
|
299
|
+
expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
|
|
300
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("curation is disabled when a revision cleared its file snapshots", async () => {
|
|
304
|
+
const { controller, client } = await connected(diffSession([]));
|
|
305
|
+
|
|
306
|
+
controller.toggleRejectChange(4);
|
|
307
|
+
await tick();
|
|
308
|
+
|
|
309
|
+
expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
|
|
310
|
+
expect(client.sessionCurate).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const PLAN_CONTENT = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n";
|
|
315
|
+
// the working copy with the "Second paragraph." block cut out
|
|
316
|
+
const PLAN_CUT = "# Title\n\nFirst paragraph.\n";
|
|
317
|
+
|
|
318
|
+
function planSession(workingCopy?: string): Thread {
|
|
319
|
+
return {
|
|
320
|
+
schemaVersion: SCHEMA_VERSION,
|
|
321
|
+
id: "ses_plan",
|
|
322
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
323
|
+
artifact: { type: "plan", content: PLAN_CONTENT, meta: {} },
|
|
324
|
+
revisions: [{ revision: 1, content: PLAN_CONTENT, submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
325
|
+
annotations: [],
|
|
326
|
+
message: null,
|
|
327
|
+
status: "pending",
|
|
328
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
329
|
+
workingCopy,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
describe("plan cut removals", () => {
|
|
334
|
+
test("a cut block becomes a plan removal card previewing its content", async () => {
|
|
335
|
+
// Arrange - a plan whose working copy dropped the second paragraph
|
|
336
|
+
const { controller } = await connected(planSession(PLAN_CUT));
|
|
337
|
+
|
|
338
|
+
// Act
|
|
339
|
+
const items = controller.curationItems();
|
|
340
|
+
|
|
341
|
+
// Assert - one plan-source removal, keyed on the base line range, content previewed
|
|
342
|
+
expect(items.length).toBe(1);
|
|
343
|
+
expect(items[0]!.source).toBe("plan");
|
|
344
|
+
expect(items[0]!.id).toBe("plan:4-4");
|
|
345
|
+
expect(items[0]!.preview).toEqual(["Second paragraph."]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("restoreCuration re-inserts the cut block and returns to pristine", async () => {
|
|
349
|
+
// Arrange
|
|
350
|
+
const { controller, sink } = await connected(planSession(PLAN_CUT));
|
|
351
|
+
const [item] = controller.curationItems();
|
|
352
|
+
|
|
353
|
+
// Act
|
|
354
|
+
controller.restoreCuration(item!.id);
|
|
355
|
+
await tick();
|
|
356
|
+
|
|
357
|
+
// Assert - restoring the only cut round-trips to the submitted revision
|
|
358
|
+
expect(controller.getSnapshot().status).toContain("removal restored");
|
|
359
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a second cut before the daemon answers indexes the already-cut copy", async () => {
|
|
363
|
+
// Arrange - pristine plan: title(0), first(1), second(2)
|
|
364
|
+
const { controller, client, sink } = await connected(planSession());
|
|
365
|
+
|
|
366
|
+
// Act - cut the first paragraph, then the second, with no answer in between
|
|
367
|
+
controller.cut(1);
|
|
368
|
+
controller.cut(2);
|
|
369
|
+
await tick();
|
|
370
|
+
|
|
371
|
+
// Assert - the second paragraph is block 1 once the first is gone
|
|
372
|
+
expect(client.sessionCutBlock).toHaveBeenNthCalledWith(1, "ses_plan", 1);
|
|
373
|
+
expect(client.sessionCutBlock).toHaveBeenNthCalledWith(2, "ses_plan", 1);
|
|
374
|
+
expect(sink.workingCopy).toBe("# Title\n");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// a file whose hunk touches line 2 only, so lines 1/3/4 are unchanged tail/context
|
|
379
|
+
const TAIL_PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
380
|
+
--- a/src/store.ts
|
|
381
|
+
+++ b/src/store.ts
|
|
382
|
+
@@ -2,1 +2,1 @@
|
|
383
|
+
- private items = [];
|
|
384
|
+
+ private items = new Map();
|
|
385
|
+
`;
|
|
386
|
+
|
|
387
|
+
function tailSession(): Thread {
|
|
388
|
+
return {
|
|
389
|
+
...diffSession(),
|
|
390
|
+
artifact: {
|
|
391
|
+
type: "diff",
|
|
392
|
+
content: TAIL_PATCH,
|
|
393
|
+
meta: {},
|
|
394
|
+
files: [
|
|
395
|
+
{
|
|
396
|
+
path: "src/store.ts",
|
|
397
|
+
oldContents: "export class Store {\n private items = [];\n}\nexport const x = 1;\n",
|
|
398
|
+
newContents:
|
|
399
|
+
"export class Store {\n private items = new Map();\n}\nexport const x = 1;\n",
|
|
400
|
+
status: "modified",
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
describe("diff file fold", () => {
|
|
408
|
+
test("collapse leaves only the file band; expand restores the body", async () => {
|
|
409
|
+
// Arrange
|
|
410
|
+
const { controller } = await connected(tailSession());
|
|
411
|
+
const bodyRows = () =>
|
|
412
|
+
controller.rows().filter((row) => row.file === "src/store.ts" && row.kind !== "file").length;
|
|
413
|
+
|
|
414
|
+
// Assert - starts unfolded with a body
|
|
415
|
+
expect(bodyRows()).toBeGreaterThan(0);
|
|
416
|
+
|
|
417
|
+
// Act / Assert - collapse hides the body
|
|
418
|
+
controller.setFileCollapsed("src/store.ts", true);
|
|
419
|
+
expect(bodyRows()).toBe(0);
|
|
420
|
+
expect(controller.isFileCollapsed("src/store.ts")).toBe(true);
|
|
421
|
+
|
|
422
|
+
// Act / Assert - unfold brings it back
|
|
423
|
+
controller.setFileCollapsed("src/store.ts", false);
|
|
424
|
+
expect(bodyRows()).toBeGreaterThan(0);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("expand weaves the file's unchanged tail line the hunk never showed", async () => {
|
|
428
|
+
// Arrange
|
|
429
|
+
const { controller } = await connected(tailSession());
|
|
430
|
+
const hasTail = () => controller.rows().some((row) => row.text.includes("export const x = 1"));
|
|
431
|
+
|
|
432
|
+
// Assert - the hunk view does not show the tail line
|
|
433
|
+
expect(controller.canExpandFile("src/store.ts")).toBe(true);
|
|
434
|
+
expect(hasTail()).toBe(false);
|
|
435
|
+
|
|
436
|
+
// Act - weave the full file
|
|
437
|
+
controller.setFileExpanded("src/store.ts", true);
|
|
438
|
+
|
|
439
|
+
// Assert
|
|
440
|
+
expect(hasTail()).toBe(true);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("a diff without full contents cannot be expanded", async () => {
|
|
444
|
+
// Arrange - no artifact.files
|
|
445
|
+
const { controller } = await connected(diffSession(undefined));
|
|
446
|
+
|
|
447
|
+
// Assert
|
|
448
|
+
expect(controller.canExpandFile("src/store.ts")).toBe(false);
|
|
449
|
+
});
|
|
450
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
|
|
3
|
+
import { sameDerivationInputs } from "./thread-controller";
|
|
4
|
+
|
|
5
|
+
function threadFixture(overrides: Partial<Thread> = {}): Thread {
|
|
6
|
+
return {
|
|
7
|
+
schemaVersion: SCHEMA_VERSION,
|
|
8
|
+
id: "ses_1",
|
|
9
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
10
|
+
artifact: { type: "plan", content: "# Plan\n\nShip it.\n", meta: {} },
|
|
11
|
+
revisions: [
|
|
12
|
+
{ revision: 1, content: "# Plan\n\nShip it.\n", submittedAt: "2026-01-01T00:00:00.000Z" },
|
|
13
|
+
],
|
|
14
|
+
annotations: [],
|
|
15
|
+
message: null,
|
|
16
|
+
status: "pending",
|
|
17
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const annotation: Annotation = {
|
|
23
|
+
id: "ann_1",
|
|
24
|
+
kind: "comment",
|
|
25
|
+
anchor: { quote: "Ship it.", prefix: "", suffix: "" },
|
|
26
|
+
body: "nit",
|
|
27
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("sameDerivationInputs", () => {
|
|
31
|
+
test("an annotation-only update reuses the projection (a fresh record, identical inputs)", () => {
|
|
32
|
+
// Arrange - the daemon re-serializes the whole thread on any change, so the record is all-new
|
|
33
|
+
const before = threadFixture();
|
|
34
|
+
const after = threadFixture({ annotations: [annotation] });
|
|
35
|
+
|
|
36
|
+
// Assert - the artifact and working copy are unchanged, so the projection can be reused
|
|
37
|
+
expect(sameDerivationInputs(before, after)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a content edit forces a re-derivation", () => {
|
|
41
|
+
const before = threadFixture();
|
|
42
|
+
const after = threadFixture({
|
|
43
|
+
artifact: { type: "plan", content: "# Plan\n\nShip it soon.\n", meta: {} },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(sameDerivationInputs(before, after)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a working-copy change forces a re-derivation", () => {
|
|
50
|
+
const before = threadFixture();
|
|
51
|
+
const after = threadFixture({ workingCopy: "# Plan\n\nShip it.\n\nedited\n" });
|
|
52
|
+
|
|
53
|
+
expect(sameDerivationInputs(before, after)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a different thread is never a reuse", () => {
|
|
57
|
+
const before = threadFixture();
|
|
58
|
+
const after = threadFixture({ id: "ses_2" });
|
|
59
|
+
|
|
60
|
+
expect(sameDerivationInputs(before, after)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|