@cueloop/client 0.1.0-alpha.76 → 0.1.0-alpha.77
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.identity.test.tsx +78 -0
- package/src/App.share-choice.test.tsx +105 -0
- package/src/App.toast.test.tsx +3 -1
- package/src/App.tsx +176 -5
- package/src/app-screens.tsx +13 -0
- package/src/app-view-model.ts +1 -0
- package/src/components/AnnotatableFileView.tsx +9 -1
- package/src/components/AnnotationCards.tsx +22 -4
- package/src/components/DiffContentView.tsx +5 -1
- package/src/components/GridTabContent.tsx +2 -0
- package/src/components/ManageAccessDialog.stories.tsx +36 -0
- package/src/components/ManageAccessDialog.test.tsx +94 -0
- package/src/components/ManageAccessDialog.tsx +120 -0
- package/src/components/ShareChoiceDialog.stories.tsx +34 -0
- package/src/components/ShareChoiceDialog.test.tsx +103 -0
- package/src/components/ShareChoiceDialog.tsx +82 -0
- package/src/components/ThreadView.tsx +5 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +93 -7
- package/src/config-identity.test.ts +78 -0
- package/src/config.ts +44 -1
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/intent-dispatch.test.ts +17 -2
- package/src/intent-dispatch.ts +7 -1
- package/src/keymap.ts +1 -0
- package/src/share.ts +10 -2
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +1 -0
- package/src/thread-controller.share.test.ts +36 -2
- package/src/thread-controller.tree.test.ts +1 -0
- package/src/thread-controller.ts +14 -0
- package/src/use-annotation-surface.tsx +10 -2
- package/src/use-settings-dialog.tsx +24 -0
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.77",
|
|
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,10 +27,10 @@
|
|
|
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.
|
|
30
|
+
"@cueloop/daemon": "0.1.0-alpha.77",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.77",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.77",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.77",
|
|
34
34
|
"@opentui/core": "^0.5.8",
|
|
35
35
|
"@opentui/keymap": "0.5.8",
|
|
36
36
|
"@opentui/react": "^0.5.8",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Reviewer identity surfaces: the Account settings tab and the author-name tooltip on a comment dot. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
9
|
+
import { App } from "./App";
|
|
10
|
+
import { isolateUserConfig, locateText, renderReadyApp, waitForText } from "./test-support";
|
|
11
|
+
|
|
12
|
+
const PLAN = "# Migration Plan\n\nThe daemon persists sessions to disk atomically.\n";
|
|
13
|
+
|
|
14
|
+
let home: string;
|
|
15
|
+
let restoreUserConfig: () => void;
|
|
16
|
+
let server: DaemonServer;
|
|
17
|
+
let sessionId: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-identity-"));
|
|
21
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
22
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
23
|
+
server.start();
|
|
24
|
+
const session = server.core.sessionCreate({
|
|
25
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
26
|
+
artifact: {
|
|
27
|
+
type: "plan",
|
|
28
|
+
content: PLAN,
|
|
29
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
sessionId = session.id;
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
restoreUserConfig();
|
|
36
|
+
server.stop();
|
|
37
|
+
rmSync(home, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function renderApp() {
|
|
41
|
+
return renderReadyApp(<App home={home} sessionId={sessionId} />, { width: 120, height: 32 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("the Account settings tab shows the display name row and the GitHub sync action", async () => {
|
|
45
|
+
const setup = await renderApp();
|
|
46
|
+
|
|
47
|
+
await setup.mockMouse.click(1, 0);
|
|
48
|
+
await waitForText(setup, "Settings");
|
|
49
|
+
const menu = setup.captureCharFrame().split("\n");
|
|
50
|
+
const settingsRow = menu.findIndex((line) => line.includes("Settings"));
|
|
51
|
+
|
|
52
|
+
await setup.mockMouse.click(menu[settingsRow]!.indexOf("Settings") + 1, settingsRow);
|
|
53
|
+
await waitForText(setup, "Account");
|
|
54
|
+
const dialog = setup.captureCharFrame().split("\n");
|
|
55
|
+
const accountRow = dialog.findIndex((line) => line.includes("Account"));
|
|
56
|
+
|
|
57
|
+
await setup.mockMouse.click(dialog[accountRow]!.indexOf("Account") + 1, accountRow);
|
|
58
|
+
await waitForText(setup, "Display name");
|
|
59
|
+
expect(setup.captureCharFrame()).toContain("Sync from GitHub");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("hovering an own comment's dot surfaces the author tooltip", async () => {
|
|
63
|
+
server.core.sessionAnnotate(sessionId, {
|
|
64
|
+
id: "a_own",
|
|
65
|
+
kind: "comment",
|
|
66
|
+
anchor: { quote: "atomically", prefix: "sessions to disk ", suffix: "." },
|
|
67
|
+
body: "My note.",
|
|
68
|
+
});
|
|
69
|
+
const setup = await renderApp();
|
|
70
|
+
|
|
71
|
+
await waitForText(setup, "● My note.");
|
|
72
|
+
// the tooltip is not painted until the pointer is over the dot
|
|
73
|
+
expect(setup.captureCharFrame()).not.toContain("you");
|
|
74
|
+
const dot = locateText(setup, "● My note.");
|
|
75
|
+
|
|
76
|
+
await setup.mockMouse.moveTo(dot.column, dot.row);
|
|
77
|
+
await waitForText(setup, "you");
|
|
78
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** The share shortcut opens an app-level public/private choice reachable from any view. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, mock, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import type { Thread } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
import type { ShareTransport } from "./thread-controller";
|
|
13
|
+
import { isolateUserConfig, press, pressKey, waitForText, waitForTextGone } from "./test-support";
|
|
14
|
+
|
|
15
|
+
const shareTransport: ShareTransport = {
|
|
16
|
+
publish: mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true })),
|
|
17
|
+
pull: mock(async () => {
|
|
18
|
+
throw new Error("Unexpected share pull");
|
|
19
|
+
}),
|
|
20
|
+
push: mock(async () => {}),
|
|
21
|
+
watch: () => () => {},
|
|
22
|
+
parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
|
|
23
|
+
collaboratorAnnotations: () => [],
|
|
24
|
+
mergeFromShare: () => ({ annotations: [] }),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
|
|
28
|
+
|
|
29
|
+
let home: string;
|
|
30
|
+
let restoreUserConfig: () => void;
|
|
31
|
+
let server: DaemonServer;
|
|
32
|
+
let session: Thread;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-share-choice-"));
|
|
36
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
37
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
38
|
+
server.start();
|
|
39
|
+
session = server.core.sessionCreate({
|
|
40
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
41
|
+
artifact: {
|
|
42
|
+
type: "plan",
|
|
43
|
+
content: PLAN,
|
|
44
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
restoreUserConfig();
|
|
50
|
+
server.stop();
|
|
51
|
+
rmSync(home, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("share choice", () => {
|
|
55
|
+
test("the shortcut opens the public/private choice", async () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
const setup = await testRender(
|
|
58
|
+
<App home={home} sessionId={session.id} shareTransport={shareTransport} />,
|
|
59
|
+
{ width: 120, height: 32 },
|
|
60
|
+
);
|
|
61
|
+
await waitForText(setup, "cueloop");
|
|
62
|
+
|
|
63
|
+
// Act
|
|
64
|
+
await pressKey(setup, "s", { ctrl: true });
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
await waitForText(setup, "public link");
|
|
68
|
+
await waitForText(setup, "private link");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the private choice opens the manage-access surface", async () => {
|
|
72
|
+
// Arrange
|
|
73
|
+
const setup = await testRender(
|
|
74
|
+
<App home={home} sessionId={session.id} shareTransport={shareTransport} />,
|
|
75
|
+
{ width: 120, height: 32 },
|
|
76
|
+
);
|
|
77
|
+
await waitForText(setup, "cueloop");
|
|
78
|
+
|
|
79
|
+
// Act: open the choice, move to private, select it
|
|
80
|
+
await pressKey(setup, "s", { ctrl: true });
|
|
81
|
+
await waitForText(setup, "private link");
|
|
82
|
+
await press(setup, "down");
|
|
83
|
+
await press(setup, "enter");
|
|
84
|
+
|
|
85
|
+
// Assert
|
|
86
|
+
await waitForText(setup, "Manage access");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("escape cancels the choice without sharing", async () => {
|
|
90
|
+
// Arrange
|
|
91
|
+
const setup = await testRender(
|
|
92
|
+
<App home={home} sessionId={session.id} shareTransport={shareTransport} />,
|
|
93
|
+
{ width: 120, height: 32 },
|
|
94
|
+
);
|
|
95
|
+
await waitForText(setup, "cueloop");
|
|
96
|
+
|
|
97
|
+
// Act
|
|
98
|
+
await pressKey(setup, "s", { ctrl: true });
|
|
99
|
+
await waitForText(setup, "public link");
|
|
100
|
+
await press(setup, "escape");
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
103
|
+
await waitForTextGone(setup, "public link");
|
|
104
|
+
});
|
|
105
|
+
});
|
package/src/App.toast.test.tsx
CHANGED
|
@@ -73,8 +73,10 @@ describe("share toast", () => {
|
|
|
73
73
|
|
|
74
74
|
await waitForText(setup, "cueloop");
|
|
75
75
|
|
|
76
|
-
// Act: share raises the toast, then
|
|
76
|
+
// Act: share opens the choice, enter picks public and raises the toast, then a composer under it
|
|
77
77
|
await pressKey(setup, "s", { ctrl: true });
|
|
78
|
+
await waitForText(setup, "public link");
|
|
79
|
+
await press(setup, "enter");
|
|
78
80
|
await waitForText(setup, "share link copied");
|
|
79
81
|
await clickText(setup, "daemon");
|
|
80
82
|
await typeText(setup, "x");
|
package/src/App.tsx
CHANGED
|
@@ -15,11 +15,15 @@ import {
|
|
|
15
15
|
loadConfig,
|
|
16
16
|
persistAuthorName,
|
|
17
17
|
persistDiffView,
|
|
18
|
+
persistIdentity,
|
|
18
19
|
persistPins,
|
|
19
20
|
type AutoClose,
|
|
20
21
|
type DiffViewMode,
|
|
22
|
+
type IdentityConfig,
|
|
21
23
|
type QuickAction,
|
|
22
24
|
} from "./config";
|
|
25
|
+
import { resolveDisplayName } from "./attribution";
|
|
26
|
+
import { resolveGithubIdentity } from "./github-identity";
|
|
23
27
|
import { loadSkills, PaletteNamesContext, SlashSkillsContext } from "./skills";
|
|
24
28
|
import { mergeSlashItems, slashItemsFrom, type SlashItem } from "./slash-palette";
|
|
25
29
|
import {
|
|
@@ -38,6 +42,7 @@ import { KeyBindings, type CheatsheetSection } from "./key-bindings";
|
|
|
38
42
|
import { useReadySignal } from "./ready-signal";
|
|
39
43
|
import { ThemeProvider } from "./components/theme-context";
|
|
40
44
|
import { Button } from "./components/primitives/Button";
|
|
45
|
+
import { ShareChoiceDialog, SHARE_CHOICES } from "./components/ShareChoiceDialog";
|
|
41
46
|
import { Toolbar } from "./components/primitives/Toolbar";
|
|
42
47
|
import { groupInbox, projectName, threadTitle } from "./components/session-tree";
|
|
43
48
|
import { ThreadTree } from "./components/ThreadTree";
|
|
@@ -52,6 +57,7 @@ import { useRememberLayout } from "./use-remember-layout";
|
|
|
52
57
|
import type { LaunchLayout } from "./launch-layout";
|
|
53
58
|
import { ThreadFooter } from "./components/ThreadFooter";
|
|
54
59
|
import { ConfirmCard } from "./components/ConfirmCard";
|
|
60
|
+
import { ManageAccessDialog } from "./components/ManageAccessDialog";
|
|
55
61
|
import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
|
|
56
62
|
import {
|
|
57
63
|
diffChordEntries,
|
|
@@ -67,7 +73,7 @@ import {
|
|
|
67
73
|
import { type DiffFoldControls } from "./components/DiffContentView";
|
|
68
74
|
import { commentCountsByFile } from "./view-diff";
|
|
69
75
|
import { annotationTarget } from "@cueloop/schema";
|
|
70
|
-
import type { Artifact, DiffFileContents, Thread } from "@cueloop/schema";
|
|
76
|
+
import type { Annotation, Artifact, DiffFileContents, Identity, Thread } from "@cueloop/schema";
|
|
71
77
|
import { PrototypePixels } from "./prototype-pixels";
|
|
72
78
|
import type { PrototypeElement } from "./prototype-browser";
|
|
73
79
|
import {
|
|
@@ -139,6 +145,11 @@ function menuChromeOpen(menuDialog: "keybinds" | "settings" | null): boolean {
|
|
|
139
145
|
return menuDialog !== null;
|
|
140
146
|
}
|
|
141
147
|
|
|
148
|
+
/** The private-share allowlist for a session, or an empty list for a public one. */
|
|
149
|
+
function allowedGithubLogins(session: Thread | null): string[] {
|
|
150
|
+
return session?.access?.githubLogins ?? [];
|
|
151
|
+
}
|
|
152
|
+
|
|
142
153
|
/** The drop-up chrome or a floating popover menu (thread actions, editor split) holds the keyboard. */
|
|
143
154
|
function keyboardHeldByMenu(
|
|
144
155
|
menuDialog: "keybinds" | "settings" | null,
|
|
@@ -184,11 +195,89 @@ function keyboardOwnedElsewhere(menuOwnsKeyboard: boolean, overlay: KeyState["ov
|
|
|
184
195
|
return menuOwnsKeyboard || overlay !== "none";
|
|
185
196
|
}
|
|
186
197
|
|
|
198
|
+
/** The highlighted share option, or the public default when the choice is closed. */
|
|
199
|
+
function shareChoiceIndex(mode: Mode): number {
|
|
200
|
+
return mode.type === "shareChoice" ? mode.index : 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface ShareChoiceActions {
|
|
204
|
+
setMode: (mode: Mode) => void;
|
|
205
|
+
isOwner: boolean;
|
|
206
|
+
publish: () => void;
|
|
207
|
+
openManageAccess: () => void;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Move the share-choice highlight by one row, clamped to the list. */
|
|
211
|
+
function movedShareChoice(index: number, delta: number): Mode {
|
|
212
|
+
const next = Math.min(SHARE_CHOICES.length - 1, Math.max(0, index + delta));
|
|
213
|
+
|
|
214
|
+
return { type: "shareChoice", index: next };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Commit the highlighted share option: public publishes a link, private opens manage-access. */
|
|
218
|
+
function commitShareChoice(index: number, actions: ShareChoiceActions): void {
|
|
219
|
+
const picked = SHARE_CHOICES[index]?.choice;
|
|
220
|
+
|
|
221
|
+
actions.setMode({ type: "normal" });
|
|
222
|
+
|
|
223
|
+
if (!actions.isOwner) return;
|
|
224
|
+
|
|
225
|
+
if (picked === "private") actions.openManageAccess();
|
|
226
|
+
else actions.publish();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Route one key to the open share choice: escape cancels, arrows move, enter commits. */
|
|
230
|
+
function handleShareChoiceKey(
|
|
231
|
+
index: number,
|
|
232
|
+
key: { name: string },
|
|
233
|
+
actions: ShareChoiceActions,
|
|
234
|
+
): void {
|
|
235
|
+
if (key.name === "escape") return actions.setMode({ type: "normal" });
|
|
236
|
+
|
|
237
|
+
if (key.name === "down" || key.name === "j") return actions.setMode(movedShareChoice(index, 1));
|
|
238
|
+
|
|
239
|
+
if (key.name === "up" || key.name === "k") return actions.setMode(movedShareChoice(index, -1));
|
|
240
|
+
|
|
241
|
+
if (key.name === "return" || key.name === "enter") commitShareChoice(index, actions);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* An app-level dialog owns the keyboard while open: the manage-access dialog takes its own keys,
|
|
246
|
+
* and the share choice moves the highlight, commits on enter, and cancels on escape. Returns true
|
|
247
|
+
* when a dialog consumed the key so the caller stops the grammar.
|
|
248
|
+
*/
|
|
249
|
+
function appDialogOwnsKey(
|
|
250
|
+
accessDialogOpen: boolean,
|
|
251
|
+
mode: Mode,
|
|
252
|
+
key: { name: string },
|
|
253
|
+
actions: ShareChoiceActions,
|
|
254
|
+
): boolean {
|
|
255
|
+
if (accessDialogOpen) return true;
|
|
256
|
+
|
|
257
|
+
if (mode.type !== "shareChoice") return false;
|
|
258
|
+
|
|
259
|
+
handleShareChoiceKey(mode.index, key, actions);
|
|
260
|
+
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
187
264
|
/** The footer submit fires only for the owner of an unresolved review, never an observer. */
|
|
188
265
|
function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean): boolean {
|
|
189
266
|
return isOwner && !resolved && !observer;
|
|
190
267
|
}
|
|
191
268
|
|
|
269
|
+
/** The author display name for a comment's hover tooltip: the reviewer's own name for their notes, else the resolved collaborator name. */
|
|
270
|
+
function authorLabelResolver(
|
|
271
|
+
ownName: string | undefined,
|
|
272
|
+
participants: Identity[] | undefined,
|
|
273
|
+
overrides: Record<string, string>,
|
|
274
|
+
): (annotation: Annotation) => string | undefined {
|
|
275
|
+
return (annotation) =>
|
|
276
|
+
annotation.author === undefined
|
|
277
|
+
? (ownName ?? "you")
|
|
278
|
+
: resolveDisplayName(annotation.author, participants, overrides);
|
|
279
|
+
}
|
|
280
|
+
|
|
192
281
|
/** The Project pane body: the resolved repo's changed files in changes mode, its full tree otherwise. */
|
|
193
282
|
function ProjectPanelBody(props: {
|
|
194
283
|
mode: ProjectPanelMode;
|
|
@@ -505,6 +594,7 @@ export function App({
|
|
|
505
594
|
const [mode, setMode] = useState<Mode>({ type: "normal" });
|
|
506
595
|
// the top-left settings gear drop-down and the centered dialog it opens
|
|
507
596
|
const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
|
|
597
|
+
const [accessDialogOpen, setAccessDialogOpen] = useState(false);
|
|
508
598
|
const [autoClose, setAutoClose] = useState<AutoClose>("off");
|
|
509
599
|
// unified or side-by-side diff; split only lays out when the Changes pane is zoomed
|
|
510
600
|
const [diffView, setDiffView] = useState<DiffViewMode>("split");
|
|
@@ -526,6 +616,7 @@ export function App({
|
|
|
526
616
|
const [themeName, setThemeName] = useState<ThemeName>(DEFAULT_THEME_NAME);
|
|
527
617
|
const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
|
|
528
618
|
const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
|
|
619
|
+
const [identity, setIdentity] = useState<IdentityConfig>({ provider: "typed" });
|
|
529
620
|
const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
|
|
530
621
|
const [skills, setSkills] = useState<SlashItem[]>([]);
|
|
531
622
|
const paletteNames = useMemo(
|
|
@@ -545,6 +636,7 @@ export function App({
|
|
|
545
636
|
setThemeName(config.ui.theme);
|
|
546
637
|
setThemeOverrides(config.themeOverrides);
|
|
547
638
|
setAuthorNames(config.authors);
|
|
639
|
+
setIdentity(config.identity);
|
|
548
640
|
setQuickActions(config.actions);
|
|
549
641
|
setSkills(loadSkills(config.skillsPath));
|
|
550
642
|
setAutoClose(config.ui.autoClose);
|
|
@@ -578,6 +670,29 @@ export function App({
|
|
|
578
670
|
if (!known) setMode({ type: "nameSelf", text: "" });
|
|
579
671
|
}, [role, selfAuthor, session]);
|
|
580
672
|
|
|
673
|
+
// Bumped on every identity change so a slow GitHub sync that lands after a newer rename is discarded.
|
|
674
|
+
const identityGenerationRef = useRef(0);
|
|
675
|
+
const applyIdentity = (next: IdentityConfig): void => {
|
|
676
|
+
identityGenerationRef.current += 1;
|
|
677
|
+
setIdentity(next);
|
|
678
|
+
try {
|
|
679
|
+
persistIdentity(next);
|
|
680
|
+
} catch {
|
|
681
|
+
controller.setStatus("could not save identity to config");
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
const syncGithubIdentity = (): void => {
|
|
685
|
+
const generation = (identityGenerationRef.current += 1);
|
|
686
|
+
void resolveGithubIdentity().then((github) => {
|
|
687
|
+
if (identityGenerationRef.current !== generation) return;
|
|
688
|
+
if (!github) return controller.setStatus("GitHub not connected - run gh auth login");
|
|
689
|
+
const name = github.name?.trim() || github.login;
|
|
690
|
+
|
|
691
|
+
applyIdentity({ name, provider: "github" });
|
|
692
|
+
controller.setStatus(`identity synced from GitHub - ${name}`);
|
|
693
|
+
});
|
|
694
|
+
};
|
|
695
|
+
|
|
581
696
|
// ── settings dialog: config-backed model, navigation, persistence ──
|
|
582
697
|
const {
|
|
583
698
|
settingsNav,
|
|
@@ -600,6 +715,13 @@ export function App({
|
|
|
600
715
|
quickActions,
|
|
601
716
|
setQuickActions,
|
|
602
717
|
setMenuDialog,
|
|
718
|
+
identityName: identity.name,
|
|
719
|
+
identityProvider: identity.provider,
|
|
720
|
+
onSyncGithubIdentity: syncGithubIdentity,
|
|
721
|
+
onRenameDisplayName: () => {
|
|
722
|
+
setMenuDialog(null);
|
|
723
|
+
setMode({ type: "renameSelf", text: identity.name ?? "" });
|
|
724
|
+
},
|
|
603
725
|
});
|
|
604
726
|
|
|
605
727
|
// ── derived view model ──────────────────────
|
|
@@ -718,6 +840,9 @@ export function App({
|
|
|
718
840
|
setAuthorNames((prev) => ({ ...prev, [id]: name }));
|
|
719
841
|
},
|
|
720
842
|
renameThread: (id: string, title: string) => controller.renameSession(id, title),
|
|
843
|
+
setLocalIdentityName: (name: string) => {
|
|
844
|
+
applyIdentity(name ? { name, provider: "typed" } : { provider: "typed" });
|
|
845
|
+
},
|
|
721
846
|
liveInput,
|
|
722
847
|
setCursor,
|
|
723
848
|
setInboxCursor,
|
|
@@ -746,8 +871,8 @@ export function App({
|
|
|
746
871
|
|
|
747
872
|
const menuControl = useMenuControlState();
|
|
748
873
|
const menuOwnsKeyboard = keyboardHeldByMenu(menuDialog, menuControl.openMenuId);
|
|
749
|
-
// an overlay (submit, walk, prompt, confirm) or the menu takes the
|
|
750
|
-
// from the thread view; the view suspends its own grammar meanwhile
|
|
874
|
+
// an overlay (submit, walk, prompt, confirm, share choice) or the menu takes the
|
|
875
|
+
// keyboard from the thread view; the view suspends its own grammar meanwhile
|
|
751
876
|
const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay);
|
|
752
877
|
|
|
753
878
|
const leaderCombos = leaderCombosFor(keysRef.current.leader);
|
|
@@ -787,6 +912,16 @@ export function App({
|
|
|
787
912
|
|
|
788
913
|
useKeyboard((key) => {
|
|
789
914
|
if (quitKeyHandled(key, onExit)) return;
|
|
915
|
+
// an app-level dialog (manage-access, share choice) owns the keyboard while open
|
|
916
|
+
if (
|
|
917
|
+
appDialogOwnsKey(accessDialogOpen, mode, key, {
|
|
918
|
+
setMode,
|
|
919
|
+
isOwner,
|
|
920
|
+
publish: () => controller.share(),
|
|
921
|
+
openManageAccess: () => setAccessDialogOpen(true),
|
|
922
|
+
})
|
|
923
|
+
)
|
|
924
|
+
return;
|
|
790
925
|
if (menuModalHandled(menuControl, key)) return;
|
|
791
926
|
if (
|
|
792
927
|
appLeaderHandled({ focusedPane, key, leaderCombos, pending: leaderPending, runLeaderCommand })
|
|
@@ -919,6 +1054,11 @@ export function App({
|
|
|
919
1054
|
);
|
|
920
1055
|
|
|
921
1056
|
const activeSession = session;
|
|
1057
|
+
const resolveAuthorLabel = authorLabelResolver(
|
|
1058
|
+
identity.name,
|
|
1059
|
+
activeSession.participants,
|
|
1060
|
+
authorNames,
|
|
1061
|
+
);
|
|
922
1062
|
|
|
923
1063
|
if (isCompletionOverlayPhase(completion) && activeSession.verdict)
|
|
924
1064
|
return (
|
|
@@ -968,12 +1108,19 @@ export function App({
|
|
|
968
1108
|
runEditorHandOff();
|
|
969
1109
|
};
|
|
970
1110
|
|
|
971
|
-
//
|
|
1111
|
+
// the share choice's public option: publish the plan, copy the ssh line
|
|
972
1112
|
const onShareRequest = (): void => {
|
|
973
1113
|
if (!isOwner) return controller.setStatus("only the plan owner can share");
|
|
974
1114
|
controller.share();
|
|
975
1115
|
};
|
|
976
1116
|
|
|
1117
|
+
// the share choice's private option opens the manage-access allowlist surface
|
|
1118
|
+
const onPrivateShareRequest = (): void => {
|
|
1119
|
+
if (!isOwner) return controller.setStatus("only the plan owner can share");
|
|
1120
|
+
setAccessDialogOpen(true);
|
|
1121
|
+
};
|
|
1122
|
+
const allowedLogins = allowedGithubLogins(session);
|
|
1123
|
+
|
|
977
1124
|
// clicking the rail Submit button: same read-only answer as the submit key
|
|
978
1125
|
const onSubmitRequest = (): void => {
|
|
979
1126
|
if (observer) return controller.setStatus("observer - read-only");
|
|
@@ -1027,7 +1174,7 @@ export function App({
|
|
|
1027
1174
|
<Button onPress={onEditRequest} theme={theme}>
|
|
1028
1175
|
{" edit "}
|
|
1029
1176
|
</Button>
|
|
1030
|
-
<Button onPress={
|
|
1177
|
+
<Button onPress={() => dispatch({ type: "share" })} theme={theme}>
|
|
1031
1178
|
{" share "}
|
|
1032
1179
|
</Button>
|
|
1033
1180
|
</Toolbar>
|
|
@@ -1092,6 +1239,7 @@ export function App({
|
|
|
1092
1239
|
void controller.reply(rootAnnotationId, body)
|
|
1093
1240
|
}
|
|
1094
1241
|
onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
|
|
1242
|
+
resolveAuthorLabel={resolveAuthorLabel}
|
|
1095
1243
|
onExit={() => onExit?.(0)}
|
|
1096
1244
|
/>
|
|
1097
1245
|
)}
|
|
@@ -1160,6 +1308,7 @@ export function App({
|
|
|
1160
1308
|
onReply: (rootAnnotationId, body) =>
|
|
1161
1309
|
void controller.reply(rootAnnotationId, body),
|
|
1162
1310
|
onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
|
|
1311
|
+
resolveAuthorLabel,
|
|
1163
1312
|
leaderCombos,
|
|
1164
1313
|
onLeaderCommand: runLeaderCommand,
|
|
1165
1314
|
onExit: () => onExit?.(0),
|
|
@@ -1231,6 +1380,28 @@ export function App({
|
|
|
1231
1380
|
</box>
|
|
1232
1381
|
</box>
|
|
1233
1382
|
) : null}
|
|
1383
|
+
<ManageAccessDialog
|
|
1384
|
+
isOpen={accessDialogOpen}
|
|
1385
|
+
logins={allowedLogins}
|
|
1386
|
+
onAdd={(login) => controller.setShareAccess([...allowedLogins, login])}
|
|
1387
|
+
onRemove={(login) =>
|
|
1388
|
+
controller.setShareAccess(allowedLogins.filter((entry) => entry !== login))
|
|
1389
|
+
}
|
|
1390
|
+
onCreateLink={() => {
|
|
1391
|
+
setAccessDialogOpen(false);
|
|
1392
|
+
controller.share();
|
|
1393
|
+
}}
|
|
1394
|
+
onClose={() => setAccessDialogOpen(false)}
|
|
1395
|
+
theme={theme}
|
|
1396
|
+
/>
|
|
1397
|
+
<ShareChoiceDialog
|
|
1398
|
+
isOpen={mode.type === "shareChoice"}
|
|
1399
|
+
selectedIndex={shareChoiceIndex(mode)}
|
|
1400
|
+
onPublicShare={onShareRequest}
|
|
1401
|
+
onPrivateShare={onPrivateShareRequest}
|
|
1402
|
+
onClose={() => setMode({ type: "normal" })}
|
|
1403
|
+
theme={theme}
|
|
1404
|
+
/>
|
|
1234
1405
|
{menuChrome}
|
|
1235
1406
|
</AppShell>
|
|
1236
1407
|
</MenuControlProvider>
|
package/src/app-screens.tsx
CHANGED
|
@@ -532,6 +532,19 @@ export function TrailingOverlays(props: {
|
|
|
532
532
|
theme={theme}
|
|
533
533
|
/>
|
|
534
534
|
) : null}
|
|
535
|
+
{mode.type === "renameSelf" ? (
|
|
536
|
+
<PromptDialog
|
|
537
|
+
isOpen
|
|
538
|
+
title=" Display name "
|
|
539
|
+
label="Your display name - it attributes the notes you leave:"
|
|
540
|
+
value={mode.text}
|
|
541
|
+
placeholder="your name"
|
|
542
|
+
onInput={(text) => setMode({ ...mode, text })}
|
|
543
|
+
onSave={() => dispatch({ type: "confirmDialog" })}
|
|
544
|
+
onCancel={() => setMode({ type: "normal" })}
|
|
545
|
+
theme={theme}
|
|
546
|
+
/>
|
|
547
|
+
) : null}
|
|
535
548
|
{mode.type === "treePrompt" ? (
|
|
536
549
|
<PromptDialog
|
|
537
550
|
isOpen
|
package/src/app-view-model.ts
CHANGED
|
@@ -57,6 +57,7 @@ export function resolveOverlay(
|
|
|
57
57
|
mode.type === "treePrompt"
|
|
58
58
|
)
|
|
59
59
|
return "prompt";
|
|
60
|
+
if (mode.type === "shareChoice") return "shareChoice";
|
|
60
61
|
if (mode.type === "spanActions") return "spanActions";
|
|
61
62
|
if (completionPhase === "prompt") return "completion-prompt";
|
|
62
63
|
if (completionPhase === "counting") return "completion-counting";
|
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
// hold the notes; the bare-launch welcome shell uses the read-only FileContentsView instead.
|
|
5
5
|
|
|
6
6
|
import React, { useEffect, useRef, useState } from "react";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
makeAnchor,
|
|
9
|
+
annotationTarget,
|
|
10
|
+
type Anchor,
|
|
11
|
+
type Annotation,
|
|
12
|
+
type Thread,
|
|
13
|
+
} from "@cueloop/schema";
|
|
8
14
|
import type { Theme } from "../theme";
|
|
9
15
|
import type { QuickAction } from "../config";
|
|
10
16
|
import { useComponentTheme } from "./theme-context";
|
|
@@ -28,6 +34,7 @@ export interface AnnotatableFileViewProps {
|
|
|
28
34
|
onAddComment: (anchor: Anchor, body: string) => void;
|
|
29
35
|
onReply: (rootAnnotationId: string, body: string) => void;
|
|
30
36
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
37
|
+
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
31
38
|
onExit: () => void;
|
|
32
39
|
theme?: Theme;
|
|
33
40
|
}
|
|
@@ -119,6 +126,7 @@ export function AnnotatableFileView(props: AnnotatableFileViewProps): React.Reac
|
|
|
119
126
|
}
|
|
120
127
|
onReply={props.onReply}
|
|
121
128
|
onUpdateAnnotation={props.onUpdateAnnotation}
|
|
129
|
+
resolveAuthorLabel={props.resolveAuthorLabel}
|
|
122
130
|
onExit={props.onExit}
|
|
123
131
|
theme={props.theme}
|
|
124
132
|
/>
|
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import React, { useContext, useLayoutEffect, useRef, useState } from "react";
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
BoxRenderable,
|
|
12
|
+
KeyBinding,
|
|
13
|
+
MouseEvent as TerminalMouseEvent,
|
|
14
|
+
TextareaRenderable,
|
|
15
|
+
} from "@opentui/core";
|
|
11
16
|
import type { Annotation } from "@cueloop/schema";
|
|
12
17
|
import type { Theme } from "../theme";
|
|
18
|
+
import { useTooltip } from "./Tooltip";
|
|
13
19
|
import { lighten } from "../annotation-palette";
|
|
14
20
|
import { useFrameMeasure } from "../use-frame-measure";
|
|
15
21
|
import { skillReferenceRanges, type SlashItem } from "../slash-palette";
|
|
@@ -281,22 +287,34 @@ export function DiscussionCard({
|
|
|
281
287
|
export function CommentRow({
|
|
282
288
|
annotation,
|
|
283
289
|
tokens,
|
|
290
|
+
authorLabel,
|
|
284
291
|
}: {
|
|
285
292
|
annotation: Annotation;
|
|
286
293
|
tokens: Theme;
|
|
294
|
+
/** The author's resolved display name, shown as a tooltip when the dot is hovered. */
|
|
295
|
+
authorLabel?: string;
|
|
287
296
|
}): React.ReactNode {
|
|
288
297
|
// own comments (no author) wear the filled dot, collaborators the outline
|
|
289
298
|
const own = annotation.author === undefined;
|
|
290
299
|
const glyph = own ? "●" : "○";
|
|
291
300
|
const glyphColor = own ? tokens.text : tokens.textMuted;
|
|
301
|
+
const { showTooltip, hideTooltip } = useTooltip();
|
|
302
|
+
const dotHover = authorLabel
|
|
303
|
+
? {
|
|
304
|
+
onMouseOver: (event: TerminalMouseEvent) => showTooltip(authorLabel, event.x, event.y),
|
|
305
|
+
onMouseOut: hideTooltip,
|
|
306
|
+
}
|
|
307
|
+
: {};
|
|
292
308
|
|
|
293
309
|
return (
|
|
294
310
|
<box style={{ flexDirection: "column" }}>
|
|
295
311
|
{annotation.body.split("\n").map((line, lineIndex) => (
|
|
296
312
|
<box key={lineIndex} style={{ flexDirection: "row" }}>
|
|
297
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
313
|
+
<box style={{ flexShrink: 0 }} {...(lineIndex === 0 ? dotHover : {})}>
|
|
314
|
+
<text selectable={false} fg={glyphColor}>
|
|
315
|
+
{lineIndex === 0 ? `${glyph} ` : " "}
|
|
316
|
+
</text>
|
|
317
|
+
</box>
|
|
300
318
|
<text fg={tokens.text} style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}>
|
|
301
319
|
{line}
|
|
302
320
|
</text>
|