@cueloop/client 0.1.0-alpha.25 → 0.1.0-alpha.27
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 +5 -5
- package/src/App.plan-review.test.tsx +52 -0
- package/src/App.tsx +77 -4
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +32 -0
- package/src/components/AnnotationCard.tsx +24 -5
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +56 -0
- package/src/components/InboxList.tsx +27 -14
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +62 -0
- package/src/components/ReviewPanel.stories.tsx +1 -0
- package/src/components/ReviewRail.stories.tsx +4 -0
- package/src/components/ReviewRail.tsx +5 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +62 -2
- package/src/config.test.ts +30 -1
- package/src/config.ts +36 -0
- package/src/intent-dispatch.test.ts +95 -0
- package/src/intent-dispatch.ts +26 -1
- package/src/keymap.test.ts +38 -0
- package/src/keymap.ts +21 -1
- package/src/session-controller.share.test.ts +12 -6
- package/src/session-controller.ts +20 -1
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.27",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
11
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
12
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
10
|
+
"@cueloop/daemon": "0.1.0-alpha.27",
|
|
11
|
+
"@cueloop/extension-api": "0.1.0-alpha.27",
|
|
12
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.27",
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.27",
|
|
14
14
|
"@opentui/core": "^0.5.1",
|
|
15
15
|
"@opentui/keymap": "0.5.1",
|
|
16
16
|
"@opentui/react": "^0.5.1",
|
|
@@ -27,9 +27,14 @@ The daemon persists sessions to disk atomically.
|
|
|
27
27
|
let home: string;
|
|
28
28
|
let server: DaemonServer;
|
|
29
29
|
let session: ReviewSession;
|
|
30
|
+
let priorConfig: string | undefined;
|
|
30
31
|
|
|
31
32
|
beforeEach(() => {
|
|
32
33
|
home = mkdtempSync(join(tmpdir(), "cueloop-plan-review-"));
|
|
34
|
+
// Isolate from the dev's real config so the rail state is the default here,
|
|
35
|
+
// not whatever review_state they last persisted (CI runs clean already).
|
|
36
|
+
priorConfig = process.env.CUELOOP_CONFIG;
|
|
37
|
+
process.env.CUELOOP_CONFIG = join(home, "no-config.toml");
|
|
33
38
|
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
34
39
|
server.start();
|
|
35
40
|
session = server.core.sessionCreate({
|
|
@@ -38,6 +43,8 @@ beforeEach(() => {
|
|
|
38
43
|
});
|
|
39
44
|
});
|
|
40
45
|
afterEach(() => {
|
|
46
|
+
if (priorConfig === undefined) delete process.env.CUELOOP_CONFIG;
|
|
47
|
+
else process.env.CUELOOP_CONFIG = priorConfig;
|
|
41
48
|
server.stop();
|
|
42
49
|
rmSync(home, { recursive: true, force: true });
|
|
43
50
|
});
|
|
@@ -130,6 +137,28 @@ describe("edit affordance", () => {
|
|
|
130
137
|
});
|
|
131
138
|
});
|
|
132
139
|
|
|
140
|
+
describe("attribution", () => {
|
|
141
|
+
test("a collaborator's note shows their name in the card border", async () => {
|
|
142
|
+
// Arrange - a pulled collaborator note carries an author fingerprint
|
|
143
|
+
const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
|
|
144
|
+
const blocks = parseBlocks(PLAN);
|
|
145
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
|
|
146
|
+
server.core.sessionAnnotate(session.id, {
|
|
147
|
+
id: "collab-1",
|
|
148
|
+
kind: "comment",
|
|
149
|
+
anchor: makeAnchor(blocks, contextBlockIndex, 0, 10),
|
|
150
|
+
body: "who owns retries?",
|
|
151
|
+
author: "SHA256:1a2b3c4d5e6f",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Act
|
|
155
|
+
const setup = await renderApp();
|
|
156
|
+
|
|
157
|
+
// Assert - the short author handle rides the card border
|
|
158
|
+
await waitForText(setup, "1a2b3c4d");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
133
162
|
describe("selection feeds the annotation quote", () => {
|
|
134
163
|
test("v anchors, l extends, c composes with the char-precise quote", async () => {
|
|
135
164
|
// Arrange
|
|
@@ -335,6 +364,29 @@ describe("the document selects, the rail edits", () => {
|
|
|
335
364
|
);
|
|
336
365
|
});
|
|
337
366
|
|
|
367
|
+
test("activating a collaborator's card opens rename, not a body edit", async () => {
|
|
368
|
+
// Arrange - a pulled collaborator note carries an author fingerprint
|
|
369
|
+
const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
|
|
370
|
+
const blocks = parseBlocks(PLAN);
|
|
371
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
|
|
372
|
+
server.core.sessionAnnotate(session.id, {
|
|
373
|
+
id: "collab-1",
|
|
374
|
+
kind: "comment",
|
|
375
|
+
anchor: makeAnchor(blocks, contextBlockIndex, 0, 10),
|
|
376
|
+
body: "who owns retries?",
|
|
377
|
+
author: "SHA256:1a2b3c4d5e6f",
|
|
378
|
+
});
|
|
379
|
+
const setup = await renderApp();
|
|
380
|
+
await waitForText(setup, "1a2b3c4d");
|
|
381
|
+
|
|
382
|
+
// Act - focus the collaborator card, then activate it
|
|
383
|
+
await press(setup, "n");
|
|
384
|
+
await press(setup, "e");
|
|
385
|
+
|
|
386
|
+
// Assert - the rename prompt opens; the body never becomes an editor
|
|
387
|
+
await waitForText(setup, "Rename author");
|
|
388
|
+
});
|
|
389
|
+
|
|
338
390
|
test("x deletes the selected card and un-paints the document highlight", async () => {
|
|
339
391
|
// Arrange
|
|
340
392
|
const setup = await renderApp();
|
package/src/App.tsx
CHANGED
|
@@ -16,7 +16,7 @@ import { type VerdictKind } from "@cueloop/schema";
|
|
|
16
16
|
import { displayText, marksByDisplay, type Mark } from "./view-plan";
|
|
17
17
|
import { noteForFile, viewedCount } from "./walk";
|
|
18
18
|
import { DARK, dimmedTheme } from "./theme";
|
|
19
|
-
import { DEFAULT_KEYS, loadConfig } from "./config";
|
|
19
|
+
import { DEFAULT_KEYS, loadConfig, persistAuthorName } from "./config";
|
|
20
20
|
import { returnPaneFor } from "@cueloop/schema";
|
|
21
21
|
import { createReviewController } from "./session-controller";
|
|
22
22
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
@@ -39,6 +39,8 @@ import {
|
|
|
39
39
|
} from "./review-panel";
|
|
40
40
|
import { CompletionOverlay } from "./components/CompletionOverlay";
|
|
41
41
|
import { InboxList } from "./components/InboxList";
|
|
42
|
+
import { ConfirmDialog } from "./components/ConfirmDialog";
|
|
43
|
+
import { PromptDialog } from "./components/PromptDialog";
|
|
42
44
|
import { ComposeBar } from "./components/ComposeBar";
|
|
43
45
|
import { WalkWizard } from "./components/WalkWizard";
|
|
44
46
|
|
|
@@ -67,9 +69,14 @@ export interface AppProps {
|
|
|
67
69
|
* viewer: annotates, but cannot edit the plan or submit an agent verdict.
|
|
68
70
|
*/
|
|
69
71
|
role?: "owner" | "observer" | "collaborator";
|
|
72
|
+
/**
|
|
73
|
+
* A collaborator's own SSH fingerprint. On first open of a share it seeds the
|
|
74
|
+
* name prompt so their notes attribute to a name, not a fingerprint.
|
|
75
|
+
*/
|
|
76
|
+
selfAuthor?: string;
|
|
70
77
|
}
|
|
71
78
|
|
|
72
|
-
export function App({ home, sessionId, readOnly = false, onExit, clock, openClient, role = "owner" }: AppProps): React.ReactNode {
|
|
79
|
+
export function App({ home, sessionId, readOnly = false, onExit, clock, openClient, role = "owner", selfAuthor }: AppProps): React.ReactNode {
|
|
73
80
|
// Observer stays fully read-only; a collaborator writes annotations but not
|
|
74
81
|
// the plan or a verdict. `observer` is what the controller and every write
|
|
75
82
|
// gate key off; the two capability flags carve out the collaborator's middle.
|
|
@@ -126,6 +133,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
126
133
|
const keysRef = useRef(DEFAULT_KEYS);
|
|
127
134
|
const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
|
|
128
135
|
const [theme, setTheme] = useState(DARK);
|
|
136
|
+
const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
|
|
129
137
|
useEffect(() => {
|
|
130
138
|
const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
|
|
131
139
|
keysRef.current = config.keys;
|
|
@@ -134,6 +142,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
134
142
|
setReviewMode(config.ui.reviewState);
|
|
135
143
|
setReviewWidth(config.ui.reviewWidth);
|
|
136
144
|
reviewWidthRef.current = config.ui.reviewWidth;
|
|
145
|
+
setAuthorNames(config.authors);
|
|
137
146
|
controller.applyConfig(config);
|
|
138
147
|
}, [session?.workspace.repoRoot, controller, keyBindings]);
|
|
139
148
|
useEffect(
|
|
@@ -143,6 +152,16 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
143
152
|
[],
|
|
144
153
|
);
|
|
145
154
|
|
|
155
|
+
// First open of a share: ask the collaborator for a display name once, unless
|
|
156
|
+
// a past visit already recorded one. esc skips and their notes read anonymous.
|
|
157
|
+
const promptedSelfRef = useRef(false);
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (promptedSelfRef.current || role !== "collaborator" || !selfAuthor || !session) return;
|
|
160
|
+
promptedSelfRef.current = true;
|
|
161
|
+
const known = session.participants?.find((participant) => participant.id === selfAuthor)?.name;
|
|
162
|
+
if (!known) setMode({ type: "nameSelf", text: "" });
|
|
163
|
+
}, [role, selfAuthor, session]);
|
|
164
|
+
|
|
146
165
|
// ── derived view model ──────────────────────
|
|
147
166
|
const display = controller.display();
|
|
148
167
|
const rows = controller.rows();
|
|
@@ -204,9 +223,15 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
204
223
|
|
|
205
224
|
const openCardEdit = (annotationId: string): void => {
|
|
206
225
|
if (observer) return controller.setStatus("observer - read-only");
|
|
207
|
-
if (resolved) return controller.setStatus("review submitted - read-only");
|
|
208
226
|
const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
|
|
209
227
|
if (!annotation) return;
|
|
228
|
+
// a collaborator's note is theirs to word: activating it (click or e) renames
|
|
229
|
+
// the author rather than editing the body the planner does not own
|
|
230
|
+
if (annotation.author) {
|
|
231
|
+
setFocusedAnnotationId(annotationId);
|
|
232
|
+
return void setMode({ type: "rename", authorId: annotation.author, text: authorNames[annotation.author] ?? "" });
|
|
233
|
+
}
|
|
234
|
+
if (resolved) return controller.setStatus("review submitted - read-only");
|
|
210
235
|
liveInput.current = annotation.body;
|
|
211
236
|
setMode({ type: "railEdit", id: annotation.id, text: annotation.body });
|
|
212
237
|
};
|
|
@@ -237,6 +262,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
237
262
|
reviewWidth,
|
|
238
263
|
terminalWidth,
|
|
239
264
|
focusedAnnotationId,
|
|
265
|
+
authorNames,
|
|
266
|
+
renameAuthor: (id: string, name: string) => {
|
|
267
|
+
persistAuthorName(id, name);
|
|
268
|
+
setAuthorNames((prev) => ({ ...prev, [id]: name }));
|
|
269
|
+
},
|
|
240
270
|
liveInput,
|
|
241
271
|
reviewWidthRef,
|
|
242
272
|
planSheetRef,
|
|
@@ -258,6 +288,10 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
258
288
|
? "compose"
|
|
259
289
|
: mode.type === "submit"
|
|
260
290
|
? "submit"
|
|
291
|
+
: mode.type === "confirmDelete"
|
|
292
|
+
? "confirm"
|
|
293
|
+
: mode.type === "rename" || mode.type === "nameSelf"
|
|
294
|
+
? "prompt"
|
|
261
295
|
: completion.phase === "prompt"
|
|
262
296
|
? "completion-prompt"
|
|
263
297
|
: completion.phase === "counting"
|
|
@@ -307,9 +341,25 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
307
341
|
);
|
|
308
342
|
}
|
|
309
343
|
if (!session && inbox) {
|
|
344
|
+
const confirming = mode.type === "confirmDelete" ? mode : null;
|
|
310
345
|
return (
|
|
311
346
|
<ThemeProvider theme={theme}>
|
|
312
|
-
<InboxList
|
|
347
|
+
<InboxList
|
|
348
|
+
inbox={inbox}
|
|
349
|
+
cursor={inboxCursor}
|
|
350
|
+
onRequestDelete={(id, title) => setMode({ type: "confirmDelete", sessionId: id, title })}
|
|
351
|
+
/>
|
|
352
|
+
<ConfirmDialog
|
|
353
|
+
isOpen={confirming !== null}
|
|
354
|
+
title=" Delete plan "
|
|
355
|
+
message={confirming ? `Delete "${confirming.title}"? This removes the plan and its review.` : ""}
|
|
356
|
+
onConfirm={() => {
|
|
357
|
+
if (confirming) controller.deleteSession(confirming.sessionId);
|
|
358
|
+
setMode({ type: "normal" });
|
|
359
|
+
}}
|
|
360
|
+
onCancel={() => setMode({ type: "normal" })}
|
|
361
|
+
theme={theme}
|
|
362
|
+
/>
|
|
313
363
|
</ThemeProvider>
|
|
314
364
|
);
|
|
315
365
|
}
|
|
@@ -513,6 +563,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
513
563
|
railRef={railRef}
|
|
514
564
|
rail={{
|
|
515
565
|
session: activeSession,
|
|
566
|
+
authorNames,
|
|
516
567
|
selectedId: focusedAnnotationId,
|
|
517
568
|
resolvedIds: isDiff ? null : resolvedIds,
|
|
518
569
|
railTab,
|
|
@@ -557,6 +608,28 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
|
|
|
557
608
|
onBack={() => dispatch({ type: "walkBack" })}
|
|
558
609
|
/>
|
|
559
610
|
) : null}
|
|
611
|
+
{mode.type === "rename" ? (
|
|
612
|
+
<PromptDialog
|
|
613
|
+
isOpen
|
|
614
|
+
title=" Rename author "
|
|
615
|
+
label="Display name for this collaborator:"
|
|
616
|
+
value={mode.text}
|
|
617
|
+
placeholder="their name"
|
|
618
|
+
onInput={(text) => setMode({ ...mode, text })}
|
|
619
|
+
theme={theme}
|
|
620
|
+
/>
|
|
621
|
+
) : null}
|
|
622
|
+
{mode.type === "nameSelf" ? (
|
|
623
|
+
<PromptDialog
|
|
624
|
+
isOpen
|
|
625
|
+
title=" Welcome "
|
|
626
|
+
label="Your name (optional) - it attributes the notes you leave:"
|
|
627
|
+
value={mode.text}
|
|
628
|
+
placeholder="your name"
|
|
629
|
+
onInput={(text) => setMode({ ...mode, text })}
|
|
630
|
+
theme={theme}
|
|
631
|
+
/>
|
|
632
|
+
) : null}
|
|
560
633
|
</box>
|
|
561
634
|
</ThemeProvider>
|
|
562
635
|
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Identity } from "@cueloop/schema";
|
|
3
|
+
import { ANONYMOUS_LABEL, resolveDisplayName, shortHandle } from "./attribution";
|
|
4
|
+
|
|
5
|
+
describe(shortHandle, () => {
|
|
6
|
+
test("shortens an ssh fingerprint to a stable handle", () => {
|
|
7
|
+
// Arrange / Act / Assert
|
|
8
|
+
expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe("1a2b3c4d");
|
|
9
|
+
expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe(shortHandle("SHA256:1a2b3c4d5e6f"));
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe(resolveDisplayName, () => {
|
|
14
|
+
const named: Identity[] = [{ id: "SHA256:abc", provider: "ssh", name: "Al" }];
|
|
15
|
+
const unnamed: Identity[] = [{ id: "SHA256:abc", provider: "ssh" }];
|
|
16
|
+
|
|
17
|
+
test("a local rename override wins over everything", () => {
|
|
18
|
+
// Arrange / Act
|
|
19
|
+
const label = resolveDisplayName("SHA256:abc", named, { "SHA256:abc": "Alex" });
|
|
20
|
+
|
|
21
|
+
// Assert
|
|
22
|
+
expect(label).toBe("Alex");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("falls back to name, then anonymous, then the short handle", () => {
|
|
26
|
+
// Arrange / Act / Assert
|
|
27
|
+
expect(resolveDisplayName("SHA256:abc", named, {})).toBe("Al");
|
|
28
|
+
expect(resolveDisplayName("SHA256:abc", unnamed, {})).toBe(ANONYMOUS_LABEL);
|
|
29
|
+
expect(resolveDisplayName("SHA256:1a2b3c4d5e", [], {})).toBe("1a2b3c4d");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attribution labels for annotations. An annotation's `author` is an identity
|
|
3
|
+
* id (an SSH fingerprint today). The rail resolves it against the share's
|
|
4
|
+
* participant registry and the planner's local rename overrides; ADR 0006.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Identity } from "@cueloop/schema";
|
|
8
|
+
|
|
9
|
+
const FINGERPRINT_PREFIX = "SHA256:";
|
|
10
|
+
|
|
11
|
+
/** Shown for a known participant who chose not to give a name. */
|
|
12
|
+
export const ANONYMOUS_LABEL = "-- anonymous --";
|
|
13
|
+
|
|
14
|
+
/** A short, stable handle from a raw author id when no name is known. */
|
|
15
|
+
export function shortHandle(authorId: string): string {
|
|
16
|
+
const base = authorId.startsWith(FINGERPRINT_PREFIX) ? authorId.slice(FINGERPRINT_PREFIX.length) : authorId;
|
|
17
|
+
return base.slice(0, 8) || authorId;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The rail label for an author id: a local rename wins, then the collaborator's
|
|
22
|
+
* own name, then anonymous for a known-but-unnamed participant, then a short
|
|
23
|
+
* handle for an id we hold no identity for.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveDisplayName(authorId: string, participants: Identity[] | undefined, overrides: Record<string, string>): string {
|
|
26
|
+
const override = overrides[authorId];
|
|
27
|
+
if (override) return override;
|
|
28
|
+
const identity = participants?.find((participant) => participant.id === authorId);
|
|
29
|
+
if (identity?.name) return identity.name;
|
|
30
|
+
if (identity) return ANONYMOUS_LABEL;
|
|
31
|
+
return shortHandle(authorId);
|
|
32
|
+
}
|
|
@@ -14,7 +14,8 @@ import React, { useEffect, useRef, useState } from "react";
|
|
|
14
14
|
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
15
15
|
import type { Theme } from "../theme";
|
|
16
16
|
import { useComponentTheme } from "./theme-context";
|
|
17
|
-
import { Card } from "./primitives/Card";
|
|
17
|
+
import { Card, cardHeight } from "./primitives/Card";
|
|
18
|
+
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
18
19
|
import { Button } from "./primitives/Button";
|
|
19
20
|
import { Toolbar } from "./primitives/Toolbar";
|
|
20
21
|
import { truncateToSingleLine } from "./truncate-text";
|
|
@@ -31,6 +32,8 @@ export interface AnnotationSaved {
|
|
|
31
32
|
isSelected: boolean;
|
|
32
33
|
isOrphan: boolean;
|
|
33
34
|
isBlocking: boolean;
|
|
35
|
+
/** Resolved author display name; own notes carry none. Set = name-in-border. */
|
|
36
|
+
authorLabel?: string;
|
|
34
37
|
/** Non-null while the card body is being rewritten in place. */
|
|
35
38
|
editing: AnnotationDraft | null;
|
|
36
39
|
onPress: () => void;
|
|
@@ -166,23 +169,39 @@ export function AnnotationCard({ id, kind, quote, draft, saved, theme }: Annotat
|
|
|
166
169
|
);
|
|
167
170
|
}
|
|
168
171
|
const card = saved!;
|
|
172
|
+
// A collaborator's note (author set) renders as a named, bordered card; own notes stay borderless.
|
|
173
|
+
const authored = card.authorLabel !== undefined && card.authorLabel !== "";
|
|
174
|
+
const indent = authored ? "" : " ";
|
|
175
|
+
const marker = card.isSelected ? "▸ " : indent;
|
|
176
|
+
// Fixed content rows (a bordered box must declare a height or it collapses): header + quote + body, or the editor.
|
|
177
|
+
const contentRows = card.editing ? editorRowCount + 3 : 3;
|
|
178
|
+
// The border + its side padding eat this much content width, so text truncates shorter.
|
|
179
|
+
const borderInset = authored ? 4 : 0;
|
|
169
180
|
return (
|
|
170
181
|
<box
|
|
171
182
|
id={id}
|
|
172
|
-
|
|
183
|
+
title={authored ? ` ${card.authorLabel} ` : undefined}
|
|
184
|
+
style={{
|
|
185
|
+
flexDirection: "column",
|
|
186
|
+
marginBottom: 1,
|
|
187
|
+
backgroundColor: card.isSelected ? tokens.elevated : undefined,
|
|
188
|
+
...(authored
|
|
189
|
+
? { height: cardHeight(contentRows), border: true, borderStyle: FRAME_BORDER_STYLE, borderColor: tokens.blue, paddingLeft: 1, paddingRight: 1 }
|
|
190
|
+
: {}),
|
|
191
|
+
}}
|
|
173
192
|
onMouseUp={card.onPress}
|
|
174
193
|
>
|
|
175
194
|
<text fg={card.isSelected ? tokens.text : kindColor}>
|
|
176
|
-
{
|
|
195
|
+
{marker}
|
|
177
196
|
{kind.toUpperCase()}
|
|
178
197
|
{card.isBlocking ? <span fg={tokens.red}> · BLOCKING</span> : null}
|
|
179
198
|
<span fg={tokens.textDim}>{card.isOrphan ? " · ORPHANED" : " · pending"}</span>
|
|
180
199
|
</text>
|
|
181
|
-
<text fg={tokens.textDim}>
|
|
200
|
+
<text fg={tokens.textDim}>{indent}"{truncateToSingleLine(quote, 26 - borderInset)}"</text>
|
|
182
201
|
{card.editing ? (
|
|
183
202
|
<DraftEditor draft={card.editing} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
|
|
184
203
|
) : (
|
|
185
|
-
<text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}>
|
|
204
|
+
<text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}>{indent}{truncateToSingleLine(card.body, 28 - borderInset)}</text>
|
|
186
205
|
)}
|
|
187
206
|
</box>
|
|
188
207
|
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ConfirmDialog } from "./ConfirmDialog";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "ConfirmDialog" };
|
|
7
|
+
|
|
8
|
+
const callbacks = { onConfirm: () => {}, onCancel: () => {} };
|
|
9
|
+
|
|
10
|
+
export const DeletePlan: Story = {
|
|
11
|
+
render: () => (
|
|
12
|
+
<ConfirmDialog
|
|
13
|
+
isOpen
|
|
14
|
+
title=" Delete plan "
|
|
15
|
+
message={'Delete "Auth rollout"? This removes the plan and its review.'}
|
|
16
|
+
{...callbacks}
|
|
17
|
+
/>
|
|
18
|
+
),
|
|
19
|
+
expectedColors: [DARK.backdrop, DARK.panel, DARK.accent],
|
|
20
|
+
size: { width: 64, height: 16 },
|
|
21
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A centered yes/no confirmation on the shared Dialog overlay. Presentational:
|
|
3
|
+
* the key grammar (⏎ confirm, esc cancel) is driven by the reducer; the buttons
|
|
4
|
+
* carry the same intents for the mouse. Used for destructive actions like the
|
|
5
|
+
* inbox delete.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
10
|
+
import type { Theme } from "../theme";
|
|
11
|
+
import { useComponentTheme } from "./theme-context";
|
|
12
|
+
import { Dialog } from "./primitives/Dialog";
|
|
13
|
+
import { Button } from "./primitives/Button";
|
|
14
|
+
import { Toolbar } from "./primitives/Toolbar";
|
|
15
|
+
|
|
16
|
+
export interface ConfirmDialogProps {
|
|
17
|
+
isOpen: boolean;
|
|
18
|
+
title?: string;
|
|
19
|
+
message: string;
|
|
20
|
+
confirmLabel?: string;
|
|
21
|
+
cancelLabel?: string;
|
|
22
|
+
onConfirm: () => void;
|
|
23
|
+
onCancel: () => void;
|
|
24
|
+
theme?: Theme;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ConfirmDialog({
|
|
28
|
+
isOpen,
|
|
29
|
+
title,
|
|
30
|
+
message,
|
|
31
|
+
confirmLabel = "Delete",
|
|
32
|
+
cancelLabel = "Cancel",
|
|
33
|
+
onConfirm,
|
|
34
|
+
onCancel,
|
|
35
|
+
theme,
|
|
36
|
+
}: ConfirmDialogProps): React.ReactNode {
|
|
37
|
+
const tokens = useComponentTheme(theme);
|
|
38
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
39
|
+
if (!isOpen) return null;
|
|
40
|
+
return (
|
|
41
|
+
<Dialog isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
|
|
42
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
|
|
43
|
+
<text fg={tokens.text}>{message}</text>
|
|
44
|
+
<box style={{ flexGrow: 1 }} />
|
|
45
|
+
<Toolbar>
|
|
46
|
+
<Button variant="solid" onPress={onConfirm} theme={theme}>
|
|
47
|
+
{` ${confirmLabel} ⏎ `}
|
|
48
|
+
</Button>
|
|
49
|
+
<Button onPress={onCancel} theme={theme}>
|
|
50
|
+
{` ${cancelLabel} esc `}
|
|
51
|
+
</Button>
|
|
52
|
+
</Toolbar>
|
|
53
|
+
</box>
|
|
54
|
+
</Dialog>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pending-review inbox: a cursor-driven session list. Selection stays
|
|
3
|
-
*
|
|
2
|
+
* The pending-review inbox: a cursor-driven session list. Selection stays with
|
|
3
|
+
* the keyboard grammar; the list only renders the snapshot. The selected row
|
|
4
|
+
* carries a [delete] word-button (click, or `d` in the grammar) that asks the
|
|
5
|
+
* caller to confirm removal.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import React from "react";
|
|
@@ -8,14 +10,17 @@ import type { ReviewSession } from "@cueloop/schema";
|
|
|
8
10
|
import type { Theme } from "../theme";
|
|
9
11
|
import { useComponentTheme } from "./theme-context";
|
|
10
12
|
import { StatusBar } from "./primitives/StatusBar";
|
|
13
|
+
import { Button } from "./primitives/Button";
|
|
11
14
|
|
|
12
15
|
export interface InboxListProps {
|
|
13
16
|
inbox: ReviewSession[];
|
|
14
17
|
cursor: number;
|
|
18
|
+
/** Ask to delete a plan (the selected row's [delete] button). */
|
|
19
|
+
onRequestDelete?: (id: string, title: string) => void;
|
|
15
20
|
theme?: Theme;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
export function InboxList({ inbox, cursor, theme }: InboxListProps): React.ReactNode {
|
|
23
|
+
export function InboxList({ inbox, cursor, onRequestDelete, theme }: InboxListProps): React.ReactNode {
|
|
19
24
|
const tokens = useComponentTheme(theme);
|
|
20
25
|
return (
|
|
21
26
|
<box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: tokens.bg, padding: 1 }}>
|
|
@@ -24,19 +29,27 @@ export function InboxList({ inbox, cursor, theme }: InboxListProps): React.React
|
|
|
24
29
|
{inbox.length === 0 ? (
|
|
25
30
|
<text fg={tokens.textDim}>nothing waiting for review</text>
|
|
26
31
|
) : (
|
|
27
|
-
inbox.map((session, index) =>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
inbox.map((session, index) => {
|
|
33
|
+
const selected = index === cursor;
|
|
34
|
+
const title = session.artifact.meta.title ?? session.id;
|
|
35
|
+
return (
|
|
36
|
+
<box key={session.id} style={{ flexDirection: "row", backgroundColor: selected ? tokens.cursorBg : undefined }}>
|
|
37
|
+
<text fg={selected ? tokens.text : tokens.textMuted}>
|
|
38
|
+
{selected ? "▸ " : " "}
|
|
39
|
+
{title} · {session.workspace.branch} · {session.artifact.type}
|
|
40
|
+
</text>
|
|
41
|
+
<box style={{ flexGrow: 1 }} />
|
|
42
|
+
{selected && onRequestDelete ? (
|
|
43
|
+
<Button onPress={() => onRequestDelete(session.id, title)} theme={theme}>
|
|
44
|
+
{" [delete] "}
|
|
45
|
+
</Button>
|
|
46
|
+
) : null}
|
|
47
|
+
</box>
|
|
48
|
+
);
|
|
49
|
+
})
|
|
37
50
|
)}
|
|
38
51
|
<box style={{ flexGrow: 1 }} />
|
|
39
|
-
<StatusBar theme={theme}>j/k move · ⏎ open · q quit</StatusBar>
|
|
52
|
+
<StatusBar theme={theme}>j/k move · ⏎ open · d delete · q quit</StatusBar>
|
|
40
53
|
</box>
|
|
41
54
|
);
|
|
42
55
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { PromptDialog } from "./PromptDialog";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "PromptDialog" };
|
|
7
|
+
|
|
8
|
+
export const RenameAuthor: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<PromptDialog
|
|
11
|
+
isOpen
|
|
12
|
+
title=" Rename author "
|
|
13
|
+
label="Display name for this collaborator:"
|
|
14
|
+
value="Robin"
|
|
15
|
+
placeholder="their name"
|
|
16
|
+
onInput={() => {}}
|
|
17
|
+
/>
|
|
18
|
+
),
|
|
19
|
+
expectedColors: [DARK.backdrop, DARK.panel, DARK.accent],
|
|
20
|
+
size: { width: 64, height: 16 },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const NameYourself: Story = {
|
|
24
|
+
render: () => (
|
|
25
|
+
<PromptDialog
|
|
26
|
+
isOpen
|
|
27
|
+
title=" Welcome "
|
|
28
|
+
label="Your name (optional) - it attributes the notes you leave:"
|
|
29
|
+
value=""
|
|
30
|
+
placeholder="your name"
|
|
31
|
+
onInput={() => {}}
|
|
32
|
+
/>
|
|
33
|
+
),
|
|
34
|
+
expectedColors: [DARK.backdrop, DARK.panel],
|
|
35
|
+
size: { width: 64, height: 16 },
|
|
36
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A centered single-field text prompt on the shared Dialog overlay. The focused
|
|
3
|
+
* input owns typing/cursor natively; the reducer's `prompt` overlay drives ⏎
|
|
4
|
+
* save / esc cancel (like the composer). Used to rename an author and to ask a
|
|
5
|
+
* collaborator for their name.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useEffect, useRef } from "react";
|
|
9
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
10
|
+
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
11
|
+
import type { Theme } from "../theme";
|
|
12
|
+
import { useComponentTheme } from "./theme-context";
|
|
13
|
+
import { Dialog } from "./primitives/Dialog";
|
|
14
|
+
|
|
15
|
+
// ⏎ submits (suppresses the textarea's default newline); the grammar owns save.
|
|
16
|
+
const PROMPT_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
|
|
17
|
+
|
|
18
|
+
export interface PromptDialogProps {
|
|
19
|
+
isOpen: boolean;
|
|
20
|
+
title?: string;
|
|
21
|
+
label: string;
|
|
22
|
+
value: string;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
onInput: (text: string) => void;
|
|
25
|
+
theme?: Theme;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PromptDialog({ isOpen, title, label, value, placeholder, onInput, theme }: PromptDialogProps): React.ReactNode {
|
|
29
|
+
const tokens = useComponentTheme(theme);
|
|
30
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
31
|
+
const inputRef = useRef<TextareaRenderable | null>(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
// open with the caret after the seeded value, like a text field
|
|
34
|
+
if (inputRef.current) inputRef.current.cursorOffset = value.length;
|
|
35
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
36
|
+
}, []);
|
|
37
|
+
if (!isOpen) return null;
|
|
38
|
+
return (
|
|
39
|
+
<Dialog isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
|
|
40
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
|
|
41
|
+
<text fg={tokens.textDim}>{label}</text>
|
|
42
|
+
<textarea
|
|
43
|
+
ref={inputRef}
|
|
44
|
+
focused
|
|
45
|
+
initialValue={value}
|
|
46
|
+
placeholder={placeholder}
|
|
47
|
+
keyBindings={PROMPT_KEY_BINDINGS}
|
|
48
|
+
onContentChange={() => onInput(inputRef.current?.plainText ?? "")}
|
|
49
|
+
style={{
|
|
50
|
+
height: 1,
|
|
51
|
+
backgroundColor: tokens.elevated,
|
|
52
|
+
focusedBackgroundColor: tokens.elevated,
|
|
53
|
+
textColor: tokens.text,
|
|
54
|
+
focusedTextColor: tokens.text,
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
<box style={{ flexGrow: 1 }} />
|
|
58
|
+
<text fg={tokens.textDim}>⏎ save · esc cancel</text>
|
|
59
|
+
</box>
|
|
60
|
+
</Dialog>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -9,6 +9,7 @@ export const meta: StoryMeta = { title: "ReviewPanel" };
|
|
|
9
9
|
|
|
10
10
|
const rail = {
|
|
11
11
|
session: fixturePlanSession(),
|
|
12
|
+
authorNames: {},
|
|
12
13
|
selectedId: FIXTURE_ANNOTATIONS[0]!.id,
|
|
13
14
|
resolvedIds: new Set(FIXTURE_ANNOTATIONS.map((annotation) => annotation.id)),
|
|
14
15
|
railTab: "review" as const,
|
|
@@ -23,6 +23,7 @@ export const AnnotationStack: Story = {
|
|
|
23
23
|
<RailFrame>
|
|
24
24
|
<ReviewRail
|
|
25
25
|
session={fixturePlanSession()}
|
|
26
|
+
authorNames={{}}
|
|
26
27
|
selectedId={FIXTURE_ANNOTATIONS[0]!.id}
|
|
27
28
|
resolvedIds={new Set(FIXTURE_ANNOTATIONS.map((annotation) => annotation.id))}
|
|
28
29
|
railTab="review"
|
|
@@ -42,6 +43,7 @@ export const AgentTab: Story = {
|
|
|
42
43
|
<RailFrame>
|
|
43
44
|
<ReviewRail
|
|
44
45
|
session={fixturePlanSession()}
|
|
46
|
+
authorNames={{}}
|
|
45
47
|
resolvedIds={new Set()}
|
|
46
48
|
railTab="agent"
|
|
47
49
|
pendingCount={2}
|
|
@@ -59,6 +61,7 @@ export const SubmitConfirmOpen: Story = {
|
|
|
59
61
|
<RailFrame>
|
|
60
62
|
<ReviewRail
|
|
61
63
|
session={fixturePlanSession()}
|
|
64
|
+
authorNames={{}}
|
|
62
65
|
resolvedIds={new Set()}
|
|
63
66
|
railTab="review"
|
|
64
67
|
pendingCount={2}
|
|
@@ -89,6 +92,7 @@ export const Resolved: Story = {
|
|
|
89
92
|
status: "resolved",
|
|
90
93
|
verdict: { kind: "approve", summary: "", feedback: "", resolvedAt: "2026-01-01T00:00:00Z" },
|
|
91
94
|
})}
|
|
95
|
+
authorNames={{}}
|
|
92
96
|
resolvedIds={new Set()}
|
|
93
97
|
railTab="review"
|
|
94
98
|
pendingCount={0}
|
|
@@ -15,6 +15,7 @@ import { Tabs, Tab, TabList } from "./primitives/Tabs";
|
|
|
15
15
|
import { Button } from "./primitives/Button";
|
|
16
16
|
import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
|
|
17
17
|
import { ConfirmCard, type ConfirmCardProps } from "./ConfirmCard";
|
|
18
|
+
import { resolveDisplayName } from "../attribution";
|
|
18
19
|
|
|
19
20
|
export type RailTab = "review" | "agent";
|
|
20
21
|
|
|
@@ -29,6 +30,8 @@ export interface ReviewRailHandle {
|
|
|
29
30
|
|
|
30
31
|
export interface ReviewRailProps {
|
|
31
32
|
session: ReviewSession;
|
|
33
|
+
/** Planner-local author renames (config [authors]); override the registry name. */
|
|
34
|
+
authorNames: Record<string, string>;
|
|
32
35
|
selectedId?: string;
|
|
33
36
|
/** Ids whose anchor resolved; null = orphan display off (diff view). */
|
|
34
37
|
resolvedIds: Set<string> | null;
|
|
@@ -58,6 +61,7 @@ export function annotationBlocking(annotation: Annotation): boolean {
|
|
|
58
61
|
export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function ReviewRail(
|
|
59
62
|
{
|
|
60
63
|
session,
|
|
64
|
+
authorNames,
|
|
61
65
|
selectedId,
|
|
62
66
|
resolvedIds,
|
|
63
67
|
railTab,
|
|
@@ -121,6 +125,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
121
125
|
isSelected: annotation.id === selectedId,
|
|
122
126
|
isOrphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
|
|
123
127
|
isBlocking: annotationBlocking(annotation),
|
|
128
|
+
authorLabel: annotation.author ? resolveDisplayName(annotation.author, session.participants, authorNames) : undefined,
|
|
124
129
|
editing:
|
|
125
130
|
cardEdit && cardEdit.id === annotation.id
|
|
126
131
|
? {
|
|
@@ -467,7 +467,7 @@ exports[`stories catalog InboxList/Empty renders, snapshots, and carries its col
|
|
|
467
467
|
nothing waiting for review
|
|
468
468
|
|
|
469
469
|
|
|
470
|
-
j/k move · ⏎ open · q quit
|
|
470
|
+
j/k move · ⏎ open · d delete · q quit
|
|
471
471
|
|
|
472
472
|
"
|
|
473
473
|
`;
|
|
@@ -483,7 +483,7 @@ exports[`stories catalog InboxList/TwoPending renders, snapshots, and carries it
|
|
|
483
483
|
|
|
484
484
|
|
|
485
485
|
|
|
486
|
-
j/k move · ⏎ open · q quit
|
|
486
|
+
j/k move · ⏎ open · d delete · q quit
|
|
487
487
|
|
|
488
488
|
"
|
|
489
489
|
`;
|
|
@@ -1455,5 +1455,65 @@ exports[`stories catalog ReviewPanel/Hidden renders, snapshots, and carries its
|
|
|
1455
1455
|
|
|
1456
1456
|
|
|
1457
1457
|
|
|
1458
|
+
"
|
|
1459
|
+
`;
|
|
1460
|
+
|
|
1461
|
+
exports[`stories catalog ConfirmDialog/DeletePlan renders, snapshots, and carries its colors: ConfirmDialog/DeletePlan 1`] = `
|
|
1462
|
+
"
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
╭─ Delete plan ──────────────────────────────────────╮
|
|
1468
|
+
│ │
|
|
1469
|
+
│ Delete "Auth rollout"? This removes the plan and │
|
|
1470
|
+
│ its review. │
|
|
1471
|
+
│ │
|
|
1472
|
+
│ Delete ⏎ Cancel esc │
|
|
1473
|
+
╰────────────────────────────────────────────────────╯
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
"
|
|
1479
|
+
`;
|
|
1480
|
+
|
|
1481
|
+
exports[`stories catalog PromptDialog/NameYourself renders, snapshots, and carries its colors: PromptDialog/NameYourself 1`] = `
|
|
1482
|
+
"
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
╭─ Welcome ──────────────────────────────────────────╮
|
|
1488
|
+
│ │
|
|
1489
|
+
│ Your name (optional) - it attributes the notes │
|
|
1490
|
+
│ you leave: │
|
|
1491
|
+
│ your name │
|
|
1492
|
+
│ ⏎ save · esc cancel │
|
|
1493
|
+
╰────────────────────────────────────────────────────╯
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
"
|
|
1499
|
+
`;
|
|
1500
|
+
|
|
1501
|
+
exports[`stories catalog PromptDialog/RenameAuthor renders, snapshots, and carries its colors: PromptDialog/RenameAuthor 1`] = `
|
|
1502
|
+
"
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
╭─ Rename author ────────────────────────────────────╮
|
|
1508
|
+
│ │
|
|
1509
|
+
│ Display name for this collaborator: │
|
|
1510
|
+
│ Robin │
|
|
1511
|
+
│ │
|
|
1512
|
+
│ ⏎ save · esc cancel │
|
|
1513
|
+
╰────────────────────────────────────────────────────╯
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
|
|
1458
1518
|
"
|
|
1459
1519
|
`;
|
package/src/config.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { DEFAULT_KEYS, actionFor, loadConfig, persistReviewState, persistReviewWidth } from "./config";
|
|
5
|
+
import { DEFAULT_KEYS, actionFor, loadConfig, persistAuthorName, persistReviewState, persistReviewWidth } from "./config";
|
|
6
6
|
import { REVIEW_DEFAULT_WIDTH, REVIEW_MAX_WIDTH } from "./review-panel";
|
|
7
7
|
import { DARK } from "./theme";
|
|
8
8
|
|
|
@@ -251,3 +251,32 @@ describe("actionFor", () => {
|
|
|
251
251
|
expect(actionFor(DEFAULT_KEYS, "zz", false)).toBeUndefined();
|
|
252
252
|
});
|
|
253
253
|
});
|
|
254
|
+
|
|
255
|
+
describe("[authors] rename map", () => {
|
|
256
|
+
test("loads quoted fingerprint keys", () => {
|
|
257
|
+
// Arrange
|
|
258
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-authors-"));
|
|
259
|
+
const path = join(dir, "config.toml");
|
|
260
|
+
writeFileSync(path, `[authors]\n"SHA256:abc+def/gh" = "Alex"\n`);
|
|
261
|
+
|
|
262
|
+
// Act
|
|
263
|
+
const config = loadConfig({ userConfigPath: path });
|
|
264
|
+
|
|
265
|
+
// Assert
|
|
266
|
+
expect(config.authors["SHA256:abc+def/gh"]).toBe("Alex");
|
|
267
|
+
rmSync(dir, { recursive: true, force: true });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("persistAuthorName round-trips and updates a fingerprint id in place", () => {
|
|
271
|
+
// Arrange
|
|
272
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-authors2-"));
|
|
273
|
+
const path = join(dir, "config.toml");
|
|
274
|
+
|
|
275
|
+
// Act / Assert
|
|
276
|
+
persistAuthorName("SHA256:abc+def/gh", "Alex", path);
|
|
277
|
+
expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alex");
|
|
278
|
+
persistAuthorName("SHA256:abc+def/gh", "Alexa", path);
|
|
279
|
+
expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alexa");
|
|
280
|
+
rmSync(dir, { recursive: true, force: true });
|
|
281
|
+
});
|
|
282
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface CueloopConfig {
|
|
|
34
34
|
* collapse mode and expanded-rail width, persisted so they survive restarts.
|
|
35
35
|
*/
|
|
36
36
|
ui: { autoClose: AutoClose; editor?: string; reviewState: ReviewPanelMode; reviewWidth: number };
|
|
37
|
+
/** Planner-local author renames: identity id → display name ([authors] table). */
|
|
38
|
+
authors: Record<string, string>;
|
|
37
39
|
integrations: IntegrationsConfig;
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -51,6 +53,7 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
|
|
|
51
53
|
next_annotation: ["n"],
|
|
52
54
|
prev_annotation: ["p"],
|
|
53
55
|
delete_annotation: ["backspace"],
|
|
56
|
+
rename: ["r"],
|
|
54
57
|
submit: ["return", "enter"],
|
|
55
58
|
share: ["S"],
|
|
56
59
|
quit: ["q"],
|
|
@@ -70,8 +73,15 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
70
73
|
keys: { ...base.keys },
|
|
71
74
|
theme: { ...base.theme },
|
|
72
75
|
ui: { ...base.ui },
|
|
76
|
+
authors: { ...base.authors },
|
|
73
77
|
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
74
78
|
};
|
|
79
|
+
const authors = raw["authors"] as Record<string, unknown> | undefined;
|
|
80
|
+
if (authors) {
|
|
81
|
+
for (const [id, value] of Object.entries(authors)) {
|
|
82
|
+
if (typeof value === "string") out.authors[id] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
75
85
|
const keys = raw["keys"] as KeymapConfig | undefined;
|
|
76
86
|
if (keys) {
|
|
77
87
|
for (const [action, combo] of Object.entries(keys)) {
|
|
@@ -120,6 +130,7 @@ export function loadConfig(options: { repoRoot?: string; userConfigPath?: string
|
|
|
120
130
|
keys: { ...DEFAULT_KEYS },
|
|
121
131
|
theme: { ...DARK },
|
|
122
132
|
ui: { autoClose: "off", reviewState: "expanded", reviewWidth: REVIEW_DEFAULT_WIDTH },
|
|
133
|
+
authors: {},
|
|
123
134
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
124
135
|
};
|
|
125
136
|
const userPath = userConfigPathFrom(options.userConfigPath);
|
|
@@ -187,3 +198,28 @@ export function persistReviewWidth(width: number, userConfigPath?: string): void
|
|
|
187
198
|
export function persistReviewState(state: ReviewPanelMode, userConfigPath?: string): void {
|
|
188
199
|
persistUiSetting("review_state", `"${state}"`, userConfigPath);
|
|
189
200
|
}
|
|
201
|
+
|
|
202
|
+
function escapeRegExp(text: string): string {
|
|
203
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Persist an author rename into the `[authors]` table. The identity id (an SSH
|
|
208
|
+
* fingerprint) is not a bare TOML key, so it is written quoted.
|
|
209
|
+
*/
|
|
210
|
+
export function persistAuthorName(id: string, name: string, userConfigPath?: string): void {
|
|
211
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
212
|
+
let text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
213
|
+
const key = `"${id.replace(/"/g, '\\"')}"`;
|
|
214
|
+
const value = `"${name.replace(/"/g, '\\"')}"`;
|
|
215
|
+
const assignment = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=.*$`, "m");
|
|
216
|
+
if (assignment.test(text)) {
|
|
217
|
+
text = text.replace(assignment, `$1${key} = ${value}`);
|
|
218
|
+
} else if (/^\[authors\]/m.test(text)) {
|
|
219
|
+
text = text.replace(/^\[authors\]\s*$/m, `[authors]\n${key} = ${value}`);
|
|
220
|
+
} else {
|
|
221
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[authors]\n${key} = ${value}\n`;
|
|
222
|
+
}
|
|
223
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
224
|
+
writeFileSync(path, text);
|
|
225
|
+
}
|
|
@@ -26,6 +26,8 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
26
26
|
const controller = {
|
|
27
27
|
setStatus: mock(),
|
|
28
28
|
open: mock(),
|
|
29
|
+
deleteSession: mock(),
|
|
30
|
+
setSelfName: mock(),
|
|
29
31
|
cut: mock(),
|
|
30
32
|
annotate: mock(),
|
|
31
33
|
updateAnnotation: mock(),
|
|
@@ -56,6 +58,8 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
56
58
|
reviewWidth: 34,
|
|
57
59
|
terminalWidth: 120,
|
|
58
60
|
focusedAnnotationId: undefined,
|
|
61
|
+
authorNames: {},
|
|
62
|
+
renameAuthor: mock(),
|
|
59
63
|
liveInput: { current: "" },
|
|
60
64
|
reviewWidthRef: { current: 34 },
|
|
61
65
|
planSheetRef: { current: null },
|
|
@@ -211,3 +215,94 @@ describe("exit", () => {
|
|
|
211
215
|
expect(deps.onExit).toHaveBeenCalledWith(0);
|
|
212
216
|
});
|
|
213
217
|
});
|
|
218
|
+
|
|
219
|
+
describe("inbox delete", () => {
|
|
220
|
+
test("requestDeleteSession opens the confirm on the cursor row", () => {
|
|
221
|
+
// Arrange
|
|
222
|
+
const inbox = [{ id: "ses_1", artifact: { meta: { title: "Plan A" } } }] as never;
|
|
223
|
+
const deps = makeDeps({ inbox, inboxCursor: 0 });
|
|
224
|
+
|
|
225
|
+
// Act
|
|
226
|
+
createIntentDispatch(deps)({ type: "requestDeleteSession" });
|
|
227
|
+
|
|
228
|
+
// Assert
|
|
229
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "confirmDelete", sessionId: "ses_1", title: "Plan A" });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("confirmDialog deletes the session and closes", () => {
|
|
233
|
+
// Arrange
|
|
234
|
+
const deps = makeDeps({ mode: { type: "confirmDelete", sessionId: "ses_1", title: "Plan A" } });
|
|
235
|
+
|
|
236
|
+
// Act
|
|
237
|
+
createIntentDispatch(deps)({ type: "confirmDialog" });
|
|
238
|
+
|
|
239
|
+
// Assert
|
|
240
|
+
expect(deps.controller.deleteSession).toHaveBeenCalledWith("ses_1");
|
|
241
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("rename author", () => {
|
|
246
|
+
test("openRename seeds the prompt with the author's current local name", () => {
|
|
247
|
+
// Arrange
|
|
248
|
+
const session = { annotations: [{ id: "a1", author: "SHA256:x" }] } as never;
|
|
249
|
+
const deps = makeDeps({ session, focusedAnnotationId: "a1", authorNames: { "SHA256:x": "Alex" } });
|
|
250
|
+
|
|
251
|
+
// Act
|
|
252
|
+
createIntentDispatch(deps)({ type: "openRename" });
|
|
253
|
+
|
|
254
|
+
// Assert
|
|
255
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "rename", authorId: "SHA256:x", text: "Alex" });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("openRename on your own note does nothing but explain", () => {
|
|
259
|
+
// Arrange
|
|
260
|
+
const session = { annotations: [{ id: "a1" }] } as never;
|
|
261
|
+
const deps = makeDeps({ session, focusedAnnotationId: "a1" });
|
|
262
|
+
|
|
263
|
+
// Act
|
|
264
|
+
createIntentDispatch(deps)({ type: "openRename" });
|
|
265
|
+
|
|
266
|
+
// Assert
|
|
267
|
+
expect(deps.controller.setStatus).toHaveBeenCalled();
|
|
268
|
+
expect(deps.setMode).not.toHaveBeenCalled();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("confirmDialog in rename mode persists the trimmed name and closes", () => {
|
|
272
|
+
// Arrange
|
|
273
|
+
const deps = makeDeps({ mode: { type: "rename", authorId: "SHA256:x", text: " Alex " } });
|
|
274
|
+
|
|
275
|
+
// Act
|
|
276
|
+
createIntentDispatch(deps)({ type: "confirmDialog" });
|
|
277
|
+
|
|
278
|
+
// Assert
|
|
279
|
+
expect(deps.renameAuthor).toHaveBeenCalledWith("SHA256:x", "Alex");
|
|
280
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("collaborator self-name", () => {
|
|
285
|
+
test("confirmDialog in nameSelf mode records the trimmed name and closes", () => {
|
|
286
|
+
// Arrange
|
|
287
|
+
const deps = makeDeps({ mode: { type: "nameSelf", text: " Robin " } });
|
|
288
|
+
|
|
289
|
+
// Act
|
|
290
|
+
createIntentDispatch(deps)({ type: "confirmDialog" });
|
|
291
|
+
|
|
292
|
+
// Assert
|
|
293
|
+
expect(deps.controller.setSelfName).toHaveBeenCalledWith("Robin");
|
|
294
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("an empty name still closes - the collaborator stays anonymous", () => {
|
|
298
|
+
// Arrange
|
|
299
|
+
const deps = makeDeps({ mode: { type: "nameSelf", text: " " } });
|
|
300
|
+
|
|
301
|
+
// Act
|
|
302
|
+
createIntentDispatch(deps)({ type: "confirmDialog" });
|
|
303
|
+
|
|
304
|
+
// Assert
|
|
305
|
+
expect(deps.controller.setSelfName).toHaveBeenCalledWith("");
|
|
306
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
|
|
307
|
+
});
|
|
308
|
+
});
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -23,7 +23,10 @@ export type Mode =
|
|
|
23
23
|
| { type: "span"; span: SpanState }
|
|
24
24
|
| { type: "compose"; kind: "comment" | "suggestion"; displayIndex: number; start: number; end: number; text: string }
|
|
25
25
|
| { type: "railEdit"; id: string; text: string }
|
|
26
|
-
| { type: "submit"; verdict: VerdictKind; summary: string }
|
|
26
|
+
| { type: "submit"; verdict: VerdictKind; summary: string }
|
|
27
|
+
| { type: "confirmDelete"; sessionId: string; title: string }
|
|
28
|
+
| { type: "rename"; authorId: string; text: string }
|
|
29
|
+
| { type: "nameSelf"; text: string };
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* Annotations that still count as feedback: agent notes never do, and an
|
|
@@ -60,6 +63,10 @@ export interface IntentDispatchDeps {
|
|
|
60
63
|
reviewWidth: number;
|
|
61
64
|
terminalWidth: number;
|
|
62
65
|
focusedAnnotationId: string | undefined;
|
|
66
|
+
/** Planner-local author renames, for seeding the rename prompt. */
|
|
67
|
+
authorNames: Record<string, string>;
|
|
68
|
+
/** Persist an author rename and update the live overrides (App-owned). */
|
|
69
|
+
renameAuthor: (id: string, name: string) => void;
|
|
63
70
|
|
|
64
71
|
liveInput: MutableRefObject<string>;
|
|
65
72
|
reviewWidthRef: MutableRefObject<number>;
|
|
@@ -96,6 +103,8 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
|
|
|
96
103
|
reviewWidth,
|
|
97
104
|
terminalWidth,
|
|
98
105
|
focusedAnnotationId,
|
|
106
|
+
authorNames,
|
|
107
|
+
renameAuthor,
|
|
99
108
|
liveInput,
|
|
100
109
|
reviewWidthRef,
|
|
101
110
|
planSheetRef,
|
|
@@ -136,6 +145,22 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
|
|
|
136
145
|
if (selected) controller.open(selected.id);
|
|
137
146
|
return;
|
|
138
147
|
}
|
|
148
|
+
case "requestDeleteSession": {
|
|
149
|
+
const selected = inbox?.[inboxCursor];
|
|
150
|
+
if (selected) setMode({ type: "confirmDelete", sessionId: selected.id, title: selected.artifact.meta.title ?? selected.id });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
case "openRename": {
|
|
154
|
+
const focused = session?.annotations.find((annotation) => annotation.id === focusedAnnotationId);
|
|
155
|
+
if (!focused?.author) return void controller.setStatus("that is your own note - nothing to rename");
|
|
156
|
+
return void setMode({ type: "rename", authorId: focused.author, text: authorNames[focused.author] ?? "" });
|
|
157
|
+
}
|
|
158
|
+
case "confirmDialog": {
|
|
159
|
+
if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
|
|
160
|
+
else if (mode.type === "rename") renameAuthor(mode.authorId, mode.text.trim());
|
|
161
|
+
else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
|
|
162
|
+
return void setMode({ type: "normal" });
|
|
163
|
+
}
|
|
139
164
|
case "startSpan": {
|
|
140
165
|
const block = display[cursor];
|
|
141
166
|
if (!block?.work) return;
|
package/src/keymap.test.ts
CHANGED
|
@@ -437,3 +437,41 @@ describe("read-only filter", () => {
|
|
|
437
437
|
expect(reduceKey(keyState, key("s"))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
438
438
|
});
|
|
439
439
|
});
|
|
440
|
+
|
|
441
|
+
describe("inbox delete", () => {
|
|
442
|
+
test("d on a session requests delete", () => {
|
|
443
|
+
// Arrange / Act / Assert
|
|
444
|
+
expect(reduceKey(state({ view: "inbox" }), key("d"))).toEqual([{ type: "requestDeleteSession" }]);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe("confirm overlay", () => {
|
|
449
|
+
// Arrange
|
|
450
|
+
const keyState = state({ overlay: "confirm" });
|
|
451
|
+
const table: [string, Intent[]][] = [
|
|
452
|
+
["return", [{ type: "confirmDialog" }]],
|
|
453
|
+
["enter", [{ type: "confirmDialog" }]],
|
|
454
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
455
|
+
["j", []],
|
|
456
|
+
["d", []],
|
|
457
|
+
];
|
|
458
|
+
for (const [name, expected] of table) {
|
|
459
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
460
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
describe("rename grammar", () => {
|
|
466
|
+
test("r on a focused note opens rename", () => {
|
|
467
|
+
// Arrange / Act / Assert
|
|
468
|
+
expect(reduceKey(state({ hasFocusedAnnotation: true }), key("r"))).toEqual([{ type: "openRename" }]);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("prompt overlay routes ⏎ to confirm and esc to cancel", () => {
|
|
472
|
+
// Arrange / Act / Assert
|
|
473
|
+
expect(reduceKey(state({ overlay: "prompt" }), key("return"))).toEqual([{ type: "confirmDialog" }]);
|
|
474
|
+
expect(reduceKey(state({ overlay: "prompt" }), key("escape"))).toEqual([{ type: "closeOverlay" }]);
|
|
475
|
+
expect(reduceKey(state({ overlay: "prompt" }), key("a"))).toEqual([]);
|
|
476
|
+
});
|
|
477
|
+
});
|
package/src/keymap.ts
CHANGED
|
@@ -14,6 +14,9 @@ export type Intent =
|
|
|
14
14
|
| { type: "move"; to: "down" | "up" | "top" | "bottom" }
|
|
15
15
|
| { type: "inboxMove"; to: "down" | "up" }
|
|
16
16
|
| { type: "openSession" }
|
|
17
|
+
| { type: "requestDeleteSession" }
|
|
18
|
+
| { type: "openRename" }
|
|
19
|
+
| { type: "confirmDialog" }
|
|
17
20
|
| { type: "startSpan" }
|
|
18
21
|
| { type: "spanKey"; name: string }
|
|
19
22
|
| { type: "openCompose"; kind: "comment" | "suggestion"; from: "cursor" | "span" }
|
|
@@ -61,7 +64,7 @@ export interface KeyState {
|
|
|
61
64
|
/** Owner-only: publish the plan as a share. A collaborator never re-shares. */
|
|
62
65
|
canShare?: boolean;
|
|
63
66
|
/** Layer that owns keys before the grammar runs. */
|
|
64
|
-
overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
|
|
67
|
+
overlay: "none" | "walk" | "compose" | "submit" | "confirm" | "prompt" | "completion-prompt" | "completion-counting";
|
|
65
68
|
view: "inbox" | "plan" | "diff";
|
|
66
69
|
/** Plan-only span selection sub-mode. */
|
|
67
70
|
spanMode: boolean;
|
|
@@ -111,6 +114,18 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
111
114
|
}
|
|
112
115
|
return [];
|
|
113
116
|
}
|
|
117
|
+
// a modal confirm owns the keys: ⏎ commits the action, escape backs out
|
|
118
|
+
if (state.overlay === "confirm") {
|
|
119
|
+
if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
|
|
120
|
+
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
// a text prompt: the focused input owns typing; only ⏎ save and esc route here
|
|
124
|
+
if (state.overlay === "prompt") {
|
|
125
|
+
if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
|
|
126
|
+
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
114
129
|
if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
|
|
115
130
|
if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
|
|
116
131
|
if (name === "a") return [{ type: "optInAutoClose" }];
|
|
@@ -168,6 +183,7 @@ function inboxGrammar(state: KeyState, name: string): Intent[] {
|
|
|
168
183
|
if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
|
|
169
184
|
if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
|
|
170
185
|
if (name === "return" || name === "enter") return [{ type: "openSession" }];
|
|
186
|
+
if (name === "d") return [{ type: "requestDeleteSession" }];
|
|
171
187
|
return [];
|
|
172
188
|
}
|
|
173
189
|
|
|
@@ -243,6 +259,10 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
|
|
|
243
259
|
if (state.resolved || !state.hasFocusedAnnotation) return [];
|
|
244
260
|
return [{ type: "removeAnnotation" }];
|
|
245
261
|
}
|
|
262
|
+
if (action === "rename") {
|
|
263
|
+
if (!state.hasFocusedAnnotation) return status("select a collaborator's note to rename them");
|
|
264
|
+
return [{ type: "openRename" }];
|
|
265
|
+
}
|
|
246
266
|
if (action === "submit") {
|
|
247
267
|
// a collaborator's notes union back as they go; there is no verdict to submit
|
|
248
268
|
if (state.canSubmitVerdict === false) return status("shared view - your notes save as you go; q to leave");
|
|
@@ -46,9 +46,9 @@ function fakeClient(session: ReviewSession): SessionClient {
|
|
|
46
46
|
sessionList: async () => [session],
|
|
47
47
|
sessionAnnotate: mock(async () => session),
|
|
48
48
|
sessionSetShareId: mock(async (_id: string, shareId: string) => ((session.shareId = shareId), session)),
|
|
49
|
-
|
|
49
|
+
sessionMergeShared: mock(async (_id: string, incoming: { annotations: Annotation[] }) => {
|
|
50
50
|
const known = new Set(session.annotations.map((existing) => existing.id));
|
|
51
|
-
for (const note of incoming) if (!known.has(note.id)) session.annotations.push(note);
|
|
51
|
+
for (const note of incoming.annotations) if (!known.has(note.id)) session.annotations.push(note);
|
|
52
52
|
return session;
|
|
53
53
|
}),
|
|
54
54
|
close: () => {},
|
|
@@ -83,9 +83,12 @@ describe("share", () => {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
describe("pullShared", () => {
|
|
86
|
-
test("unions collaborator notes in when the plan was shared", async () => {
|
|
86
|
+
test("unions collaborator notes and identities in when the plan was shared", async () => {
|
|
87
87
|
// Arrange
|
|
88
|
-
remote = sessionFixture({
|
|
88
|
+
remote = sessionFixture({
|
|
89
|
+
annotations: [annotation("a1", "SHA256:mate")],
|
|
90
|
+
participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
|
|
91
|
+
});
|
|
89
92
|
const { controller, client } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }));
|
|
90
93
|
|
|
91
94
|
// Act
|
|
@@ -94,7 +97,10 @@ describe("pullShared", () => {
|
|
|
94
97
|
|
|
95
98
|
// Assert
|
|
96
99
|
expect(pullShare).toHaveBeenCalledWith("p_abc123xy");
|
|
97
|
-
expect(client.
|
|
100
|
+
expect(client.sessionMergeShared).toHaveBeenCalledWith("ses_1", {
|
|
101
|
+
annotations: remote.annotations,
|
|
102
|
+
participants: remote.participants,
|
|
103
|
+
});
|
|
98
104
|
});
|
|
99
105
|
|
|
100
106
|
test("does nothing for a plan that was never shared", async () => {
|
|
@@ -108,7 +114,7 @@ describe("pullShared", () => {
|
|
|
108
114
|
|
|
109
115
|
// Assert
|
|
110
116
|
expect(pullShare).not.toHaveBeenCalled();
|
|
111
|
-
expect(client.
|
|
117
|
+
expect(client.sessionMergeShared).not.toHaveBeenCalled();
|
|
112
118
|
});
|
|
113
119
|
});
|
|
114
120
|
|
|
@@ -104,6 +104,10 @@ export interface ReviewController {
|
|
|
104
104
|
working(): string;
|
|
105
105
|
/** Open a session from the inbox. */
|
|
106
106
|
open(id: string): void;
|
|
107
|
+
/** Delete a session for good (inbox delete); the inbox refreshes on the event. */
|
|
108
|
+
deleteSession(id: string): void;
|
|
109
|
+
/** Record the viewer's own name into the share's participant registry (collaborator self-naming). */
|
|
110
|
+
setSelfName(name: string): void;
|
|
107
111
|
/** Cut the block under the cursor, or restore a cut one. */
|
|
108
112
|
cut(displayIndex: number): void;
|
|
109
113
|
/** The $EDITOR hand-off on the working copy. */
|
|
@@ -311,6 +315,19 @@ class Controller implements ReviewController {
|
|
|
311
315
|
else void this.refreshSession(id);
|
|
312
316
|
}
|
|
313
317
|
|
|
318
|
+
deleteSession(id: string): void {
|
|
319
|
+
this.client
|
|
320
|
+
?.sessionDelete(id)
|
|
321
|
+
.then(() => this.setStatus("plan deleted"))
|
|
322
|
+
.catch((error: unknown) => this.setStatus(`delete failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
setSelfName(name: string): void {
|
|
326
|
+
const session = this.snapshot.session;
|
|
327
|
+
if (!session) return;
|
|
328
|
+
this.apply(this.client!.sessionSetSelfName(session.id, name));
|
|
329
|
+
}
|
|
330
|
+
|
|
314
331
|
cut(displayIndex: number): void {
|
|
315
332
|
const session = this.snapshot.session;
|
|
316
333
|
if (!session || session.status === "resolved") return;
|
|
@@ -514,7 +531,9 @@ class Controller implements ReviewController {
|
|
|
514
531
|
if (!session?.shareId || !this.client) return Promise.resolve();
|
|
515
532
|
const client = this.client;
|
|
516
533
|
return pullShare(session.shareId)
|
|
517
|
-
.then((remote) =>
|
|
534
|
+
.then((remote) =>
|
|
535
|
+
client.sessionMergeShared(session.id, { annotations: collaboratorAnnotations(remote), participants: remote.participants }),
|
|
536
|
+
)
|
|
518
537
|
.then(() => {})
|
|
519
538
|
.catch(() => {});
|
|
520
539
|
}
|