@cueloop/client 0.1.0-alpha.22 → 0.1.0-alpha.23

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 CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.22",
3
+ "version": "0.1.0-alpha.23",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@cueloop/daemon": "0.1.0-alpha.22",
11
- "@cueloop/extension-api": "0.1.0-alpha.22",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.22",
13
- "@cueloop/schema": "0.1.0-alpha.22",
10
+ "@cueloop/daemon": "0.1.0-alpha.23",
11
+ "@cueloop/extension-api": "0.1.0-alpha.23",
12
+ "@cueloop/integration-obsidian": "0.1.0-alpha.23",
13
+ "@cueloop/schema": "0.1.0-alpha.23",
14
14
  "@opentui/core": "^0.5.1",
15
15
  "@opentui/keymap": "0.5.1",
16
16
  "@opentui/react": "^0.5.1",
@@ -85,6 +85,25 @@ describe("share button", () => {
85
85
  });
86
86
  });
87
87
 
88
+ describe("edit affordance", () => {
89
+ test("the owner sees the Edit button", async () => {
90
+ // Arrange / Act
91
+ const owner = await renderApp();
92
+
93
+ // Assert
94
+ expect(owner.captureCharFrame()).toContain("Edit");
95
+ });
96
+
97
+ test("a read-only viewer (a plan shared over ssh) never sees the Edit button", async () => {
98
+ // Arrange / Act
99
+ const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, { width: 120, height: 32 });
100
+ await waitForText(viewer, "cueloop");
101
+
102
+ // Assert
103
+ expect(viewer.captureCharFrame()).not.toContain("Edit");
104
+ });
105
+ });
106
+
88
107
  describe("selection feeds the annotation quote", () => {
89
108
  test("v anchors, l extends, c composes with the char-precise quote", async () => {
90
109
  // Arrange
package/src/App.tsx CHANGED
@@ -74,9 +74,10 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
74
74
  // the plan or a verdict. `observer` is what the controller and every write
75
75
  // gate key off; the two capability flags carve out the collaborator's middle.
76
76
  const observer = readOnly || role === "observer";
77
- const canEditPlan = !observer && role !== "collaborator";
78
- const canSubmitVerdict = !observer && role !== "collaborator";
79
- const canShare = !observer && role === "owner";
77
+ // Editing the plan, submitting a verdict, and sharing are all the owner's
78
+ // alone - a collaborator annotates, an observer only reads. One predicate
79
+ // feeds all three; split it the day a collaborator earns one of them.
80
+ const isOwner = !observer && role === "owner";
80
81
  const controller = useMemo(
81
82
  () => createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
82
83
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -263,9 +264,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
263
264
  const state: KeyState = {
264
265
  keys: keysRef.current,
265
266
  readOnly: observer,
266
- canEditPlan,
267
- canSubmitVerdict,
268
- canShare,
267
+ canEditPlan: isOwner,
268
+ canSubmitVerdict: isOwner,
269
+ canShare: isOwner,
269
270
  overlay,
270
271
  view: !session ? "inbox" : isDiff ? "diff" : "plan",
271
272
  spanMode: mode.type === "span",
@@ -358,22 +359,23 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
358
359
  };
359
360
 
360
361
  const onEditRequest = (): void => {
361
- if (observer) return controller.setStatus("observer - read-only");
362
- if (!canEditPlan) return controller.setStatus("shared plan - edit it in your own copy");
362
+ // A share viewer/observer has no Edit affordance (the button is hidden), so
363
+ // this is owner-only; stay silent rather than nag if it is ever reached.
364
+ if (!isOwner) return;
363
365
  if (resolved) return controller.setStatus("review submitted - read-only");
364
366
  runEditorHandOff();
365
367
  };
366
368
 
367
369
  // clicking the rail Share button: publish the plan, copy the ssh line
368
370
  const onShareRequest = (): void => {
369
- if (!canShare) return controller.setStatus("only the plan owner can share");
371
+ if (!isOwner) return controller.setStatus("only the plan owner can share");
370
372
  controller.share();
371
373
  };
372
374
 
373
375
  // clicking the rail Submit button: same read-only answer as the submit key
374
376
  const onSubmitRequest = (): void => {
375
377
  if (observer) return controller.setStatus("observer - read-only");
376
- if (!canSubmitVerdict) return controller.setStatus("shared view - your notes save as you go");
378
+ if (!isOwner) return controller.setStatus("shared view - your notes save as you go");
377
379
  if (resolved) return;
378
380
  dispatch({ type: "openSubmit" });
379
381
  };
@@ -448,7 +450,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
448
450
  ? "walk"
449
451
  : focusedAnnotationId !== undefined
450
452
  ? "card"
451
- : "normal";
453
+ : role === "collaborator"
454
+ ? "collaborator"
455
+ : "normal";
452
456
 
453
457
  return (
454
458
  <ThemeProvider theme={theme}>
@@ -489,6 +493,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
489
493
  editOrphanCount={editOrphanCount}
490
494
  onLineActivate={onLineActivate}
491
495
  onEditRequest={onEditRequest}
496
+ canEdit={isOwner}
492
497
  />
493
498
  )}
494
499
  <ReviewPanel
@@ -511,7 +516,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
511
516
  onSelectCard: selectCardFromRail,
512
517
  onActivateCard: openCardEdit,
513
518
  onSubmitRequest,
514
- onShareRequest: canShare ? onShareRequest : undefined,
519
+ onShareRequest: isOwner ? onShareRequest : undefined,
515
520
  }}
516
521
  />
517
522
  </box>
@@ -6,7 +6,7 @@ import { fixtureDisplay, fixtureMarks, fixturePlanSession } from "./story-fixtur
6
6
 
7
7
  export const meta: StoryMeta = { title: "PlanSheet" };
8
8
 
9
- const callbacks = { onLineActivate: () => {}, onEditRequest: () => {} };
9
+ const callbacks = { onLineActivate: () => {}, onEditRequest: () => {}, canEdit: true };
10
10
 
11
11
  export const AnnotatedPlan: Story = {
12
12
  render: () => (
@@ -64,6 +64,8 @@ export interface PlanSheetProps {
64
64
  editOrphanCount: number;
65
65
  onLineActivate: (displayIndex: number) => void;
66
66
  onEditRequest: () => void;
67
+ /** Owner-only: show the Edit affordance. A share viewer never sees it. */
68
+ canEdit: boolean;
67
69
  theme?: Theme;
68
70
  }
69
71
 
@@ -97,6 +99,7 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
97
99
  editOrphanCount,
98
100
  onLineActivate,
99
101
  onEditRequest,
102
+ canEdit,
100
103
  theme,
101
104
  }: PlanSheetProps,
102
105
  handleRef,
@@ -215,7 +218,7 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
215
218
 
216
219
  return (
217
220
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
218
- <SheetHeader session={session} onEditRequest={onEditRequest} theme={theme} />
221
+ <SheetHeader session={session} onEditRequest={onEditRequest} canEdit={canEdit} theme={theme} />
219
222
  {editOrphanCount > 0 ? (
220
223
  <box style={{ height: 1, backgroundColor: tokens.markCommentBg, paddingLeft: 2 }}>
221
224
  <text fg={tokens.red}>
@@ -234,10 +237,12 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
234
237
  function SheetHeader({
235
238
  session,
236
239
  onEditRequest,
240
+ canEdit,
237
241
  theme,
238
242
  }: {
239
243
  session: ReviewSession;
240
244
  onEditRequest: () => void;
245
+ canEdit: boolean;
241
246
  theme?: Theme;
242
247
  }): React.ReactNode {
243
248
  const tokens = useComponentTheme(theme);
@@ -255,11 +260,13 @@ function SheetHeader({
255
260
  ) : null}
256
261
  </text>
257
262
  <box style={{ flexGrow: 1 }} />
258
- <Toolbar>
259
- <Button onPress={onEditRequest} theme={theme}>
260
- {" Edit "}
261
- </Button>
262
- </Toolbar>
263
+ {canEdit ? (
264
+ <Toolbar>
265
+ <Button onPress={onEditRequest} theme={theme}>
266
+ {" Edit "}
267
+ </Button>
268
+ </Toolbar>
269
+ ) : null}
263
270
  </box>
264
271
  );
265
272
  }
@@ -118,6 +118,17 @@ describe("getActiveKeys-generated status hints", () => {
118
118
  expect(bindings().statusHint("read-only")).toBe("observer - read-only · j/k move · n/p annotations · q quit");
119
119
  });
120
120
 
121
+ test("collaborator hint annotates but shows no plan edit or submit", () => {
122
+ // Arrange / Act
123
+ const hint = bindings().statusHint("collaborator");
124
+
125
+ // Assert
126
+ expect(hint).toBe("j/k move · v span · drag selects · c comment · s suggest · n/p annotations · q quit");
127
+ expect(hint).not.toContain("cut");
128
+ expect(hint).not.toContain("edit");
129
+ expect(hint).not.toContain("submit");
130
+ });
131
+
121
132
  test("a rebound key shows its real binding in the hint", () => {
122
133
  // Arrange
123
134
  const resolver = new KeyBindings({ ...DEFAULT_KEYS, comment: ["m"] });
@@ -24,7 +24,7 @@ export interface KeyLayerContext {
24
24
  }
25
25
 
26
26
  /** Hint templates keyed by the same view states the status line shows. */
27
- export type HintMode = "normal" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
27
+ export type HintMode = "normal" | "collaborator" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
28
28
 
29
29
  type HintEntry =
30
30
  | { text: string }
@@ -43,6 +43,16 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
43
43
  { commands: ["submit"], label: "submit" },
44
44
  { commands: ["quit"], label: "quit" },
45
45
  ],
46
+ // A share viewer: annotate and navigate, no plan edit / verdict submit.
47
+ collaborator: [
48
+ { commands: ["down", "up"], label: "move" },
49
+ { commands: ["span"], label: "span" },
50
+ { text: "drag selects" },
51
+ { commands: ["comment"], label: "comment" },
52
+ { commands: ["suggest"], label: "suggest" },
53
+ { commands: ["next_annotation", "prev_annotation"], label: "annotations" },
54
+ { commands: ["quit"], label: "quit" },
55
+ ],
46
56
  card: [
47
57
  { text: "card" },
48
58
  { commands: ["edit"], label: "edit" },
@@ -144,13 +144,13 @@ describe("collaborator (share) capabilities", () => {
144
144
  expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "editCard" }]);
145
145
  });
146
146
 
147
- test("cannot edit the plan: cut/edit with no card is refused", () => {
147
+ test("cannot edit the plan: cut/edit with no card is silent (no nag, no button)", () => {
148
148
  // Arrange
149
149
  const collab = state({ canEditPlan: false, hasFocusedAnnotation: false });
150
150
 
151
151
  // Act / Assert
152
- expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "status", message: "shared plan - edit it in your own copy" }]);
153
- expect(reduceKey(collab, key("c"), "cut")).toEqual([{ type: "status", message: "shared plan - edit it in your own copy" }]);
152
+ expect(reduceKey(collab, key("e"), "edit")).toEqual([]);
153
+ expect(reduceKey(collab, key("c"), "cut")).toEqual([]);
154
154
  });
155
155
 
156
156
  test("cannot submit a verdict: there is no agent on a share", () => {
package/src/keymap.ts CHANGED
@@ -217,8 +217,9 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
217
217
  // the document selects, the rail edits: with a card selected, Cut deletes
218
218
  // the annotation and edit rewrites the card body in place
219
219
  if (state.hasFocusedAnnotation) return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
220
- // editing the plan itself is the owner's verb - a collaborator only annotates
221
- if (state.canEditPlan === false) return status("shared plan - edit it in your own copy");
220
+ // editing the plan itself is the owner's verb; a share viewer only annotates,
221
+ // and has no edit affordance, so the key is silent rather than a nag
222
+ if (state.canEditPlan === false) return [];
222
223
  return [{ type: action }];
223
224
  }
224
225
  return annotationCluster(state, action) ?? [];