@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.
Files changed (34) hide show
  1. package/package.json +5 -5
  2. package/src/App.identity.test.tsx +78 -0
  3. package/src/App.share-choice.test.tsx +105 -0
  4. package/src/App.toast.test.tsx +3 -1
  5. package/src/App.tsx +176 -5
  6. package/src/app-screens.tsx +13 -0
  7. package/src/app-view-model.ts +1 -0
  8. package/src/components/AnnotatableFileView.tsx +9 -1
  9. package/src/components/AnnotationCards.tsx +22 -4
  10. package/src/components/DiffContentView.tsx +5 -1
  11. package/src/components/GridTabContent.tsx +2 -0
  12. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  13. package/src/components/ManageAccessDialog.test.tsx +94 -0
  14. package/src/components/ManageAccessDialog.tsx +120 -0
  15. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  16. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  17. package/src/components/ShareChoiceDialog.tsx +82 -0
  18. package/src/components/ThreadView.tsx +5 -1
  19. package/src/components/__snapshots__/stories.test.tsx.snap +93 -7
  20. package/src/config-identity.test.ts +78 -0
  21. package/src/config.ts +44 -1
  22. package/src/github-identity.test.ts +58 -0
  23. package/src/github-identity.ts +41 -0
  24. package/src/intent-dispatch.test.ts +17 -2
  25. package/src/intent-dispatch.ts +7 -1
  26. package/src/keymap.ts +1 -0
  27. package/src/share.ts +10 -2
  28. package/src/thread-controller.curate.test.ts +1 -0
  29. package/src/thread-controller.live-diff.test.ts +1 -0
  30. package/src/thread-controller.share.test.ts +36 -2
  31. package/src/thread-controller.tree.test.ts +1 -0
  32. package/src/thread-controller.ts +14 -0
  33. package/src/use-annotation-surface.tsx +10 -2
  34. 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.76",
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.76",
31
- "@cueloop/extension-api": "0.1.0-alpha.76",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.76",
33
- "@cueloop/schema": "0.1.0-alpha.76",
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
+ });
@@ -73,8 +73,10 @@ describe("share toast", () => {
73
73
 
74
74
  await waitForText(setup, "cueloop");
75
75
 
76
- // Act: share raises the toast, then open a composer under it by typing
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 keyboard
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
- // clicking the header Share button: publish the plan, copy the ssh line
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={onShareRequest} theme={theme}>
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>
@@ -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
@@ -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 { makeAnchor, annotationTarget, type Anchor, type Thread } from "@cueloop/schema";
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 { BoxRenderable, KeyBinding, TextareaRenderable } from "@opentui/core";
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
- <text selectable={false} fg={glyphColor} style={{ flexShrink: 0 }}>
298
- {lineIndex === 0 ? `${glyph} ` : " "}
299
- </text>
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>