@cueloop/client 0.1.0-alpha.69 → 0.1.0-alpha.70

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 (53) hide show
  1. package/package.json +5 -5
  2. package/src/App.appearance.test.tsx +2 -2
  3. package/src/App.autoclose.test.tsx +2 -2
  4. package/src/App.diff.test.tsx +2 -2
  5. package/src/App.export.test.tsx +2 -2
  6. package/src/App.plan-review.test.tsx +2 -2
  7. package/src/App.prototype-markdown.test.tsx +2 -2
  8. package/src/App.prototype.test.tsx +2 -2
  9. package/src/App.readonly.test.tsx +2 -2
  10. package/src/App.rendering.test.tsx +2 -2
  11. package/src/App.submit-confirm.test.tsx +2 -2
  12. package/src/App.test.tsx +2 -2
  13. package/src/App.toast.test.tsx +3 -3
  14. package/src/App.tsx +240 -228
  15. package/src/App.walk.test.tsx +2 -2
  16. package/src/App.workbench.test.tsx +2 -2
  17. package/src/app-key-state.ts +3 -3
  18. package/src/app-screens.tsx +4 -4
  19. package/src/app-view-model.ts +6 -6
  20. package/src/components/AnnotatableFileView.stories.tsx +2 -2
  21. package/src/components/AnnotatableFileView.test.tsx +2 -2
  22. package/src/components/AnnotatableFileView.tsx +2 -2
  23. package/src/components/BareWorkbenchFileView.tsx +2 -7
  24. package/src/components/ChangesColumn.tsx +2 -2
  25. package/src/components/DiffContentView.tsx +2 -2
  26. package/src/components/ThreadView.selection.test.tsx +5 -5
  27. package/src/components/ThreadView.slash.test.tsx +49 -4
  28. package/src/components/ThreadView.tsx +2 -2
  29. package/src/components/WelcomePlayground.tsx +2 -2
  30. package/src/components/__snapshots__/stories.test.tsx.snap +6 -6
  31. package/src/components/agent-launcher.tsx +2 -2
  32. package/src/components/quick-actions-editor.test.tsx +49 -0
  33. package/src/components/quick-actions-editor.tsx +32 -20
  34. package/src/components/session-tree.test.ts +2 -2
  35. package/src/components/session-tree.ts +10 -10
  36. package/src/components/story-fixtures.ts +3 -3
  37. package/src/config.test.ts +30 -1
  38. package/src/config.ts +19 -0
  39. package/src/discussions.ts +2 -2
  40. package/src/intent-dispatch.test.ts +3 -6
  41. package/src/intent-dispatch.ts +6 -6
  42. package/src/serve.test.ts +2 -2
  43. package/src/share.ts +9 -14
  44. package/src/skills.test.ts +74 -0
  45. package/src/skills.ts +68 -0
  46. package/src/slash-palette.ts +10 -3
  47. package/src/{session-controller.curate.test.ts → thread-controller.curate.test.ts} +8 -7
  48. package/src/{session-controller.live-diff.test.ts → thread-controller.live-diff.test.ts} +7 -6
  49. package/src/{session-controller.share.test.ts → thread-controller.share.test.ts} +14 -13
  50. package/src/{session-controller.tree.test.ts → thread-controller.tree.test.ts} +8 -7
  51. package/src/{session-controller.ts → thread-controller.ts} +18 -22
  52. package/src/use-annotation-surface.tsx +14 -10
  53. package/src/view-diff.ts +2 -2
@@ -1,8 +1,8 @@
1
1
  import type { Dispatch, MutableRefObject, SetStateAction } from "react";
2
- import type { ReviewSession, VerdictKind } from "@cueloop/schema";
2
+ import type { Thread, VerdictKind } from "@cueloop/schema";
3
3
  import type { Mode } from "./intent-dispatch";
4
4
  import type { Intent, KeyState } from "./keymap";
5
- import type { Completion } from "./session-controller";
5
+ import type { Completion } from "./thread-controller";
6
6
  import type { WalkFile } from "./walk";
7
7
  import { viewedCount } from "./walk";
