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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/package.json +14 -13
  2. package/src/App.diff.test.tsx +9 -4
  3. package/src/App.focus.test.ts +1 -59
  4. package/src/App.identity.test.tsx +3 -3
  5. package/src/App.plan-render.test.tsx +209 -0
  6. package/src/App.plan-review.test.tsx +75 -26
  7. package/src/App.readonly.test.tsx +6 -5
  8. package/src/App.share-dialog.test.tsx +172 -0
  9. package/src/App.submit-confirm.test.tsx +6 -4
  10. package/src/App.test.tsx +31 -28
  11. package/src/App.toast.test.tsx +11 -4
  12. package/src/App.tsx +259 -274
  13. package/src/App.walk.test.tsx +9 -8
  14. package/src/App.workbench.test.tsx +86 -0
  15. package/src/annotation-palette.ts +1 -0
  16. package/src/app-screens.tsx +12 -6
  17. package/src/app-view-model.ts +0 -1
  18. package/src/components/ConfirmDialog.stories.tsx +3 -3
  19. package/src/components/DiffContentView.tsx +14 -7
  20. package/src/components/DiscussionMarkerRail.tsx +9 -4
  21. package/src/components/GridTabContent.tsx +8 -2
  22. package/src/components/HandleChipsInput.stories.tsx +24 -0
  23. package/src/components/HandleChipsInput.test.tsx +35 -0
  24. package/src/components/HandleChipsInput.tsx +145 -0
  25. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  26. package/src/components/MarkdownGridBlock.tsx +72 -0
  27. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  28. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  29. package/src/components/MarkdownThreadEditor.tsx +162 -0
  30. package/src/components/NavModeHint.stories.tsx +40 -0
  31. package/src/components/NavModeHint.tsx +31 -0
  32. package/src/components/SettingsDialog.tsx +1 -0
  33. package/src/components/ShareDialog.stories.tsx +121 -0
  34. package/src/components/ShareDialog.tsx +552 -0
  35. package/src/components/ThreadFooter.tsx +4 -1
  36. package/src/components/ThreadTree.tsx +1 -1
  37. package/src/components/ThreadView.slash.test.tsx +0 -35
  38. package/src/components/ThreadView.tsx +142 -68
  39. package/src/components/Toast.stories.tsx +7 -0
  40. package/src/components/Toast.tsx +15 -3
  41. package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
  42. package/src/components/primitives/Button.tsx +6 -2
  43. package/src/components/primitives/Card.tsx +2 -2
  44. package/src/components/primitives/Dialog.tsx +2 -2
  45. package/src/components/primitives/Switch.stories.tsx +17 -0
  46. package/src/components/primitives/Switch.tsx +32 -0
  47. package/src/components/primitives/Tree.tsx +5 -1
  48. package/src/components/primitives/frame.ts +5 -0
  49. package/src/components/share-dialog-store.ts +58 -0
  50. package/src/components/stories.test.tsx +4 -3
  51. package/src/config.test.ts +18 -0
  52. package/src/config.ts +8 -1
  53. package/src/diff-fold.ts +1 -1
  54. package/src/intent-dispatch.test.ts +12 -3
  55. package/src/intent-dispatch.ts +10 -25
  56. package/src/key-bindings.ts +0 -6
  57. package/src/keymap.ts +0 -1
  58. package/src/markdown-editor-syntax-style.ts +50 -0
  59. package/src/markdown-grid.test.ts +101 -0
  60. package/src/markdown-grid.ts +203 -0
  61. package/src/markdown-highlight.test.ts +61 -0
  62. package/src/markdown-highlight.ts +122 -0
  63. package/src/prototype-browser.test.ts +9 -1
  64. package/src/prototype-browser.ts +22 -0
  65. package/src/run.ts +8 -6
  66. package/src/share.ts +15 -1
  67. package/src/test-support.ts +14 -0
  68. package/src/thread-chords.test.ts +55 -178
  69. package/src/thread-chords.ts +78 -169
  70. package/src/thread-controller.curate.test.ts +1 -0
  71. package/src/thread-controller.live-diff.test.ts +3 -0
  72. package/src/thread-controller.share.test.ts +60 -5
  73. package/src/thread-controller.switch-cache.test.ts +30 -0
  74. package/src/thread-controller.tree.test.ts +6 -1
  75. package/src/thread-controller.ts +229 -54
  76. package/src/use-annotation-surface.tsx +45 -36
  77. package/src/use-settings-dialog.tsx +14 -3
  78. package/src/use-thread-body-editing.tsx +58 -0
  79. package/src/view-plan.test.ts +56 -6
  80. package/src/view-plan.ts +154 -5
  81. package/src/App.share-choice.test.tsx +0 -105
  82. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  83. package/src/components/ManageAccessDialog.test.tsx +0 -94
  84. package/src/components/ManageAccessDialog.tsx +0 -122
  85. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  86. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  87. package/src/components/ShareChoiceDialog.tsx +0 -80
  88. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  89. package/src/components/ThreadsSidebar.tsx +0 -71
  90. package/src/components/agent-launcher.stories.tsx +0 -26
  91. package/src/components/agent-launcher.test.tsx +0 -50
  92. package/src/components/agent-launcher.tsx +0 -149
