@cueloop/client 0.1.0-alpha.78 → 0.1.0-alpha.79
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 +14 -13
- package/src/App.diff.test.tsx +9 -4
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +75 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +6 -4
- package/src/App.test.tsx +31 -28
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +259 -274
- package/src/App.walk.test.tsx +9 -8
- package/src/App.workbench.test.tsx +86 -0
- package/src/annotation-palette.ts +1 -0
- package/src/app-screens.tsx +12 -6
- package/src/app-view-model.ts +0 -1
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/DiffContentView.tsx +14 -7
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/GridTabContent.tsx +8 -2
- 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/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/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +1 -1
- package/src/components/ThreadView.slash.test.tsx +0 -35
- package/src/components/ThreadView.tsx +142 -68
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +2 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories.test.tsx +4 -3
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.ts +0 -1
- 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/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +8 -6
- package/src/share.ts +15 -1
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +30 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +229 -54
- package/src/use-annotation-surface.tsx +45 -36
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -122
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -80
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- package/src/components/agent-launcher.tsx +0 -149
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.79",
|
|
4
4
|
"description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -27,24 +27,25 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
34
|
-
"@opentui/core": "^0.5.
|
|
35
|
-
"@opentui/keymap": "0.5.
|
|
36
|
-
"@opentui/react": "^0.5.
|
|
37
|
-
"@opentui/ssh": "0.5.
|
|
38
|
-
"@pierre/diffs": "^1.
|
|
30
|
+
"@cueloop/daemon": "0.1.0-alpha.79",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.79",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.79",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.79",
|
|
34
|
+
"@opentui/core": "^0.5.11",
|
|
35
|
+
"@opentui/keymap": "0.5.11",
|
|
36
|
+
"@opentui/react": "^0.5.11",
|
|
37
|
+
"@opentui/ssh": "0.5.11",
|
|
38
|
+
"@pierre/diffs": "^1.4.1",
|
|
39
39
|
"@tanstack/virtual-core": "^3.17.9",
|
|
40
40
|
"diff": "^9",
|
|
41
|
-
"puppeteer-core": "^25.
|
|
41
|
+
"puppeteer-core": "^25.9.0",
|
|
42
42
|
"react": "^19.2.0",
|
|
43
|
-
"valibot": "^1.4.2"
|
|
43
|
+
"valibot": "^1.4.2",
|
|
44
|
+
"zustand": "^5.0.15"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"@types/react": "^19.2.0",
|
|
47
|
-
"@types/ssh2": "^1.15.
|
|
48
|
+
"@types/ssh2": "^1.15.6",
|
|
48
49
|
"ssh2": "^1.17.0"
|
|
49
50
|
},
|
|
50
51
|
"engines": {
|
package/src/App.diff.test.tsx
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
clickText,
|
|
14
14
|
dragText,
|
|
15
15
|
isolateUserConfig,
|
|
16
|
+
navCommand,
|
|
17
|
+
press,
|
|
16
18
|
pressKey,
|
|
17
19
|
renderReadyApp,
|
|
18
20
|
typeText,
|
|
@@ -92,8 +94,11 @@ describe("diff review", () => {
|
|
|
92
94
|
await waitForText(setup, "● Map needs an eviction story.");
|
|
93
95
|
expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("new Map()");
|
|
94
96
|
|
|
95
|
-
// Act - submit with the session chord (cmd+enter, no composer open),
|
|
97
|
+
// Act - submit with the session chord (cmd+enter, no composer open), cycle to request changes
|
|
96
98
|
await pressKey(setup, "RETURN", { meta: true });
|
|
99
|
+
await waitForText(setup, "[Approve]");
|
|
100
|
+
await press(setup, "right");
|
|
101
|
+
await waitForText(setup, "[Changes]");
|
|
97
102
|
await pressKey(setup, "RETURN", { meta: true });
|
|
98
103
|
|
|
99
104
|
// Assert
|
|
@@ -138,7 +143,7 @@ describe("diff review", () => {
|
|
|
138
143
|
const setup = await renderApp();
|
|
139
144
|
|
|
140
145
|
// Act
|
|
141
|
-
await
|
|
146
|
+
await navCommand(setup, "c");
|
|
142
147
|
|
|
143
148
|
// Assert - the body is gone but the band (with its counts) remains
|
|
144
149
|
await waitForState(setup, () => !setup.captureCharFrame().includes("new Map()"));
|
|
@@ -159,7 +164,7 @@ describe("diff review", () => {
|
|
|
159
164
|
const setup = await renderApp();
|
|
160
165
|
|
|
161
166
|
// Act
|
|
162
|
-
await
|
|
167
|
+
await navCommand(setup, "x");
|
|
163
168
|
|
|
164
169
|
// Assert
|
|
165
170
|
await waitForText(setup, "hunk curation needs full file contents");
|
|
@@ -187,7 +192,7 @@ describe("diff review", () => {
|
|
|
187
192
|
|
|
188
193
|
// Act - place the caret on the added line and reject its change
|
|
189
194
|
await clickText(setup, "new Map()");
|
|
190
|
-
await
|
|
195
|
+
await navCommand(setup, "x");
|
|
191
196
|
|
|
192
197
|
// Assert - the single change is gone, so the curated working copy is empty
|
|
193
198
|
await waitForText(setup, "change rejected");
|
package/src/App.focus.test.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
appLeaderHandled,
|
|
4
|
-
nextFocusPane,
|
|
5
|
-
reconciledFocus,
|
|
6
|
-
threadsNavHandled,
|
|
7
|
-
visiblePanes,
|
|
8
|
-
} from "./App";
|
|
2
|
+
import { nextFocusPane, reconciledFocus, threadsNavHandled, visiblePanes } from "./App";
|
|
9
3
|
|
|
10
4
|
describe("threads keyboard nav", () => {
|
|
11
5
|
const base = () => {
|
|
@@ -92,56 +86,4 @@ describe("pane focus cycle", () => {
|
|
|
92
86
|
expect(reconciledFocus(["changes", "project"], "thread")).toBe("changes");
|
|
93
87
|
expect(reconciledFocus([], "thread")).toBeNull();
|
|
94
88
|
});
|
|
95
|
-
|
|
96
|
-
test("appLeaderHandled captures the leader for the sidebar panes, then the next key", () => {
|
|
97
|
-
const leaderCombos = ["ctrl+g"];
|
|
98
|
-
const pending = { current: false };
|
|
99
|
-
const ran: string[] = [];
|
|
100
|
-
const call = (
|
|
101
|
-
focusedPane: "threads" | "project" | "thread" | "changes",
|
|
102
|
-
key: { name: string; ctrl?: boolean },
|
|
103
|
-
) =>
|
|
104
|
-
appLeaderHandled({
|
|
105
|
-
focusedPane,
|
|
106
|
-
key,
|
|
107
|
-
leaderCombos,
|
|
108
|
-
pending,
|
|
109
|
-
runLeaderCommand: (pressed) => ran.push(pressed.name),
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
expect(call("threads", { name: "g", ctrl: true })).toBe(true);
|
|
113
|
-
expect(pending.current).toBe(true);
|
|
114
|
-
expect(call("threads", { name: "tab" })).toBe(true);
|
|
115
|
-
expect(pending.current).toBe(false);
|
|
116
|
-
expect(ran).toEqual(["tab"]);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test("appLeaderHandled defers on the content panes and swallows escape without a command", () => {
|
|
120
|
-
const leaderCombos = ["ctrl+g"];
|
|
121
|
-
let ran = false;
|
|
122
|
-
const runLeaderCommand = () => (ran = true);
|
|
123
|
-
|
|
124
|
-
expect(
|
|
125
|
-
appLeaderHandled({
|
|
126
|
-
focusedPane: "thread",
|
|
127
|
-
key: { name: "ctrl+g" },
|
|
128
|
-
leaderCombos,
|
|
129
|
-
pending: { current: false },
|
|
130
|
-
runLeaderCommand,
|
|
131
|
-
}),
|
|
132
|
-
).toBe(false);
|
|
133
|
-
|
|
134
|
-
const pending = { current: true };
|
|
135
|
-
expect(
|
|
136
|
-
appLeaderHandled({
|
|
137
|
-
focusedPane: "project",
|
|
138
|
-
key: { name: "escape" },
|
|
139
|
-
leaderCombos,
|
|
140
|
-
pending,
|
|
141
|
-
runLeaderCommand,
|
|
142
|
-
}),
|
|
143
|
-
).toBe(true);
|
|
144
|
-
expect(pending.current).toBe(false);
|
|
145
|
-
expect(ran).toBe(false);
|
|
146
|
-
});
|
|
147
89
|
});
|
|
@@ -45,11 +45,11 @@ test("the Account settings tab shows the display name row and the GitHub sync ac
|
|
|
45
45
|
const setup = await renderApp();
|
|
46
46
|
|
|
47
47
|
await setup.mockMouse.click(1, 0);
|
|
48
|
-
await waitForText(setup, "
|
|
48
|
+
await waitForText(setup, "settings");
|
|
49
49
|
const menu = setup.captureCharFrame().split("\n");
|
|
50
|
-
const settingsRow = menu.findIndex((line) => line.includes("
|
|
50
|
+
const settingsRow = menu.findIndex((line) => line.includes("settings"));
|
|
51
51
|
|
|
52
|
-
await setup.mockMouse.click(menu[settingsRow]!.indexOf("
|
|
52
|
+
await setup.mockMouse.click(menu[settingsRow]!.indexOf("settings") + 1, settingsRow);
|
|
53
53
|
await waitForText(setup, "Account");
|
|
54
54
|
const dialog = setup.captureCharFrame().split("\n");
|
|
55
55
|
const accountRow = dialog.findIndex((line) => line.includes("Account"));
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-only plan render: h1/h2 sit over
|
|
3
|
+
* a rule, links show their label in the link color with the URL concealed,
|
|
4
|
+
* inline code grays with its backticks gone, GFM tables render as an aligned grid
|
|
5
|
+
* and leading YAML frontmatter as a bordered key/value table. Because rendered
|
|
6
|
+
* text now differs from the source, a comment dragged over a concealed-markup
|
|
7
|
+
* passage must still anchor to the source and repaint on the visible label.
|
|
8
|
+
* Char-frame + styled-span assertions over the real App and an in-process daemon.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import React from "react";
|
|
16
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
17
|
+
import type { Thread } from "@cueloop/schema";
|
|
18
|
+
import { App } from "./App";
|
|
19
|
+
import { DARK } from "./theme";
|
|
20
|
+
import {
|
|
21
|
+
dragText,
|
|
22
|
+
isolateUserConfig,
|
|
23
|
+
pressKey,
|
|
24
|
+
renderReadyApp,
|
|
25
|
+
settle,
|
|
26
|
+
typeText as type,
|
|
27
|
+
waitForState,
|
|
28
|
+
waitForText,
|
|
29
|
+
} from "./test-support";
|
|
30
|
+
|
|
31
|
+
const PLAN = `---
|
|
32
|
+
title: Migration Plan
|
|
33
|
+
owner: platform team
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
# Migration Plan
|
|
37
|
+
|
|
38
|
+
## Rollout
|
|
39
|
+
|
|
40
|
+
Ship it behind a \`flag\` and read [OpenTUI](https://opentui) first.
|
|
41
|
+
|
|
42
|
+
| Name | Size |
|
|
43
|
+
| :--- | ---: |
|
|
44
|
+
| daemon | 12 |
|
|
45
|
+
| client | 7 |
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
let home: string;
|
|
49
|
+
let server: DaemonServer;
|
|
50
|
+
let session: Thread;
|
|
51
|
+
let restoreUserConfig: () => void;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-plan-render-"));
|
|
55
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
56
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
57
|
+
server.start();
|
|
58
|
+
session = server.core.sessionCreate({
|
|
59
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
60
|
+
artifact: {
|
|
61
|
+
type: "plan",
|
|
62
|
+
content: PLAN,
|
|
63
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
restoreUserConfig();
|
|
69
|
+
server.stop();
|
|
70
|
+
rmSync(home, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
type Setup = Awaited<ReturnType<typeof renderPlan>>;
|
|
74
|
+
|
|
75
|
+
async function renderPlan() {
|
|
76
|
+
const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
|
|
77
|
+
width: 90,
|
|
78
|
+
height: 40,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await waitForText(setup, "cueloop");
|
|
82
|
+
await settle(setup);
|
|
83
|
+
|
|
84
|
+
return setup;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Hex foregrounds of every styled span containing the needle. */
|
|
88
|
+
function foregroundsOf(setup: Setup, needle: string): string[] {
|
|
89
|
+
const foregrounds: string[] = [];
|
|
90
|
+
|
|
91
|
+
for (const line of setup.captureSpans().lines) {
|
|
92
|
+
for (const span of line.spans) {
|
|
93
|
+
if (span.fg === undefined || !span.text.includes(needle)) continue;
|
|
94
|
+
const [red, green, blue] = span.fg.toInts();
|
|
95
|
+
|
|
96
|
+
foregrounds.push(
|
|
97
|
+
"#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return foregrounds;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Hex backgrounds of every styled span containing the needle. */
|
|
106
|
+
function backgroundsOf(setup: Setup, needle: string): string[] {
|
|
107
|
+
const backgrounds: string[] = [];
|
|
108
|
+
|
|
109
|
+
for (const line of setup.captureSpans().lines) {
|
|
110
|
+
for (const span of line.spans) {
|
|
111
|
+
if (!span.text.includes(needle)) continue;
|
|
112
|
+
const [red, green, blue] = span.bg.toInts();
|
|
113
|
+
|
|
114
|
+
backgrounds.push(
|
|
115
|
+
"#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return backgrounds;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const THREAD_MARK = "#463852";
|
|
124
|
+
|
|
125
|
+
describe("read-only markdown render", () => {
|
|
126
|
+
test("h1 and h2 sit over a rule", async () => {
|
|
127
|
+
const setup = await renderPlan();
|
|
128
|
+
const rows = setup.captureCharFrame().split("\n");
|
|
129
|
+
// "Migration Plan" also rides the header bar; a heading is any body row directly over a rule
|
|
130
|
+
const ruleBelow = (heading: string): boolean =>
|
|
131
|
+
rows.some((row, index) => row.includes(heading) && (rows[index + 1] ?? "").includes("──"));
|
|
132
|
+
|
|
133
|
+
expect(ruleBelow("Migration Plan")).toBe(true);
|
|
134
|
+
expect(ruleBelow("Rollout")).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a link shows its label in the link color with the URL concealed", async () => {
|
|
138
|
+
const setup = await renderPlan();
|
|
139
|
+
const frame = setup.captureCharFrame();
|
|
140
|
+
|
|
141
|
+
expect(frame).toContain("OpenTUI");
|
|
142
|
+
expect(frame).not.toContain("https://opentui");
|
|
143
|
+
expect(frame).not.toContain("[OpenTUI]");
|
|
144
|
+
expect(foregroundsOf(setup, "OpenTUI")).toContain(DARK.blue);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("inline code grays with its backticks concealed", async () => {
|
|
148
|
+
const setup = await renderPlan();
|
|
149
|
+
const frame = setup.captureCharFrame();
|
|
150
|
+
|
|
151
|
+
expect(frame).toContain("flag");
|
|
152
|
+
expect(frame).not.toContain("`flag`");
|
|
153
|
+
expect(foregroundsOf(setup, "flag")).toContain(DARK.textDim);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a table renders as an aligned grid without pipes", async () => {
|
|
157
|
+
const setup = await renderPlan();
|
|
158
|
+
const rows = setup.captureCharFrame().split("\n");
|
|
159
|
+
const header = rows.find((row) => row.includes("Name") && row.includes("Size"))!;
|
|
160
|
+
|
|
161
|
+
expect(header).not.toContain("|");
|
|
162
|
+
// the numeric column is right-aligned, so 12 and 7 line up on their last digit
|
|
163
|
+
const daemon = rows.find((row) => row.includes("daemon"))!;
|
|
164
|
+
const client = rows.find((row) => row.includes("client"))!;
|
|
165
|
+
|
|
166
|
+
expect(daemon.indexOf("12") + 1).toBe(client.indexOf("7"));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("frontmatter renders as a bordered key/value grid", async () => {
|
|
170
|
+
const setup = await renderPlan();
|
|
171
|
+
const frame = setup.captureCharFrame();
|
|
172
|
+
|
|
173
|
+
expect(frame).toContain("┌");
|
|
174
|
+
expect(frame).toContain("└");
|
|
175
|
+
expect(frame).toContain("title");
|
|
176
|
+
expect(frame).toContain("platform team");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("annotating a grid block", () => {
|
|
181
|
+
test("a comment dragged over a table cell anchors on the grid and marks it", async () => {
|
|
182
|
+
const setup = await renderPlan();
|
|
183
|
+
|
|
184
|
+
// the table renders as a grid, yet it still takes a comment like any other block
|
|
185
|
+
await dragText(setup, "daemon", "daemon", "daemon".length);
|
|
186
|
+
await type(setup, "which daemon?");
|
|
187
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
188
|
+
|
|
189
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
190
|
+
// the grid carries the mark backdrop, so an annotation resolving into it stays visible
|
|
191
|
+
await waitForState(setup, () => backgroundsOf(setup, "daemon").includes(THREAD_MARK));
|
|
192
|
+
}, 60_000);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("anchoring over concealed markup", () => {
|
|
196
|
+
test("a comment dragged over a rendered link label anchors to the source and repaints", async () => {
|
|
197
|
+
const setup = await renderPlan();
|
|
198
|
+
|
|
199
|
+
// drag the visible label "OpenTUI" (its brackets and URL are concealed)
|
|
200
|
+
await dragText(setup, "OpenTUI", "OpenTUI", "OpenTUI".length);
|
|
201
|
+
await type(setup, "which build?");
|
|
202
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
203
|
+
|
|
204
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
205
|
+
expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("OpenTUI");
|
|
206
|
+
// the saved comment keeps the visible label marked
|
|
207
|
+
await waitForState(setup, () => backgroundsOf(setup, "OpenTUI").includes(THREAD_MARK));
|
|
208
|
+
}, 60_000);
|
|
209
|
+
});
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
clickText,
|
|
15
15
|
dragText,
|
|
16
16
|
isolateUserConfig,
|
|
17
|
+
navCommand,
|
|
17
18
|
press,
|
|
18
19
|
pressKey,
|
|
19
20
|
renderReadyApp,
|
|
@@ -210,11 +211,15 @@ describe("the mark stays painted while composing", () => {
|
|
|
210
211
|
await waitForText(setup, "● x");
|
|
211
212
|
expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
|
|
212
213
|
|
|
213
|
-
// Act: escape discards the draft
|
|
214
|
+
// Act: escape discards the draft and the mark stays for re-typing; the next escape
|
|
215
|
+
// enters nav mode with the mark still held; only an escape in nav drops it
|
|
214
216
|
await press(setup, "escape");
|
|
215
217
|
await waitForTextGone(setup, "● x");
|
|
216
218
|
expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
|
|
217
219
|
await press(setup, "escape");
|
|
220
|
+
await waitForText(setup, "type to leave");
|
|
221
|
+
expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
|
|
222
|
+
await press(setup, "escape");
|
|
218
223
|
|
|
219
224
|
// Assert
|
|
220
225
|
await waitForState(setup, () => !backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
|
|
@@ -244,6 +249,22 @@ describe("the mark stays painted while composing", () => {
|
|
|
244
249
|
await waitForTextGone(setup, "● x");
|
|
245
250
|
expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
|
|
246
251
|
}, 60_000);
|
|
252
|
+
|
|
253
|
+
test("esc from type mode keeps the mark, so c in nav comments on the marked text", async () => {
|
|
254
|
+
// Arrange
|
|
255
|
+
const setup = await renderApp();
|
|
256
|
+
|
|
257
|
+
// Act: mark, enter nav with esc, open the composer with c, save
|
|
258
|
+
await dragText(setup, "The daemon", "daemon persists", "daemon".length);
|
|
259
|
+
await navCommand(setup, "c");
|
|
260
|
+
await type(setup, "Which daemon?");
|
|
261
|
+
await waitForText(setup, "● Which daemon?");
|
|
262
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
263
|
+
|
|
264
|
+
// Assert: the comment anchors to the mark, not to the word under the caret
|
|
265
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
266
|
+
expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
|
|
267
|
+
}, 60_000);
|
|
247
268
|
});
|
|
248
269
|
|
|
249
270
|
describe("compose newline convention", () => {
|
|
@@ -311,7 +332,7 @@ describe("quick-actions settings editor", () => {
|
|
|
311
332
|
// Act - open Settings from the top-left gear, enter Actions, expand the first action, type
|
|
312
333
|
await setup.mockMouse.click(1, 0);
|
|
313
334
|
await waitForText(setup, "Keybinds");
|
|
314
|
-
await clickText(setup, "
|
|
335
|
+
await clickText(setup, "settings");
|
|
315
336
|
await clickText(setup, "Actions");
|
|
316
337
|
await clickText(setup, "Zoom out, research in depth");
|
|
317
338
|
await type(setup, "CUSTOM");
|
|
@@ -327,30 +348,58 @@ describe("quick-actions settings editor", () => {
|
|
|
327
348
|
|
|
328
349
|
describe("edit-exit reconciliation", () => {
|
|
329
350
|
test("an edit that removes an anchored passage orphans the annotation and shows the banner", async () => {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
351
|
+
// Arrange - anchor a comment to a passage
|
|
352
|
+
const setup = await renderApp();
|
|
353
|
+
|
|
354
|
+
await clickText(setup, "daemon");
|
|
355
|
+
await type(setup, "Anchor me to the doomed passage.");
|
|
356
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
357
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
358
|
+
|
|
359
|
+
// Act - open the inline editor, replace the whole body (dropping the passage), and leave
|
|
360
|
+
await pressKey(setup, "e", { ctrl: true });
|
|
361
|
+
await waitForText(setup, "save & close");
|
|
362
|
+
await pressKey(setup, "a", { meta: true });
|
|
363
|
+
await type(setup, "A fresh body with no anchored passage.");
|
|
364
|
+
await clickText(setup, "normal");
|
|
365
|
+
|
|
366
|
+
// Assert - the thread view banner reports the orphaned annotation
|
|
367
|
+
await waitForText(setup, "1 annotation no longer match - the passage was removed.");
|
|
368
|
+
// the annotation is NOT deleted: the feedback serializer handles orphans
|
|
369
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe("thread switch preserves an open edit", () => {
|
|
374
|
+
test("clicking another thread while editing saves the body into the leaving thread", async () => {
|
|
375
|
+
// Arrange - a second thread sits in the sidebar to switch to
|
|
376
|
+
server.core.sessionCreate({
|
|
377
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
378
|
+
artifact: {
|
|
379
|
+
type: "plan",
|
|
380
|
+
content: "# Second Thread\n\nAnother body.\n",
|
|
381
|
+
meta: { title: "Second Thread", planPath: "plan.md", agent: "agent/worker-3" },
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
const setup = await renderApp();
|
|
385
|
+
|
|
386
|
+
// open the Threads sidebar so the other thread is on screen to click
|
|
387
|
+
await setup.mockMouse.click(4, 0);
|
|
388
|
+
await waitForText(setup, "Second Thread");
|
|
389
|
+
|
|
390
|
+
// Act - open the inline editor, rewrite the body, then click away before an explicit save
|
|
391
|
+
await pressKey(setup, "e", { ctrl: true });
|
|
392
|
+
await waitForText(setup, "save & close");
|
|
393
|
+
await pressKey(setup, "a", { meta: true });
|
|
394
|
+
await type(setup, "Body kept across a thread switch.");
|
|
395
|
+
await clickText(setup, "Second Thread");
|
|
396
|
+
|
|
397
|
+
// Assert - the leaving thread's working copy holds the edit rather than dropping it
|
|
398
|
+
await waitForState(setup, () =>
|
|
399
|
+
(server.core.sessionGet(session.id).workingCopy ?? "").includes(
|
|
400
|
+
"kept across a thread switch",
|
|
401
|
+
),
|
|
402
|
+
);
|
|
354
403
|
});
|
|
355
404
|
});
|
|
356
405
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
dragText,
|
|
14
14
|
frameRow,
|
|
15
15
|
isolateUserConfig,
|
|
16
|
+
navCommand,
|
|
16
17
|
pressKey,
|
|
17
18
|
renderReadyApp,
|
|
18
19
|
typeText,
|
|
@@ -97,7 +98,7 @@ describe("observer rendering", () => {
|
|
|
97
98
|
describe("observer primitives are blocked", () => {
|
|
98
99
|
const attempts: Array<[string, (setup: Setup) => Promise<void>]> = [
|
|
99
100
|
["comment (typing)", (setup) => typeText(setup, "c")],
|
|
100
|
-
["cut (option+x)", (setup) =>
|
|
101
|
+
["cut (option+x)", (setup) => navCommand(setup, "x")],
|
|
101
102
|
["edit (ctrl+e)", (setup) => pressKey(setup, "e", { ctrl: true })],
|
|
102
103
|
["submit (cmd+enter)", (setup) => pressKey(setup, "RETURN", { meta: true })],
|
|
103
104
|
];
|
|
@@ -159,10 +160,10 @@ describe("a resolved review is read-only for its owner too", () => {
|
|
|
159
160
|
expect(setup.captureCharFrame()).not.toContain("● c");
|
|
160
161
|
|
|
161
162
|
// Act + Assert: the focused card cannot be deleted or edited
|
|
162
|
-
await
|
|
163
|
+
await navCommand(setup, "n");
|
|
163
164
|
await pressKey(setup, "BACKSPACE", { meta: true });
|
|
164
|
-
await pressKey(setup, "e", {
|
|
165
|
-
await
|
|
165
|
+
await pressKey(setup, "e", { ctrl: true });
|
|
166
|
+
await navCommand(setup, "x");
|
|
166
167
|
expect(snapshot()).toEqual(before);
|
|
167
168
|
expect(server.core.sessionGet(session.id).annotations).toHaveLength(1);
|
|
168
169
|
});
|
|
@@ -199,7 +200,7 @@ describe("observer navigation still works", () => {
|
|
|
199
200
|
const setup = await renderObserver();
|
|
200
201
|
|
|
201
202
|
// Act - the observer navigates to the annotation without mutating anything
|
|
202
|
-
await
|
|
203
|
+
await navCommand(setup, "n");
|
|
203
204
|
await setup.renderOnce();
|
|
204
205
|
|
|
205
206
|
// Assert - the observer reads the controller's comment inline in the thread
|