8
8
  import type { ConfirmCardProps } from "./components/ConfirmCard";
@@ -17,7 +17,7 @@ export function computeRoleCapabilities(
17
17
  return { observer, isOwner: !observer && role === "owner" };
18
18
  }
19
19
 
20
- export function deriveReviewFlags(session: ReviewSession | null) {
20
+ export function deriveReviewFlags(session: Thread | null) {
21
21
  return {
22
22
  isDiff: session?.artifact.type === "diff",
23
23
  isPrototype: session?.artifact.type === "prototype",
@@ -72,7 +72,7 @@ export function isCompletionOverlayPhase(
72
72
  }
73
73
 
74
74
  export function buildHeaderItems(params: {
75
- session: ReviewSession;
75
+ session: Thread;
76
76
  resolved: boolean;
77
77
  observer: boolean;
78
78
  role: "owner" | "observer" | "collaborator";
@@ -102,7 +102,7 @@ export function buildHeaderItems(params: {
102
102
  }
103
103
 
104
104
  export function buildRenderFlags(params: {
105
- session: ReviewSession;
105
+ session: Thread;
106
106
  isOwner: boolean;
107
107
  isDiff: boolean;
108
108
  isPixelPrototype: boolean;
@@ -132,7 +132,7 @@ export function buildSubmitConfirmState(
132
132
  deps: DraftHandlerDeps & {
133
133
  mode: Mode;
134
134
  isDiff: boolean;
135
- session: ReviewSession;
135
+ session: Thread;
136
136
  walkFileList: WalkFile[];
137
137
  viewedPaths: Set<string>;
138
138
  },
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { SCHEMA_VERSION, type ReviewSession } from "@cueloop/schema";
2
+ import { SCHEMA_VERSION, type Thread } from "@cueloop/schema";
3
3
  import { DARK } from "../theme";
4
4
  import type { Story, StoryMeta } from "./story";
5
5
  import { AnnotatableFileView } from "./AnnotatableFileView";
@@ -8,7 +8,7 @@ export const meta: StoryMeta = { title: "Surfaces/AnnotatableFileView" };
8
8
 
9
9
  const SAMPLE = `export function add(a: number, b: number): number {\n return a + b;\n}\n`;
10
10
 
11
- const SESSION: ReviewSession = {
11
+ const SESSION: Thread = {
12
12
  schemaVersion: SCHEMA_VERSION,
13
13
  id: "ses_story",
14
14
  workspace: { repoRoot: "/repo", branch: "main" },
@@ -6,14 +6,14 @@
6
6
  import { describe, expect, mock, test } from "bun:test";
7
7
  import { testRender } from "@opentui/react/test-utils";
8
8
  import React from "react";
9
- import { SCHEMA_VERSION, type Anchor, type ReviewSession } from "@cueloop/schema";
9
+ import { SCHEMA_VERSION, type Anchor, type Thread } from "@cueloop/schema";
10
10
  import { AnnotatableFileView } from "./AnnotatableFileView";
11
11
  import { DARK } from "../theme";
12
12
  import { dragText, typeText, pressKey, waitForText } from "../test-support";
13
13
 
14
14
  const SAMPLE = "export function add(a: number, b: number) {\n return a + b;\n}\n";
15
15
 
16
- function planSession(): ReviewSession {
16
+ function planSession(): Thread {
17
17
  return {
18
18
  schemaVersion: SCHEMA_VERSION,
19
19
  id: "ses_plan",
@@ -4,7 +4,7 @@
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 ReviewSession } from "@cueloop/schema";
7
+ import { makeAnchor, annotationTarget, type Anchor, type Thread } from "@cueloop/schema";
8
8
  import type { Theme } from "../theme";
9
9
  import type { QuickAction } from "../config";
10
10
  import { useComponentTheme } from "./theme-context";
@@ -14,7 +14,7 @@ import { diffRowBlocks, fileContentsRows, marksByRows } from "../view-diff";
14
14
  export interface AnnotatableFileViewProps {
15
15
  path: string;
16
16
  loadContents: (path: string) => Promise<string | null>;
17
- session: ReviewSession;
17
+ session: Thread;
18
18
  quickActions: QuickAction[];
19
19
  observer: boolean;
20
20
  resolved?: boolean;
@@ -3,12 +3,7 @@
3
3
  // opens it - so this ephemeral view lives only until that first note promotes it to a real thread.
4
4
 
5
5
  import React from "react";
6
- import {
7
- SCHEMA_VERSION,
8
- type Anchor,
9
- type AnnotationTarget,
10
- type ReviewSession,
11
- } from "@cueloop/schema";
6
+ import { SCHEMA_VERSION, type Anchor, type AnnotationTarget, type Thread } from "@cueloop/schema";
12
7
  import type { Theme } from "../theme";
13
8
  import type { QuickAction } from "../config";
14
9
  import { AnnotatableFileView } from "./AnnotatableFileView";
@@ -20,7 +15,7 @@ export interface WorkbenchCommenter {
20
15
  }
21
16
 
22
17
  /** An empty, unsaved thread: it holds no notes, just enough shape for the annotation surface. */
23
- function draftThread(): ReviewSession {
18
+ function draftThread(): Thread {
24
19
  return {
25
20
  schemaVersion: SCHEMA_VERSION,
26
21
  id: "workbench-draft",
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { ScrollArea } from "./ScrollArea";
7
7
  import React, { useMemo, useState } from "react";
8
- import type { DiffFileContents, ReviewSession } from "@cueloop/schema";
8
+ import type { DiffFileContents, Thread } from "@cueloop/schema";
9
9
  import type { Theme } from "../theme";
10
10
  import type { DiffRow } from "../view-diff";
11
11
  import { Tree } from "./primitives/Tree";
@@ -28,7 +28,7 @@ export interface DiffColumnsState {
28
28
  * Resets on session change via the guarded render-time pattern, no effect.
29
29
  */
30
30
  export function useDiffColumns(params: {
31
- session: ReviewSession | null;
31
+ session: Thread | null;
32
32
  rows: DiffRow[];
33
33
  cursor: number;
34
34
  setCursor: (index: number) => void;
@@ -10,7 +10,7 @@
10
10
 
11
11
  import React, { useEffect, useMemo, useRef, useState } from "react";
12
12
  import { createTextAttributes, type ScrollBoxRenderable } from "@opentui/core";
13
- import type { ReviewSession } from "@cueloop/schema";
13
+ import type { Thread } from "@cueloop/schema";
14
14
  import { diffRowText, fileChangeCounts, type DiffRow, type Mark } from "../view-diff";
15
15
  import type { TextSpan } from "../thread-selection";
16
16
  import type { QuickAction } from "../config";
@@ -71,7 +71,7 @@ export interface DiffFoldControls {
71
71
 
72
72
  export interface DiffContentViewProps {
73
73
  rows: DiffRow[];
74
- session: ReviewSession;
74
+ session: Thread;
75
75
  /** Annotations resolved onto rows (marksByRows), with char ranges and spans. */
76
76
  marks: Map<number, Mark[]>;
77
77
  quickActions: QuickAction[];
@@ -29,7 +29,7 @@ const STANZA_ROWS = [
29
29
  "so a crash can never leave a torn file on disk.",
30
30
  ];
31
31
  const STANZA = STANZA_ROWS.join("\n");
32
- const BULLETS = ["store.ts - the SessionStore class", "schema.ts - the on-disk record shape"];
32
+ const BULLETS = ["store.ts - the ThreadStore class", "schema.ts - the on-disk record shape"];
33
33
  const PLAN = `# Plan\n\n${PARAGRAPH}\n\n${STANZA}\n\n- ${BULLETS[0]}\n- ${BULLETS[1]}\n`;
34
34
  /** Display block indices: title, paragraph, stanza, two bullets. */
35
35
  const STANZA_BLOCK = 2;
@@ -479,8 +479,8 @@ describe("marking across blocks", () => {
479
479
  await setup.mockMouse.pressDown(torn.column, torn.row);
480
480
  await settle(setup);
481
481
 
482
- // Act: onto "Session" in the first bullet
483
- await setup.mockMouse.moveTo(bullet.column + BULLETS[0]!.indexOf("Session") + 7, bullet.row);
482
+ // Act: onto "Thread" in the first bullet
483
+ await setup.mockMouse.moveTo(bullet.column + BULLETS[0]!.indexOf("Thread") + 6, bullet.row);
484
484
  await settle(setup);
485
485
 
486
486
  // Assert: two rows, the blank row between them untouched
@@ -488,7 +488,7 @@ describe("marking across blocks", () => {
488
488
 
489
489
  expect([...rows.keys()]).toEqual([torn.row, bullet.row]);
490
490
  expect(rows.get(torn.row)).toBe("torn file on disk.");
491
- expect(rows.get(bullet.row)).toBe("store.ts - the Session");
491
+ expect(rows.get(bullet.row)).toBe("store.ts - the Thread");
492
492
  });
493
493
 
494
494
  test("a drag across two bullets marks both", async () => {
@@ -506,7 +506,7 @@ describe("marking across blocks", () => {
506
506
  // Assert
507
507
  const rows = highlightedByRow();
508
508
 
509
- expect(rows.get(first.row)).toBe("the SessionStore class");
509
+ expect(rows.get(first.row)).toBe("the ThreadStore class");
510
510
  expect(rows.get(second.row)).toBe("schema.ts");
511
511
  expect(rows.size).toBe(2);
512
512
  });
@@ -8,6 +8,8 @@ import { settle, typeText } from "../test-support";
8
8
  import { buildDisplay, marksByDisplay } from "../view-plan";
9
9
  import { fixturePlanSession } from "./story-fixtures";
10
10
  import { inlineSlashToken, resolveInlineSuggestion, ThreadView } from "./ThreadView";
11
+ import { slashItemsFrom, type SlashItem } from "../slash-palette";
12
+ import { SlashSkillsContext } from "../skills";
11
13
 
12
14
  const SKILLS: QuickAction[] = [
13
15
  { prompt: "typescript magician" },
@@ -66,24 +68,67 @@ describe("inlineSlashToken", () => {
66
68
 
67
69
  describe("resolveInlineSuggestion", () => {
68
70
  test("offers the fuzzy closest skill for the trailing token", () => {
69
- const inline = resolveInlineSuggestion(false, "run /type", SKILLS);
71
+ const inline = resolveInlineSuggestion(false, "run /type", slashItemsFrom(SKILLS));
70
72
 
71
73
  expect(inline?.token).toBe("/type");
72
74
  expect(inline?.suggestion.name).toBe("typescript-magician");
73
75
  });
74
76
 
75
77
  test("prefers a prefix match over a subsequence match", () => {
76
- const inline = resolveInlineSuggestion(false, "run /impl", SKILLS);
78
+ const inline = resolveInlineSuggestion(false, "run /impl", slashItemsFrom(SKILLS));
77
79
 
78
80
  expect(inline?.suggestion.name).toBe("implement");
79
81
  });
80
82
 
81
83
  test("stays silent while the palette owns a leading slash", () => {
82
- expect(resolveInlineSuggestion(true, "/type", SKILLS)).toBeNull();
84
+ expect(resolveInlineSuggestion(true, "/type", slashItemsFrom(SKILLS))).toBeNull();
83
85
  });
84
86
 
85
87
  test("returns null when nothing matches the token", () => {
86
- expect(resolveInlineSuggestion(false, "run /zzzz", SKILLS)).toBeNull();
88
+ expect(resolveInlineSuggestion(false, "run /zzzz", slashItemsFrom(SKILLS))).toBeNull();
89
+ });
90
+ });
91
+
92
+ async function mountComposerWithSkills(skills: SlashItem[]) {
93
+ const display = buildDisplay(PLAN, undefined);
94
+ const setup = await testRender(
95
+ <SlashSkillsContext.Provider value={skills}>
96
+ <ThreadView
97
+ session={fixturePlanSession({
98
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
99
+ })}
100
+ display={display}
101
+ marks={marksByDisplay([], display)}
102
+ quickActions={SKILLS}
103
+ observer={false}
104
+ onAnnotate={() => {}}
105
+ onReply={() => {}}
106
+ onUpdateAnnotation={() => {}}
107
+ onExit={() => {}}
108
+ />
109
+ </SlashSkillsContext.Provider>,
110
+ { width: 72, height: 20 },
111
+ );
112
+
113
+ await settle(setup);
114
+ await settle(setup);
115
+
116
+ return setup;
117
+ }
118
+
119
+ describe("skills in the palette", () => {
120
+ test("a skill from context lists in the / palette and a pick inserts its /name", async () => {
121
+ const setup = await mountComposerWithSkills([
122
+ { name: "vitest-patterns", description: "patterns for vitest", body: "" },
123
+ ]);
124
+
125
+ await typeText(setup, "/vitest");
126
+ expect(setup.captureCharFrame()).toContain("/vitest-patterns");
127
+
128
+ setup.mockInput.pressKey("RETURN");
129
+ await settle(setup);
130
+ // the reference lands in the draft, exactly like a quick action
131
+ expect(setup.captureCharFrame()).toContain("/vitest-patterns");
87
132
  });
88
133
  });
89
134
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  import React, { useEffect, useRef } from "react";
16
16
  import type { ScrollBoxRenderable } from "@opentui/core";
17
- import type { ReviewSession } from "@cueloop/schema";
17
+ import type { Thread } from "@cueloop/schema";
18
18
  import { displayText, type DisplayBlock, type Mark } from "../view-plan";
19
19
  import type { TextSpan } from "../thread-selection";
20
20
  import type { QuickAction } from "../config";
@@ -104,7 +104,7 @@ export const THREAD_VIEW_CHEATSHEET: CheatsheetSection[] = [
104
104
  ];
105
105
 
106
106
  export interface ThreadViewProps {
107
- session: ReviewSession;
107
+ session: Thread;
108
108
  display: DisplayBlock[];
109
109
  marks: Map<number, Mark[]>;
110
110
  quickActions: QuickAction[];
@@ -10,7 +10,7 @@ import {
10
10
  newAnnotationId,
11
11
  SCHEMA_VERSION,
12
12
  type Annotation,
13
- type ReviewSession,
13
+ type Thread,
14
14
  } from "@cueloop/schema";
15
15
  import type { Theme } from "../theme";
16
16
  import type { QuickAction } from "../config";
@@ -55,7 +55,7 @@ function welcomeCopy(version: string): string {
55
55
 
56
56
  /** An ephemeral thread that holds nothing but the playground's notes, so the surface groups
57
57
  * discussions exactly as a real review does without any of it reaching the daemon. */
58
- function playgroundThread(annotations: Annotation[]): ReviewSession {
58
+ function playgroundThread(annotations: Annotation[]): Thread {
59
59
  return {
60
60
  schemaVersion: SCHEMA_VERSION,
61
61
  id: "welcome",
@@ -32,7 +32,7 @@ exports[`stories catalog Chrome/AnnotationCards/SlashPaletteOpen renders, snapsh
32
32
  /out-of-scope This is out of scope for the task, so capture it as a fo
33
33
  /let-s-chat-about-this Do not implement yet, and surface the open questions and
34
34
  /prototype-this Build a throwaway prototype to answer the question, skip
35
- (2/7)
35
+ (2/8)
36
36
 
37
37
 
38
38
  "
@@ -1726,11 +1726,11 @@ exports[`stories catalog Chrome/QuickActionsEditor/Collapsed renders, snapshots,
1726
1726
  Characterize the current behavior with test…
1727
1727
  ▸ Consider existing repo patterns
1728
1728
  Follow the nearest existing pattern in this…
1729
+ ▸ LGTM
1730
+ This looks good to me.
1729
1731
  + Add action
1730
1732
 
1731
1733
 
1732
-
1733
-
1734
1734
  "
1735
1735
  `;
1736
1736
 
@@ -1741,7 +1741,7 @@ exports[`stories catalog Chrome/QuickActionsEditor/RowExpanded renders, snapshot
1741
1741
  ▸ Zoom out, research in depth
1742
1742
  Research the broader problem, prior art, an…
1743
1743
  ▾ Restate simplified
1744
- > Restate this as the simplest thing that works an
1744
+ > Restate this as the simplest thing that works
1745
1745
  ▸ Out of scope
1746
1746
  This is out of scope for the task, so captu…
1747
1747
  ▸ Let's chat about this
@@ -1752,11 +1752,11 @@ exports[`stories catalog Chrome/QuickActionsEditor/RowExpanded renders, snapshot
1752
1752
  Characterize the current behavior with test…
1753
1753
  ▸ Consider existing repo patterns
1754
1754
  Follow the nearest existing pattern in this…
1755
+ ▸ LGTM
1756
+ This looks good to me.
1755
1757
  + Add action
1756
1758
 
1757
1759
 
1758
-
1759
-
1760
1760
  "
1761
1761
  `;
1762
1762
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import React, { useEffect, useRef, useState } from "react";
9
- import type { ReviewSession } from "@cueloop/schema";
9
+ import type { Thread } from "@cueloop/schema";
10
10
  import type { Theme } from "../theme";
11
11
  import { useComponentTheme } from "./theme-context";
12
12
  import { FRAME_BORDER_STYLE } from "./primitives/frame";
@@ -51,7 +51,7 @@ export function planHandoffBriefing(sessionId: string): string {
51
51
 
52
52
  /** Props for the Agent tab body: the session under review plus the launch callbacks. */
53
53
  export interface AgentLauncherProps {
54
- session: ReviewSession;
54
+ session: Thread;
55
55
  /**
56
56
  * Fallback launch when no embedded terminal ships for this platform: run the
57
57
  * harness in a herdr split. seedText is the plan-context briefing.
@@ -0,0 +1,49 @@
1
+ /** The Actions editor scrolls to keep the selected row in view when the list outgrows the pane. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import React from "react";
5
+ import { testRender } from "@opentui/react/test-utils";
6
+ import type { QuickAction } from "../config";
7
+ import { settle } from "../test-support";
8
+ import { QuickActionsEditor } from "./quick-actions-editor";
9
+
10
+ const MANY: QuickAction[] = Array.from({ length: 14 }, (_, index) => ({
11
+ prompt: `Action number ${index}`,
12
+ }));
13
+
14
+ async function mount(selectedIndex: number) {
15
+ const setup = await testRender(
16
+ <QuickActionsEditor
17
+ actions={MANY}
18
+ selectedIndex={selectedIndex}
19
+ expandedIndex={null}
20
+ onToggleExpand={() => {}}
21
+ onEditMetadata={() => {}}
22
+ onReset={() => {}}
23
+ onAdd={() => {}}
24
+ />,
25
+ { width: 60, height: 10 },
26
+ );
27
+
28
+ await settle(setup);
29
+ await settle(setup);
30
+ await settle(setup);
31
+ await settle(setup);
32
+
33
+ return setup;
34
+ }
35
+
36
+ describe("QuickActionsEditor scrolling", () => {
37
+ test("a list too long for the pane clips into the scroll area instead of overlapping rows", async () => {
38
+ const setup = await mount(0);
39
+ const lines = setup.captureCharFrame().split("\n");
40
+
41
+ // the top rows read cleanly on their own lines - the overflow no longer paints rows on top of each other
42
+ expect(lines.some((line) => line.includes("Action number 0"))).toBe(true);
43
+ expect(lines.some((line) => line.includes("Action number 1"))).toBe(true);
44
+ // a bottom row is scrolled out of the clipped pane, not overlaid onto a visible one
45
+ expect(setup.captureCharFrame()).not.toContain("Action number 13");
46
+
47
+ setup.renderer.destroy();
48
+ });
49
+ });
@@ -6,10 +6,11 @@
6
6
  * the app owns the actions, the selection, and which row is expanded.
7
7
  */
8
8
 
9
- import React, { useRef } from "react";
10
- import type { KeyBinding, TextareaRenderable } from "@opentui/core";
9
+ import React, { useEffect, useRef } from "react";
10
+ import type { KeyBinding, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core";
11
11
  import type { QuickAction } from "../config";
12
12
  import type { Theme } from "../theme";
13
+ import { ScrollArea } from "./ScrollArea";
13
14
  import { useComponentTheme } from "./theme-context";
14
15
 
15
16
  // ⏎ never inserts a newline: a system prompt is one line, esc closes the editor.
@@ -40,6 +41,11 @@ export function QuickActionsEditor({
40
41
  theme,
41
42
  }: QuickActionsEditorProps): React.ReactNode {
42
43
  const tokens = useComponentTheme(theme);
44
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
45
+
46
+ useEffect(() => {
47
+ scrollRef.current?.scrollChildIntoView(`action-row-${selectedIndex}`);
48
+ }, [selectedIndex]);
43
49
 
44
50
  return (
45
51
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
@@ -52,28 +58,33 @@ export function QuickActionsEditor({
52
58
  </box>
53
59
  </box>
54
60
  <box style={{ height: 1 }} />
55
- {actions.map((action, index) => (
56
- <ActionRow
57
- key={index}
58
- action={action}
59
- isSelected={index === selectedIndex}
60
- isExpanded={index === expandedIndex}
61
- onToggleExpand={() => onToggleExpand(index)}
62
- onEditMetadata={(metadata) => onEditMetadata(index, metadata)}
63
- theme={theme}
64
- />
65
- ))}
66
- <box
67
- style={{ backgroundColor: selectedIndex === actions.length ? tokens.border : undefined }}
68
- onMouseUp={onAdd}
69
- >
70
- <text fg={tokens.textMuted}>+ Add action</text>
71
- </box>
61
+ <ScrollArea scrollRef={scrollRef}>
62
+ {actions.map((action, index) => (
63
+ <ActionRow
64
+ key={index}
65
+ rowId={`action-row-${index}`}
66
+ action={action}
67
+ isSelected={index === selectedIndex}
68
+ isExpanded={index === expandedIndex}
69
+ onToggleExpand={() => onToggleExpand(index)}
70
+ onEditMetadata={(metadata) => onEditMetadata(index, metadata)}
71
+ theme={theme}
72
+ />
73
+ ))}
74
+ <box
75
+ id={`action-row-${actions.length}`}
76
+ style={{ backgroundColor: selectedIndex === actions.length ? tokens.border : undefined }}
77
+ onMouseUp={onAdd}
78
+ >
79
+ <text fg={tokens.textMuted}>+ Add action</text>
80
+ </box>
81
+ </ScrollArea>
72
82
  </box>
73
83
  );
74
84
  }
75
85
 
76
86
  function ActionRow({
87
+ rowId,
77
88
  action,
78
89
  isSelected,
79
90
  isExpanded,
@@ -81,6 +92,7 @@ function ActionRow({
81
92
  onEditMetadata,
82
93
  theme,
83
94
  }: {
95
+ rowId: string;
84
96
  action: QuickAction;
85
97
  isSelected: boolean;
86
98
  isExpanded: boolean;
@@ -92,7 +104,7 @@ function ActionRow({
92
104
  const inputRef = useRef<TextareaRenderable | null>(null);
93
105
 
94
106
  return (
95
- <box style={{ flexDirection: "column" }}>
107
+ <box id={rowId} style={{ flexDirection: "column" }}>
96
108
  <box
97
109
  style={{ backgroundColor: isSelected ? tokens.border : undefined }}
98
110
  onMouseUp={onToggleExpand}
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import type { ReviewSession, WorkspaceKey } from "@cueloop/schema";
2
+ import type { Thread, WorkspaceKey } from "@cueloop/schema";
3
3
  import { groupInbox, projectName } from "./session-tree";
4
4
 
5
- function session(id: string, title: string, rootCommit?: string, remote?: string): ReviewSession {
5
+ function session(id: string, title: string, rootCommit?: string, remote?: string): Thread {
6
6
  const workspace: WorkspaceKey = { repoRoot: `/home/dev/${id}-checkout`, branch: "main" };
7
7
  if (rootCommit !== undefined) workspace.rootCommit = rootCommit;
8
8
  if (remote !== undefined) workspace.remote = remote;
@@ -6,7 +6,7 @@
6
6
  * walks, so the keyboard grammar keeps a single index across the two groups.
7
7
  */
8
8
 
9
- import type { ReviewSession, WorkspaceKey } from "@cueloop/schema";
9
+ import type { Thread, WorkspaceKey } from "@cueloop/schema";
10
10
 
11
11
  /** A repo's display name: the remote basename when present, else the working-tree folder name. */
12
12
  export function projectName(workspace: WorkspaceKey): string {
@@ -33,31 +33,31 @@ export function projectName(workspace: WorkspaceKey): string {
33
33
  export type InboxRow =
34
34
  | { kind: "section"; id: string; label: string }
35
35
  | { kind: "project"; id: string; label: string }
36
- | { kind: "thread"; id: string; session: ReviewSession; selectionIndex: number };
36
+ | { kind: "thread"; id: string; session: Thread; selectionIndex: number };
37
37
 
38
38
  export interface GroupedInbox {
39
39
  rows: InboxRow[];
40
40
  /** Threads in display order; the inbox cursor indexes this. */
41
- ordered: ReviewSession[];
41
+ ordered: Thread[];
42
42
  }
43
43
 
44
- function threadTitle(session: ReviewSession): string {
44
+ function threadTitle(session: Thread): string {
45
45
  return session.artifact.meta.title ?? session.id;
46
46
  }
47
47
 
48
48
  interface ProjectGroup {
49
49
  name: string;
50
- sessions: ReviewSession[];
50
+ sessions: Thread[];
51
51
  }
52
52
 
53
53
  /** Group pending sessions into Pinned, then Projects (by root commit), then standalone Threads. */
54
54
  export function groupInbox(
55
- sessions: readonly ReviewSession[],
55
+ sessions: readonly Thread[],
56
56
  pinnedIds?: ReadonlySet<string>,
57
57
  ): GroupedInbox {
58
- const pinned: ReviewSession[] = [];
58
+ const pinned: Thread[] = [];
59
59
  const projects = new Map<string, ProjectGroup>();
60
- const standalone: ReviewSession[] = [];
60
+ const standalone: Thread[] = [];
61
61
 
62
62
  for (const session of sessions) {
63
63
  if (pinnedIds?.has(session.id) === true) {
@@ -77,8 +77,8 @@ export function groupInbox(
77
77
  }
78
78
 
79
79
  const rows: InboxRow[] = [];
80
- const ordered: ReviewSession[] = [];
81
- const pushThread = (session: ReviewSession): void => {
80
+ const ordered: Thread[] = [];
81
+ const pushThread = (session: Thread): void => {
82
82
  rows.push({ kind: "thread", id: session.id, session, selectionIndex: ordered.length });
83
83
  ordered.push(session);
84
84
  };
@@ -1,6 +1,6 @@
1
1
  /** Shared fixture data for the stories catalog: one plan session, one diff. */
2
2
 
3
- import type { Annotation, ReviewSession } from "@cueloop/schema";
3
+ import type { Annotation, Thread } from "@cueloop/schema";
4
4
  import { buildDisplay, marksByDisplay } from "../view-plan";
5
5
  import { diffRows } from "../view-diff";
6
6
 
@@ -51,7 +51,7 @@ export const FIXTURE_ANNOTATIONS: Annotation[] = [
51
51
  },
52
52
  ];
53
53
 
54
- export function fixturePlanSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
54
+ export function fixturePlanSession(overrides: Partial<Thread> = {}): Thread {
55
55
  return {
56
56
  schemaVersion: "1",
57
57
  id: "s_story_plan",
@@ -70,7 +70,7 @@ export function fixturePlanSession(overrides: Partial<ReviewSession> = {}): Revi
70
70
  };
71
71
  }
72
72
 
73
- export function fixtureDiffSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
73
+ export function fixtureDiffSession(overrides: Partial<Thread> = {}): Thread {
74
74
  return {
75
75
  schemaVersion: "1",
76
76
  id: "s_story_diff",