@@ -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
  });
@@ -1,186 +1,75 @@
1
- /**
2
- * The session-level chords of the thread view. Letters in the thread view
3
- * type a comment, so everything that acts on the session as a whole - submit,
4
- * share, edit, the rail - lives on ctrl chords (cmd where the terminal
5
- * delivers it). The guided walk is a diff-review mode and has no chord here. Pure: the view reports whether a composer is open, this maps
6
- * a key to an intent or to nothing.
7
- */
8
-
9
1
  import type { Intent } from "./keymap";
10
2
 
11
- export interface ThreadChordKey {
3
+ export interface NavKey {
12
4
  name: string;
13
- ctrl?: boolean;
14
- meta?: boolean;
15
- super?: boolean;
5
+ shift?: boolean;
16
6
  }
17
7
 
18
- export interface ThreadChordContext {
19
- /** A composer owns the keyboard: chords never fire while one is open. */
20
- composing: boolean;
21
- /** Collaborators annotate only; submit, edit, and share are the owner's. */
8
+ export interface ThreadNavContext {
22
9
  isOwner: boolean;
23
- /** A resolved session has nothing left to submit. */
24
10
  resolved: boolean;
25
- /** The rail shows the tree: next / previous move its selection instead of the cards. */
26
11
  treeActive: boolean;
27
- /** A diff review: the block chords act on the code row under the caret (reject, fold, walk). */
28
12
  isDiff?: boolean;
29
13
  }
30
14
 
31
- export const DEFAULT_LEADER = "ctrl+g";
32
-
33
- export function leaderCombosFor(configured: readonly string[] | undefined): readonly string[] {
34
- return configured ?? [DEFAULT_LEADER];
35
- }
36
-
37
- const MOD_GLYPHS = new Map<string, string>([
38
- ["ctrl", "⌃"],
39
- ["control", "⌃"],
40
- ["meta", "⌥"],
41
- ["alt", "⌥"],
42
- ["option", "⌥"],
43
- ["shift", "⇧"],
44
- ["cmd", "⌘"],
45
- ["super", "⌘"],
46
- ]);
47
-
48
- function comboParts(combo: string) {
49
- const parts = combo.split("+");
50
-
51
- return { mods: parts.slice(0, -1).map((part) => part.toLowerCase()), name: parts.at(-1) ?? "" };
52
- }
53
-
54
- export function leaderHint(combos: readonly string[]): string {
55
- const { mods, name } = comboParts(combos[0] ?? DEFAULT_LEADER);
56
-
57
- return `${mods.map((mod) => MOD_GLYPHS.get(mod) ?? mod).join("")}${name} `;
58
- }
59
-
60
- export function matchesLeader(
61
- key: { ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean; name: string },
62
- combos: readonly string[],
63
- ): boolean {
64
- return combos.some((combo) => {
65
- const { mods, name } = comboParts(combo);
66
-
67
- return (
68
- key.name.toLowerCase() === name.toLowerCase() &&
69
- Boolean(key.ctrl) === (mods.includes("ctrl") || mods.includes("control")) &&
70
- Boolean(key.shift) === mods.includes("shift") &&
71
- Boolean(key.super) === (mods.includes("cmd") || mods.includes("super")) &&
72
- Boolean(key.meta) ===
73
- (mods.includes("meta") || mods.includes("alt") || mods.includes("option"))
74
- );
75
- });
76
- }
77
-
78
15
  export interface ChordEntry {
79
16
  keys: string;
80
17
  label: string;
81
18
  }
82
19
 
83
- /** The diff-review chords: the leader (or Option) plus a letter, on the caret's row or file. */
84
- export function diffChordEntries(hint: string): ChordEntry[] {
20
+ export function sessionCommandEntries(): ChordEntry[] {
85
21
  return [
86
- { keys: `${hint}x / ${hint}X`, label: "reject the change / the hunk" },
87
- { keys: `${hint}u`, label: "restore the last rejection" },
88
- { keys: `${hint}c`, label: "collapse the file to its band" },
89
- { keys: `${hint}d`, label: "split / stacked (when wide)" },
90
- { keys: `${hint}k`, label: "start the guided walk" },
22
+ { keys: "⏎", label: "submit the review" },
23
+ { keys: "e", label: "edit in $EDITOR" },
24
+ { keys: "s", label: "share" },
91
25
  ];
92
26
  }
93
27
 
94
- /** The session chords stay on Ctrl, which terminals deliver reliably. */
95
- export const THREAD_CHORD_ENTRIES = [
96
- { keys: "⌃enter", label: "submit the review" },
97
- { keys: "⌃e", label: "edit in $EDITOR" },
98
- { keys: "⌃s", label: "share" },
99
- { keys: "⌃r", label: "cycle the rail" },
100
- ] as const;
101
-
102
- /** The rail and curation chords: the leader plus the plan sheet's old letter. */
103
- export function railChordEntries(hint: string): ChordEntry[] {
28
+ export function diffCommandEntries(): ChordEntry[] {
104
29
  return [
105
- { keys: `${hint}n / ${hint}p`, label: "next / previous card" },
106
- { keys: `${hint}e`, label: "edit the card" },
107
- { keys: `${hint}⌫`, label: "delete the card" },
108
- { keys: `${hint}r`, label: "rename the author" },
109
- { keys: `${hint}x`, label: "cut the block" },
110
- { keys: `${hint}u`, label: "restore the last cut" },
111
- { keys: "⌥w / ⌥s", label: "widen / narrow the rail" },
30
+ { keys: "x", label: "reject the change under the caret" },
31
+ { keys: "u", label: "restore the last rejection" },
32
+ { keys: "c", label: "collapse the file to its band" },
33
+ { keys: "d", label: "split / stacked (when wide)" },
34
+ { keys: "k", label: "start the guided walk" },
112
35
  ];
113
36
  }
114
37
 
115
- /** The tree chords: the leader plus a letter, all on the rail's Tree tab. */
116
- export function treeChordEntries(hint: string): ChordEntry[] {
38
+ export function curationCommandEntries(): ChordEntry[] {
117
39
  return [
118
- { keys: `${hint}t`, label: "show / hide the tree" },
119
- { keys: `${hint}n / ${hint}p`, label: "next / previous entry" },
120
- { keys: `${hint}g`, label: "go to the entry" },
121
- { keys: `${hint}b`, label: "branch off the tip" },
122
- { keys: `${hint}l`, label: "label a checkpoint" },
123
- { keys: `${hint}f`, label: "fork the path" },
124
- { keys: `${hint}h`, label: "fork and hand off" },
40
+ { keys: "n / p", label: "next / previous card" },
41
+ { keys: "z", label: "fold / unfold the card" },
42
+ { keys: "⌫", label: "delete the card" },
43
+ { keys: "r", label: "rename the author" },
44
+ { keys: "x", label: "cut the block" },
45
+ { keys: "u", label: "restore the last cut" },
125
46
  ];
126
47
  }
127
48
 
128
- export function resolveThreadChord(
129
- key: ThreadChordKey,
130
- context: ThreadChordContext,
131
- ): Intent | null {
132
- if (context.composing) return null;
133
- const modified = Boolean(key.ctrl || key.meta || key.super);
134
-
135
- if (!modified) return null;
136
- if (key.name === "return" || key.name === "enter") {
137
- if (!context.isOwner) return READ_ONLY;
138
-
139
- return context.resolved ? null : { type: "openSubmit" };
140
- }
141
- if (key.ctrl) return resolveSessionChord(key.name, context);
142
- if (key.meta)
143
- return (
144
- (context.isDiff && resolveDiffChord(key.name, context)) || resolveRailChord(key.name, context)
145
- );
146
-
147
- return null;
49
+ export function treeCommandEntries(): ChordEntry[] {
50
+ return [
51
+ { keys: "g", label: "go to the entry" },
52
+ { keys: "b", label: "branch off the tip" },
53
+ { keys: "l", label: "label a checkpoint" },
54
+ { keys: "f", label: "fork the path" },
55
+ { keys: "h", label: "fork and hand off" },
56
+ ];
148
57
  }
149
58
 
150
- export function resolveLeaderCommand(
151
- key: ThreadChordKey,
152
- context: ThreadChordContext,
153
- ): Intent | null {
154
- if (context.composing) return null;
155
- if (key.name === "return" || key.name === "enter") {
156
- if (!context.isOwner) return READ_ONLY;
157
-
158
- return context.resolved ? null : { type: "openSubmit" };
159
- }
160
-
161
- return (
162
- (context.isDiff && resolveDiffChord(key.name, context)) || resolveRailChord(key.name, context)
163
- );
164
- }
59
+ const READ_ONLY: Intent = { type: "status", message: "observer - read-only" };
60
+ const RESOLVED: Intent = { type: "status", message: "review submitted - read-only" };
165
61
 
166
- export function dispatchLeaderCommand(
167
- key: { name: string; shift?: boolean },
168
- context: ThreadChordContext,
169
- dispatch: (intent: Intent) => void,
170
- ): void {
171
- const name = key.shift ? key.name.toUpperCase() : key.name;
172
- const chord = resolveLeaderCommand({ name }, context);
62
+ function mutating(intent: Intent, context: ThreadNavContext): Intent {
63
+ if (!context.isOwner) return READ_ONLY;
64
+ if (context.resolved) return RESOLVED;
173
65
 
174
- if (chord) dispatch(chord);
66
+ return intent;
175
67
  }
176
68
 
177
- /** The diff's row and file chords; null lets the rail chords answer the letter. */
178
- function resolveDiffChord(name: string, context: ThreadChordContext): Intent | null {
69
+ function resolveDiffKey(name: string, context: ThreadNavContext): Intent | null {
179
70
  switch (name) {
180
71
  case "x":
181
72
  return mutating({ type: "rejectChange" }, context);
182
- case "X":
183
- return mutating({ type: "rejectHunk" }, context);
184
73
  case "c":
185
74
  return { type: "foldFile" };
186
75
  case "d":
@@ -192,19 +81,7 @@ function resolveDiffChord(name: string, context: ThreadChordContext): Intent | n
192
81
  }
193
82
  }
194
83
 
195
- /** The answers a blocked primitive gets - the same words the keymap uses. */
196
- const READ_ONLY: Intent = { type: "status", message: "observer - read-only" };
197
- const RESOLVED: Intent = { type: "status", message: "review submitted - read-only" };
198
-
199
- /** Editing, deleting, cutting, and restoring change the review: gated by role and by a verdict. */
200
- function mutating(intent: Intent, context: ThreadChordContext): Intent {
201
- if (!context.isOwner) return READ_ONLY;
202
- if (context.resolved) return RESOLVED;
203
-
204
- return intent;
205
- }
206
-
207
- function resolveSessionChord(name: string, context: ThreadChordContext): Intent | null {
84
+ function resolveSessionKey(name: string, context: ThreadNavContext): Intent | null {
208
85
  switch (name) {
209
86
  case "e":
210
87
  return mutating({ type: "edit" }, context);
@@ -215,33 +92,24 @@ function resolveSessionChord(name: string, context: ThreadChordContext): Intent
215
92
  }
216
93
  }
217
94
 
218
- // option arrives as meta in this terminal stack; the letters mirror the
219
- // plan sheet's keymap so the rail keeps its muscle memory
220
- function resolveRailChord(name: string, context: ThreadChordContext): Intent | null {
95
+ function resolveCurationKey(name: string, context: ThreadNavContext): Intent | null {
221
96
  switch (name) {
222
97
  case "n":
223
98
  return context.treeActive ? { type: "treeMove", direction: 1 } : { type: "nextAnnotation" };
224
99
  case "p":
225
100
  return context.treeActive ? { type: "treeMove", direction: -1 } : { type: "prevAnnotation" };
226
- case "t":
227
- return { type: "toggleTree" };
228
- // the tree is the owner's: a move, a branch, or a label changes what everyone sees
229
101
  case "g":
230
102
  return mutating({ type: "treeGo" }, context);
231
103
  case "b":
232
104
  return mutating({ type: "treeBranch" }, context);
233
105
  case "l":
234
106
  return mutating({ type: "treeLabel" }, context);
235
- // a fork can be taken from a resolved review: only the role gates it
236
107
  case "f":
237
108
  return context.isOwner ? { type: "treeFork" } : READ_ONLY;
238
109
  case "h":
239
110
  return context.isOwner ? { type: "treeForkShare" } : READ_ONLY;
240
- case "e":
241
- return mutating({ type: "editCard" }, context);
242
111
  case "backspace":
243
112
  return mutating({ type: "removeAnnotation" }, context);
244
- // renaming an author is a local display choice, open to every role
245
113
  case "r":
246
114
  return { type: "openRename" };
247
115
  case "x":
@@ -252,3 +120,44 @@ function resolveRailChord(name: string, context: ThreadChordContext): Intent | n
252
120
  return null;
253
121
  }
254
122
  }
123
+
124
+ export interface SessionChordKey {
125
+ name: string;
126
+ ctrl?: boolean;
127
+ meta?: boolean;
128
+ super?: boolean;
129
+ }
130
+
131
+ export function resolveSessionChord(
132
+ key: SessionChordKey,
133
+ context: Pick<ThreadNavContext, "isOwner" | "resolved">,
134
+ ): Intent | null {
135
+ if (key.name === "return" || key.name === "enter") {
136
+ if (!(key.ctrl || key.meta || key.super)) return null;
137
+ if (!context.isOwner) return READ_ONLY;
138
+
139
+ return context.resolved ? null : { type: "openSubmit" };
140
+ }
141
+ if (!key.ctrl) return null;
142
+ if (key.name === "e")
143
+ return context.resolved ? RESOLVED : context.isOwner ? { type: "edit" } : READ_ONLY;
144
+ if (key.name === "s") return context.isOwner ? { type: "share" } : READ_ONLY;
145
+
146
+ return null;
147
+ }
148
+
149
+ export function resolveNavKey(key: NavKey, context: ThreadNavContext): Intent | null {
150
+ const name = key.shift ? key.name.toUpperCase() : key.name;
151
+
152
+ if (name === "return" || name === "enter") {
153
+ if (!context.isOwner) return READ_ONLY;
154
+
155
+ return context.resolved ? null : { type: "openSubmit" };
156
+ }
157
+
158
+ return (
159
+ (context.isDiff ? resolveDiffKey(name, context) : null) ??
160
+ resolveSessionKey(name, context) ??
161
+ resolveCurationKey(name, context)
162
+ );
163
+ }
@@ -118,6 +118,7 @@ function fakeClient(initial: Thread, sink: WorkingCopySink): SessionClient {
118
118
  sessionSetViewed: unimplemented("sessionSetViewed"),
119
119
  sessionSetTitle: unimplemented("sessionSetTitle"),
120
120
  sessionSetShareId: unimplemented("sessionSetShareId"),
121
+ sessionSetShares: unimplemented("sessionSetShares"),
121
122
  sessionMergeShared: unimplemented("sessionMergeShared"),
122
123
  sessionDelete: unimplemented("sessionDelete"),
123
124
  sessionSetSelfName: unimplemented("sessionSetSelfName"),
@@ -38,7 +38,9 @@ const shareTransport: ShareTransport = {
38
38
  pull: mock(async () => planSession()),
39
39
  push: mock(async () => {}),
40
40
  watch: () => () => {},
41
+ revoke: async () => {},
41
42
  parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
43
+ formatShareLine: (id: string) => "ssh " + id + "@cueloop.dev",
42
44
  collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
43
45
  mergeFromShare,
44
46
  };
@@ -82,6 +84,7 @@ function fakeClient(session: Thread): SessionClient {
82
84
  sessionSetViewed: unimplemented("sessionSetViewed"),
83
85
  sessionSetTitle: unimplemented("sessionSetTitle"),
84
86
  sessionSetShareId: unimplemented("sessionSetShareId"),
87
+ sessionSetShares: unimplemented("sessionSetShares"),
85
88
  sessionMergeShared: unimplemented("sessionMergeShared"),
86
89
  sessionDelete: unimplemented("sessionDelete"),
87
90
  sessionSetSelfName: unimplemented("sessionSetSelfName"),