@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. package/src/components/agent-launcher.tsx +0 -149
package/src/share.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Publish a review session as a share: pack it, stream it to the gateway's
2
+ * Publish a thread as a share: pack it, stream it to the gateway's
3
3
  * `share` user over SSH, and copy the returned `ssh p_…@host` line to the
4
4
  * clipboard. The planner holds no key, so all this side does is upload; the
5
5
  * gateway seals and stores. Used by both `cueloop share` and the in-TUI
@@ -41,6 +41,11 @@ export function shareIdFromLine(line: string): string | undefined {
41
41
  return line.match(/^ssh (\S+)@/)?.[1];
42
42
  }
43
43
 
44
+ /** The paste line for a share id: `ssh p_…@host`. Used to copy an existing link. */
45
+ export function formatShareLine(shareId: string, target: ShareTarget = {}): string {
46
+ return `ssh ${shareId}@${target.host ?? DEFAULT_SHARE_HOST}`;
47
+ }
48
+
44
49
  /** Upload the session to the gateway and copy the resulting ssh line. */
45
50
  export async function publishShare(
46
51
  session: Thread,
@@ -108,7 +113,8 @@ export function mergeFromShare(remote: Thread): SharedMerge {
108
113
  export async function pushShare(
109
114
  shareId: string,
110
115
  annotations: Array<Omit<Annotation, "createdAt">>,
111
- access?: ShareAccess,
116
+ // ShareAccess sets a private allowlist; "public" clears it (makes the link public); undefined leaves it
117
+ access?: ShareAccess | "public",
112
118
  target: ShareTarget = {},
113
119
  ): Promise<void> {
114
120
  const payload = access ? { shareId, annotations, access } : { shareId, annotations };
@@ -121,6 +127,14 @@ export async function pushShare(
121
127
  if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
122
128
  }
123
129
 
130
+ /** Revoke a share: the gateway deletes the blob so its link stops resolving. Owner-gated; idempotent. */
131
+ export async function revokeShare(shareId: string, target: ShareTarget = {}): Promise<void> {
132
+ const { stderr, code } = await runShareSsh("cueloop-revoke", Buffer.from(shareId), target);
133
+
134
+ if (code !== 0)
135
+ throw new Error(`gateway revoke failed: ${stderr.trim() || `ssh exited ${code}`}`);
136
+ }
137
+
124
138
  export interface ShareWatchHandlers {
125
139
  /** The whole session record, each time the share changes. */
126
140
  onSession: (session: Thread) => void;
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  activeSlashToken,
4
+ insertSlashItem,
4
5
  isStandaloneSlashQuery,
5
6
  scoreMatch,
6
7
  slashFilter,
@@ -84,3 +85,19 @@ describe("isStandaloneSlashQuery", () => {
84
85
  expect(isStandaloneSlashQuery("just prose")).toBe(false);
85
86
  });
86
87
  });
88
+
89
+ describe("insertSlashItem", () => {
90
+ test("replaces the active slash token with the reference and lands the caret after it", () => {
91
+ const result = insertSlashItem("look at /rev", 12, "review");
92
+
93
+ expect(result.text).toBe("look at /review ");
94
+ expect(result.caret).toBe("look at /review ".length);
95
+ });
96
+
97
+ test("keeps text after the caret intact", () => {
98
+ const result = insertSlashItem("/pl tail", 3, "plan");
99
+
100
+ expect(result.text).toBe("/plan tail");
101
+ expect(result.caret).toBe("/plan ".length);
102
+ });
103
+ });
@@ -86,6 +86,17 @@ export function isStandaloneSlashQuery(text: string): boolean {
86
86
  return /^\/\S*$/.test(text.trim());
87
87
  }
88
88
 
89
+ export function insertSlashItem(text: string, caret: number, itemName: string) {
90
+ const token = activeSlashToken(text, caret) ?? "";
91
+ const cut = caret - token.length;
92
+ const insertion = `/${itemName} `;
93
+
94
+ return {
95
+ text: text.slice(0, cut) + insertion + text.slice(caret),
96
+ caret: cut + insertion.length,
97
+ };
98
+ }
99
+
89
100
  /**
90
101
  * The character ranges of every completed "/name" in the draft that names a real
91
102
  * action or skill, so a surface can paint the references. A half-typed "/na" is
@@ -157,6 +157,20 @@ export async function pressKey(
157
157
  await settle(setup);
158
158
  }
159
159
 
160
+ /**
161
+ * Run a thread nav-mode command: esc drops the surface into nav mode, then a bare
162
+ * key acts (a letter, or a named key like "enter"/"backspace"). The caret and any
163
+ * held selection carry into nav, so the command lands where the caret is.
164
+ */
165
+ export async function navCommand(setup: TestRendererSetup, key: string): Promise<void> {
166
+ await press(setup, "escape");
167
+ await new Promise((resolve) => setTimeout(resolve, 40));
168
+ await settle(setup);
169
+ // a single letter is a discrete keypress (nav command); named keys route through press
170
+ if (key.length === 1) await pressKey(setup, key);
171
+ else await press(setup, key);
172
+ }
173
+
160
174
  /** A 0-based cell position within a captured char frame. */
161
175
  export interface FrameLocation {
162
176
  row: number;
@@ -1,202 +1,79 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import {
3
- DEFAULT_LEADER,
4
- leaderHint,
5
- matchesLeader,
6
- resolveLeaderCommand,
7
- resolveThreadChord,
8
- } from "./thread-chords";
9
-
10
- const owner = { composing: false, isOwner: true, resolved: false, treeActive: false };
11
-
12
- describe("the command leader", () => {
13
- test("matchesLeader recognizes the configured chord and nothing else", () => {
14
- expect(matchesLeader({ ctrl: true, name: "g" }, [DEFAULT_LEADER])).toBe(true);
15
- expect(matchesLeader({ ctrl: true, name: "G" }, [DEFAULT_LEADER])).toBe(true);
16
- expect(matchesLeader({ name: "g" }, [DEFAULT_LEADER])).toBe(false);
17
- expect(matchesLeader({ ctrl: true, name: "x" }, [DEFAULT_LEADER])).toBe(false);
18
- expect(matchesLeader({ ctrl: true, meta: true, name: "g" }, [DEFAULT_LEADER])).toBe(false);
19
- expect(matchesLeader({ name: "," }, [","])).toBe(true);
2
+ import { resolveNavKey } from "./thread-chords";
3
+
4
+ const owner = { isOwner: true, resolved: false, treeActive: false };
5
+ const readOnly = { type: "status" as const, message: "observer - read-only" };
6
+ const submitted = { type: "status" as const, message: "review submitted - read-only" };
7
+
8
+ describe("resolveNavKey", () => {
9
+ test("the session commands: submit, edit, share", () => {
10
+ expect(resolveNavKey({ name: "return" }, owner)).toEqual({ type: "openSubmit" });
11
+ expect(resolveNavKey({ name: "enter" }, owner)).toEqual({ type: "openSubmit" });
12
+ expect(resolveNavKey({ name: "e" }, owner)).toEqual({ type: "edit" });
13
+ expect(resolveNavKey({ name: "s" }, owner)).toEqual({ type: "share" });
20
14
  });
21
15
 
22
- test("leaderHint renders the glyph plus a trailing space", () => {
23
- expect(leaderHint(["ctrl+g"])).toBe("⌃g ");
24
- expect(leaderHint([","])).toBe(", ");
25
- });
26
-
27
- test("a key after the leader runs the same intent as its Option chord", () => {
16
+ test("a diff row's keys win over the shared letters, then fall through to curation", () => {
28
17
  const diff = { ...owner, isDiff: true };
29
18
 
30
- expect(resolveLeaderCommand({ name: "x" }, diff)).toEqual({ type: "rejectChange" });
31
- expect(resolveLeaderCommand({ name: "X" }, diff)).toEqual({ type: "rejectHunk" });
32
- expect(resolveLeaderCommand({ name: "x" }, owner)).toEqual({ type: "cut" });
33
- expect(resolveLeaderCommand({ name: "return" }, owner)).toEqual({ type: "openSubmit" });
34
- expect(resolveLeaderCommand({ name: "x" }, { ...owner, composing: true })).toBeNull();
35
- });
36
- });
37
-
38
- describe("resolveThreadChord", () => {
39
- test("ctrl+enter opens the submit overlay for an owner with an open review", () => {
40
- // Assert: the terminal-safe chord and the cmd forms all submit
41
- expect(resolveThreadChord({ name: "return", ctrl: true }, owner)).toEqual({
42
- type: "openSubmit",
43
- });
44
- expect(resolveThreadChord({ name: "return", super: true }, owner)).toEqual({
45
- type: "openSubmit",
46
- });
47
- expect(resolveThreadChord({ name: "enter", meta: true }, owner)).toEqual({
48
- type: "openSubmit",
49
- });
50
- });
51
-
52
- test("session chords map to their intents", () => {
53
- // Assert
54
- expect(resolveThreadChord({ name: "e", ctrl: true }, owner)).toEqual({ type: "edit" });
55
- expect(resolveThreadChord({ name: "s", ctrl: true }, owner)).toEqual({ type: "share" });
19
+ expect(resolveNavKey({ name: "x" }, diff)).toEqual({ type: "rejectChange" });
20
+ expect(resolveNavKey({ name: "c" }, diff)).toEqual({ type: "foldFile" });
21
+ expect(resolveNavKey({ name: "d" }, diff)).toEqual({ type: "toggleDiffView" });
22
+ expect(resolveNavKey({ name: "k" }, diff)).toEqual({ type: "walkStart" });
23
+ // a letter with no diff meaning still reaches the curation commands
24
+ expect(resolveNavKey({ name: "n" }, diff)).toEqual({ type: "nextAnnotation" });
56
25
  });
57
26
 
58
- test("in a diff, option chords act on the caret's row and file; the rest still reach the rail", () => {
59
- // Arrange
60
- const diff = { ...owner, isDiff: true };
61
-
62
- // Assert - the code-row primitives
63
- expect(resolveThreadChord({ name: "x", meta: true }, diff)).toEqual({ type: "rejectChange" });
64
- expect(resolveThreadChord({ name: "X", meta: true }, diff)).toEqual({ type: "rejectHunk" });
65
- expect(resolveThreadChord({ name: "u", meta: true }, diff)).toEqual({
66
- type: "restoreCuration",
67
- });
68
- expect(resolveThreadChord({ name: "c", meta: true }, diff)).toEqual({ type: "foldFile" });
69
- expect(resolveThreadChord({ name: "d", meta: true }, diff)).toEqual({ type: "toggleDiffView" });
70
- expect(resolveThreadChord({ name: "k", meta: true }, diff)).toEqual({ type: "walkStart" });
71
- // the rail letters are untouched
72
- expect(resolveThreadChord({ name: "n", meta: true }, diff)).toEqual({ type: "nextAnnotation" });
73
- // rejecting is the owner's, and closed once resolved
74
- expect(resolveThreadChord({ name: "x", meta: true }, { ...diff, isOwner: false })).toEqual({
75
- type: "status",
76
- message: "observer - read-only",
77
- });
78
- expect(resolveThreadChord({ name: "k", meta: true }, { ...diff, resolved: true })).toEqual({
79
- type: "status",
80
- message: "review submitted - read-only",
81
- });
27
+ test("the discussion and curation commands", () => {
28
+ expect(resolveNavKey({ name: "n" }, owner)).toEqual({ type: "nextAnnotation" });
29
+ expect(resolveNavKey({ name: "p" }, owner)).toEqual({ type: "prevAnnotation" });
30
+ expect(resolveNavKey({ name: "backspace" }, owner)).toEqual({ type: "removeAnnotation" });
31
+ expect(resolveNavKey({ name: "r" }, owner)).toEqual({ type: "openRename" });
32
+ expect(resolveNavKey({ name: "x" }, owner)).toEqual({ type: "cut" });
33
+ expect(resolveNavKey({ name: "u" }, owner)).toEqual({ type: "restoreCuration" });
82
34
  });
83
35
 
84
- test("option chords drive the rail and curation with the plan sheet's letters", () => {
85
- // Assert
86
- expect(resolveThreadChord({ name: "n", meta: true }, owner)).toEqual({
87
- type: "nextAnnotation",
88
- });
89
- expect(resolveThreadChord({ name: "p", meta: true }, owner)).toEqual({
90
- type: "prevAnnotation",
91
- });
92
- expect(resolveThreadChord({ name: "e", meta: true }, owner)).toEqual({ type: "editCard" });
93
- expect(resolveThreadChord({ name: "backspace", meta: true }, owner)).toEqual({
94
- type: "removeAnnotation",
95
- });
96
- expect(resolveThreadChord({ name: "r", meta: true }, owner)).toEqual({ type: "openRename" });
97
- expect(resolveThreadChord({ name: "x", meta: true }, owner)).toEqual({ type: "cut" });
98
- expect(resolveThreadChord({ name: "u", meta: true }, owner)).toEqual({
99
- type: "restoreCuration",
100
- });
101
- expect(resolveThreadChord({ name: "x", meta: true }, { ...owner, isOwner: false })).toEqual({
102
- type: "status",
103
- message: "observer - read-only",
104
- });
105
- });
36
+ test("the tree commands: move on the Tree tab, go, branch, label, fork, hand off", () => {
37
+ const onTree = { ...owner, treeActive: true };
106
38
 
107
- test("an open composer swallows every chord, so typing is never hijacked", () => {
108
- // Assert
109
- expect(
110
- resolveThreadChord({ name: "return", ctrl: true }, { ...owner, composing: true }),
111
- ).toBeNull();
112
- expect(resolveThreadChord({ name: "s", ctrl: true }, { ...owner, composing: true })).toBeNull();
39
+ expect(resolveNavKey({ name: "n" }, onTree)).toEqual({ type: "treeMove", direction: 1 });
40
+ expect(resolveNavKey({ name: "p" }, onTree)).toEqual({ type: "treeMove", direction: -1 });
41
+ expect(resolveNavKey({ name: "g" }, owner)).toEqual({ type: "treeGo" });
42
+ expect(resolveNavKey({ name: "b" }, owner)).toEqual({ type: "treeBranch" });
43
+ expect(resolveNavKey({ name: "l" }, owner)).toEqual({ type: "treeLabel" });
44
+ expect(resolveNavKey({ name: "f" }, owner)).toEqual({ type: "treeFork" });
45
+ expect(resolveNavKey({ name: "h" }, owner)).toEqual({ type: "treeForkShare" });
113
46
  });
114
47
 
115
- test("plain keys and unbound chords belong to the document grammar", () => {
116
- // Assert
117
- expect(resolveThreadChord({ name: "return" }, owner)).toBeNull();
118
- expect(resolveThreadChord({ name: "s" }, owner)).toBeNull();
119
- expect(resolveThreadChord({ name: "x", ctrl: true }, owner)).toBeNull();
48
+ test("a shift letter resolves as its uppercase, which no command claims, so it returns to typing", () => {
49
+ expect(resolveNavKey({ name: "x", shift: true }, { ...owner, isDiff: true })).toBeNull();
120
50
  });
121
51
 
122
- test("a collaborator is told a primitive is read-only, and a resolved review cannot be resubmitted", () => {
123
- // Assert
52
+ test("a collaborator is refused the owner's commands; renaming stays open", () => {
124
53
  const collaborator = { ...owner, isOwner: false };
125
- const readOnly = { type: "status" as const, message: "observer - read-only" };
126
54
 
127
- expect(resolveThreadChord({ name: "return", ctrl: true }, collaborator)).toEqual(readOnly);
128
- expect(resolveThreadChord({ name: "e", ctrl: true }, collaborator)).toEqual(readOnly);
129
- expect(resolveThreadChord({ name: "s", ctrl: true }, collaborator)).toEqual(readOnly);
130
- expect(
131
- resolveThreadChord({ name: "return", ctrl: true }, { ...owner, resolved: true }),
132
- ).toBeNull();
55
+ expect(resolveNavKey({ name: "return" }, collaborator)).toEqual(readOnly);
56
+ expect(resolveNavKey({ name: "e" }, collaborator)).toEqual(readOnly);
57
+ expect(resolveNavKey({ name: "s" }, collaborator)).toEqual(readOnly);
58
+ expect(resolveNavKey({ name: "x" }, collaborator)).toEqual(readOnly);
59
+ expect(resolveNavKey({ name: "g" }, collaborator)).toEqual(readOnly);
60
+ expect(resolveNavKey({ name: "f" }, collaborator)).toEqual(readOnly);
61
+ expect(resolveNavKey({ name: "r" }, collaborator)).toEqual({ type: "openRename" });
133
62
  });
134
63
 
135
- test("a resolved review refuses every change and says so; renaming an author stays open", () => {
136
- // Arrange
64
+ test("a resolved review refuses every change and says so; a fork stays open", () => {
137
65
  const resolved = { ...owner, resolved: true };
138
- const submitted = { type: "status" as const, message: "review submitted - read-only" };
139
66
 
140
- // Assert
141
- expect(resolveThreadChord({ name: "e", ctrl: true }, resolved)).toEqual(submitted);
142
- expect(resolveThreadChord({ name: "e", meta: true }, resolved)).toEqual(submitted);
143
- expect(resolveThreadChord({ name: "backspace", meta: true }, resolved)).toEqual(submitted);
144
- expect(resolveThreadChord({ name: "x", meta: true }, resolved)).toEqual(submitted);
145
- expect(resolveThreadChord({ name: "u", meta: true }, resolved)).toEqual(submitted);
146
- expect(resolveThreadChord({ name: "r", meta: true }, resolved)).toEqual({ type: "openRename" });
147
- expect(resolveThreadChord({ name: "s", ctrl: true }, resolved)).toEqual({ type: "share" });
67
+ expect(resolveNavKey({ name: "return" }, resolved)).toBeNull();
68
+ expect(resolveNavKey({ name: "e" }, resolved)).toEqual(submitted);
69
+ expect(resolveNavKey({ name: "x" }, resolved)).toEqual(submitted);
70
+ expect(resolveNavKey({ name: "b" }, resolved)).toEqual(submitted);
71
+ expect(resolveNavKey({ name: "f" }, resolved)).toEqual({ type: "treeFork" });
72
+ expect(resolveNavKey({ name: "s" }, resolved)).toEqual({ type: "share" });
148
73
  });
149
74
 
150
- test("the tree chords: toggle, move on the Tree tab, go, branch, label, fork, hand off", () => {
151
- // Arrange
152
- const onTree = { ...owner, treeActive: true };
153
- const readOnly = { type: "status" as const, message: "observer - read-only" };
154
- const submitted = { type: "status" as const, message: "review submitted - read-only" };
155
-
156
- // Assert: next / previous follow the tab that shows
157
- expect(resolveThreadChord({ name: "t", meta: true }, owner)).toEqual({ type: "toggleTree" });
158
- expect(resolveThreadChord({ name: "n", meta: true }, onTree)).toEqual({
159
- type: "treeMove",
160
- direction: 1,
161
- });
162
- expect(resolveThreadChord({ name: "p", meta: true }, onTree)).toEqual({
163
- type: "treeMove",
164
- direction: -1,
165
- });
166
- expect(resolveThreadChord({ name: "n", meta: true }, owner)).toEqual({
167
- type: "nextAnnotation",
168
- });
169
- expect(resolveThreadChord({ name: "g", meta: true }, owner)).toEqual({ type: "treeGo" });
170
- expect(resolveThreadChord({ name: "b", meta: true }, owner)).toEqual({ type: "treeBranch" });
171
- expect(resolveThreadChord({ name: "l", meta: true }, owner)).toEqual({ type: "treeLabel" });
172
- expect(resolveThreadChord({ name: "f", meta: true }, owner)).toEqual({ type: "treeFork" });
173
- expect(resolveThreadChord({ name: "h", meta: true }, owner)).toEqual({ type: "treeForkShare" });
174
- // moving the tree is the owner's; a fork is open after a verdict, a move is not
175
- expect(resolveThreadChord({ name: "g", meta: true }, { ...owner, isOwner: false })).toEqual(
176
- readOnly,
177
- );
178
- expect(resolveThreadChord({ name: "f", meta: true }, { ...owner, isOwner: false })).toEqual(
179
- readOnly,
180
- );
181
- expect(resolveThreadChord({ name: "b", meta: true }, { ...owner, resolved: true })).toEqual(
182
- submitted,
183
- );
184
- expect(resolveThreadChord({ name: "f", meta: true }, { ...owner, resolved: true })).toEqual({
185
- type: "treeFork",
186
- });
187
- expect(resolveThreadChord({ name: "t", meta: true }, { ...owner, isOwner: false })).toEqual({
188
- type: "toggleTree",
189
- });
190
- });
191
-
192
- test("an observer cannot edit or delete cards either", () => {
193
- // Arrange
194
- const observer = { ...owner, isOwner: false };
195
- const readOnly = { type: "status" as const, message: "observer - read-only" };
196
-
197
- // Assert
198
- expect(resolveThreadChord({ name: "e", meta: true }, observer)).toEqual(readOnly);
199
- expect(resolveThreadChord({ name: "backspace", meta: true }, observer)).toEqual(readOnly);
200
- expect(resolveThreadChord({ name: "r", meta: true }, observer)).toEqual({ type: "openRename" });
75
+ test("an unmapped key returns null, so the surface returns to typing", () => {
76
+ expect(resolveNavKey({ name: "z" }, owner)).toBeNull();
77
+ expect(resolveNavKey({ name: "j" }, owner)).toBeNull();
201
78
  });
202
79
  });