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

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 (82) hide show
  1. package/package.json +7 -6
  2. package/src/App.appearance.test.tsx +8 -6
  3. package/src/App.diff.test.tsx +3 -3
  4. package/src/App.export.test.tsx +9 -3
  5. package/src/App.plan-review.test.tsx +10 -8
  6. package/src/App.prototype-markdown.test.tsx +92 -0
  7. package/src/App.prototype.test.tsx +17 -28
  8. package/src/App.readonly.test.tsx +3 -3
  9. package/src/App.rendering.test.tsx +3 -4
  10. package/src/App.test.tsx +14 -18
  11. package/src/App.tsx +117 -42
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/App.workbench.test.tsx +85 -14
  14. package/src/app-screens.tsx +26 -10
  15. package/src/app-view-model.ts +17 -3
  16. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  17. package/src/components/AnnotatableFileView.test.tsx +102 -0
  18. package/src/components/AnnotatableFileView.tsx +126 -0
  19. package/src/components/AnnotationCards.tsx +6 -6
  20. package/src/components/AppShell.tsx +62 -15
  21. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  22. package/src/components/BareWorkbenchFileView.tsx +69 -0
  23. package/src/components/ChangesColumn.tsx +3 -2
  24. package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
  25. package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
  26. package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
  27. package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
  28. package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
  29. package/src/components/DiscussionMarkerRail.tsx +2 -2
  30. package/src/components/EditorGrid.tabs.test.tsx +17 -4
  31. package/src/components/EditorGrid.tsx +49 -19
  32. package/src/components/FileContentsView.test.tsx +3 -3
  33. package/src/components/FileContentsView.tsx +37 -17
  34. package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
  35. package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
  36. package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
  37. package/src/components/ProjectTreeView.tsx +3 -2
  38. package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
  39. package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
  40. package/src/components/ScrollArea.stories.tsx +20 -0
  41. package/src/components/ScrollArea.tsx +44 -0
  42. package/src/components/SettingsDialog.tsx +3 -2
  43. package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
  44. package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
  45. package/src/components/ThreadsSidebar.tsx +5 -4
  46. package/src/components/TreePane.tsx +3 -2
  47. package/src/components/WelcomePlayground.stories.tsx +12 -0
  48. package/src/components/WelcomePlayground.test.tsx +47 -0
  49. package/src/components/WelcomePlayground.tsx +140 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
  51. package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
  52. package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
  53. package/src/config.test.ts +19 -6
  54. package/src/config.ts +26 -6
  55. package/src/intent-dispatch.test.ts +2 -0
  56. package/src/intent-dispatch.ts +1 -1
  57. package/src/key-bindings.test.ts +1 -1
  58. package/src/key-bindings.ts +5 -1
  59. package/src/keymap.ts +1 -1
  60. package/src/prototype-browser.ts +8 -0
  61. package/src/prototype-pixels.tsx +35 -0
  62. package/src/ready-signal.test.ts +85 -0
  63. package/src/ready-signal.ts +52 -0
  64. package/src/session-controller.live-diff.test.ts +198 -0
  65. package/src/session-controller.ts +141 -26
  66. package/src/slash-palette.test.ts +54 -0
  67. package/src/slash-palette.ts +32 -18
  68. package/src/test-support.ts +42 -3
  69. package/src/theme-presets.ts +20 -0
  70. package/src/theme.ts +18 -0
  71. package/src/thread-chords.ts +1 -1
  72. package/src/use-annotation-surface.tsx +12 -2
  73. package/src/use-settings-dialog.tsx +4 -4
  74. package/src/view-diff.test.ts +44 -1
  75. package/src/view-diff.ts +81 -0
  76. package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
  77. package/src/components/AnnotationCard.stories.tsx +0 -94
  78. package/src/components/AnnotationCard.tsx +0 -250
  79. package/src/components/MarkerPopover.stories.tsx +0 -58
  80. package/src/components/MarkerPopover.tsx +0 -109
  81. package/src/components/WelcomeSurface.stories.tsx +0 -12
  82. package/src/components/WelcomeSurface.tsx +0 -52
@@ -1,7 +1,9 @@
1
+ import { ScrollArea } from "./components/ScrollArea";
1
2
  import React, { useEffect, useState, type Dispatch, type SetStateAction } from "react";
2
3
  import type { DiffFileContents, ReviewSession, VerdictKind } from "@cueloop/schema";
3
4
  import { returnPaneFor } from "@cueloop/schema";
4
5
  import type { Theme } from "./theme";
6
+ import type { QuickAction } from "./config";
5
7
  import type { Mode, TreeAsk } from "./intent-dispatch";
6
8
  import type { Intent } from "./keymap";
7
9
  import type { ReviewController, ToastState } from "./session-controller";
@@ -15,13 +17,13 @@ import { ThemeProvider } from "./components/theme-context";
15
17
  import type { InboxRow } from "./components/session-tree";
16
18
  import { SettingsDialog } from "./components/SettingsDialog";
17
19
  import { CompletionOverlay } from "./components/CompletionOverlay";
18
- import { InboxList } from "./components/InboxList";
19
- import { WelcomeSurface } from "./components/WelcomeSurface";
20
+ import { ThreadTree } from "./components/ThreadTree";
21
+ import { WelcomePlayground } from "./components/WelcomePlayground";
20
22
  import { AppShell, type ProjectPanelMode } from "./components/AppShell";
21
23
  import { EditorGrid } from "./components/EditorGrid";
22
24
  import { ProjectTreeView } from "./components/ProjectTreeView";
23
25
  import { ChangesFileTree } from "./components/ChangesColumn";
24
- import { FileContentsView } from "./components/FileContentsView";
26
+ import { BareWorkbenchFileView } from "./components/BareWorkbenchFileView";
25
27
  import { useChangesWorkbench } from "./use-changes-workbench";
26
28
  import { ConfirmDialog } from "./components/ConfirmDialog";
27
29
  import { PromptDialog } from "./components/PromptDialog";
@@ -150,6 +152,10 @@ export function NoThreadShell(props: {
150
152
  pinnedIds: ReadonlySet<string>;
151
153
  onPin: (id: string) => void;
152
154
  onRename: (id: string, title: string) => void;
155
+ /** Drives the "/" quick actions and skills in the welcome playground's composer. */
156
+ quickActions: QuickAction[];
157
+ /** Reports the welcome composer's open state, so the shell suspends its inbox keys while typing. */
158
+ onWelcomeComposingChange: (composing: boolean) => void;
153
159
  }): React.ReactNode {
154
160
  const {
155
161
  rows,
@@ -165,6 +171,8 @@ export function NoThreadShell(props: {
165
171
  pinnedIds,
166
172
  onPin,
167
173
  onRename,
174
+ quickActions,
175
+ onWelcomeComposingChange,
168
176
  } = props;
169
177
  const confirming = mode.type === "confirmDelete" ? mode : null;
170
178
  // The bare-launch shell is the same four panes as a thread: the Thread pane waits in its empty state
@@ -179,8 +187,8 @@ export function NoThreadShell(props: {
179
187
  onToggleSidebar={onToggleSidebar}
180
188
  onOpenMenu={onOpenMenu}
181
189
  threadsPanel={
182
- <scrollbox style={{ flexGrow: 1 }} focused={false}>
183
- <InboxList
190
+ <ScrollArea>
191
+ <ThreadTree
184
192
  rows={rows}
185
193
  cursor={inboxCursor}
186
194
  pinnedIds={pinnedIds}
@@ -193,7 +201,7 @@ export function NoThreadShell(props: {
193
201
  onRename={onRename}
194
202
  theme={theme}
195
203
  />
196
- </scrollbox>
204
+ </ScrollArea>
197
205
  }
198
206
  threadTitle=""
199
207
  threadPanel={
@@ -205,7 +213,7 @@ export function NoThreadShell(props: {
205
213
  justifyContent: "center",
206
214
  }}
207
215
  >
208
- <text fg={theme.textDim}>Select a thread</text>
216
+ <text fg={theme.textDim}>{rows.length === 0 ? "no threads" : "Select a thread"}</text>
209
217
  </box>
210
218
  }
211
219
  changesOpen={workbench.changesOpen}
@@ -227,11 +235,19 @@ export function NoThreadShell(props: {
227
235
  zoomed={workbench.zoomed}
228
236
  renderTab={(tab) =>
229
237
  tab.kind === "welcome" ? (
230
- <WelcomeSurface version={CLIENT_VERSION} theme={theme} />
238
+ <WelcomePlayground
239
+ version={CLIENT_VERSION}
240
+ quickActions={quickActions}
241
+ onComposingChange={onWelcomeComposingChange}
242
+ theme={theme}
243
+ />
231
244
  ) : (
232
- <FileContentsView
245
+ <BareWorkbenchFileView
233
246
  path={tab.path ?? ""}
234
- loadContents={(path) => controller.repoReadFile(path)}
247
+ controller={controller}
248
+ quickActions={quickActions}
249
+ onComposingChange={onWelcomeComposingChange}
250
+ onExit={() => {}}
235
251
  theme={theme}
236
252
  />
237
253
  )
@@ -29,6 +29,19 @@ export function isWalking(isDiff: boolean, walk: { index: number } | null): bool
29
29
  return isDiff && walk !== null;
30
30
  }
31
31
 
32
+ /**
33
+ * A prototype renders as a kitty pixel mockup only in the opt-in experimental mode:
34
+ * the flag is on and the artifact carries an HTML entry. Otherwise it is the default
35
+ * markdown design doc, rendered through the thread/markdown path.
36
+ */
37
+ export function isPixelPrototypeMode(
38
+ isPrototype: boolean,
39
+ prototypePixels: boolean,
40
+ prototypePath: string | undefined,
41
+ ): boolean {
42
+ return isPrototype && prototypePixels && Boolean(prototypePath);
43
+ }
44
+
32
45
  export function resolveOverlay(
33
46
  mode: Mode,
34
47
  completionPhase: Completion["phase"],
@@ -92,19 +105,20 @@ export function buildRenderFlags(params: {
92
105
  session: ReviewSession;
93
106
  isOwner: boolean;
94
107
  isDiff: boolean;
95
- isPrototype: boolean;
108
+ isPixelPrototype: boolean;
96
109
  resolved: boolean;
97
110
  menuDialog: "keybinds" | "settings" | null;
98
111
  resolvedIds: Set<string>;
99
112
  }) {
100
- const { session, isOwner, isDiff, isPrototype, resolved, menuDialog, resolvedIds } = params;
113
+ const { session, isOwner, isDiff, isPixelPrototype, resolved, menuDialog, resolvedIds } = params;
101
114
 
102
115
  return {
103
116
  showOwnerActions: isOwner && !isDiff && !resolved,
104
117
  prototypeCanComment: isOwner && !resolved,
105
118
  chromeHidden: menuDialog !== null,
106
119
  prototypePath: session.artifact.meta.prototypePath ?? "",
107
- railResolvedIds: isDiff || isPrototype ? null : resolvedIds,
120
+ // a markdown prototype interleaves resolved cards like a plan; only the pixel mockup opts out
121
+ railResolvedIds: isDiff || isPixelPrototype ? null : resolvedIds,
108
122
  };
109
123
  }
110
124
 
@@ -0,0 +1,42 @@
1
+ import React from "react";
2
+ import { SCHEMA_VERSION, type ReviewSession } from "@cueloop/schema";
3
+ import { DARK } from "../theme";
4
+ import type { Story, StoryMeta } from "./story";
5
+ import { AnnotatableFileView } from "./AnnotatableFileView";
6
+
7
+ export const meta: StoryMeta = { title: "Surfaces/AnnotatableFileView" };
8
+
9
+ const SAMPLE = `export function add(a: number, b: number): number {\n return a + b;\n}\n`;
10
+
11
+ const SESSION: ReviewSession = {
12
+ schemaVersion: SCHEMA_VERSION,
13
+ id: "ses_story",
14
+ workspace: { repoRoot: "/repo", branch: "main" },
15
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
16
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00Z" }],
17
+ annotations: [],
18
+ verdict: null,
19
+ status: "pending",
20
+ createdAt: "2026-01-01T00:00:00Z",
21
+ };
22
+
23
+ const noop = (): void => {};
24
+
25
+ export const Loaded: Story = {
26
+ render: () => (
27
+ <AnnotatableFileView
28
+ path="src/add.ts"
29
+ loadContents={() => Promise.resolve(SAMPLE)}
30
+ session={SESSION}
31
+ quickActions={[]}
32
+ observer={false}
33
+ onAddComment={noop}
34
+ onReply={noop}
35
+ onUpdateAnnotation={noop}
36
+ onExit={noop}
37
+ theme={DARK}
38
+ />
39
+ ),
40
+ expectedColors: [DARK.textDim],
41
+ size: { width: 60, height: 12 },
42
+ };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * A project file is its own annotation surface: selecting a line and typing leaves a comment whose
3
+ * anchor quotes that file line, handed up as an Anchor the app persists with a file target.
4
+ */
5
+
6
+ import { describe, expect, mock, test } from "bun:test";
7
+ import { testRender } from "@opentui/react/test-utils";
8
+ import React from "react";
9
+ import { SCHEMA_VERSION, type Anchor, type ReviewSession } from "@cueloop/schema";
10
+ import { AnnotatableFileView } from "./AnnotatableFileView";
11
+ import { DARK } from "../theme";
12
+ import { dragText, typeText, pressKey, waitForText } from "../test-support";
13
+
14
+ const SAMPLE = "export function add(a: number, b: number) {\n return a + b;\n}\n";
15
+
16
+ function planSession(): ReviewSession {
17
+ return {
18
+ schemaVersion: SCHEMA_VERSION,
19
+ id: "ses_plan",
20
+ workspace: { repoRoot: "/repo", branch: "main" },
21
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
22
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00Z" }],
23
+ annotations: [],
24
+ verdict: null,
25
+ status: "pending",
26
+ createdAt: "2026-01-01T00:00:00Z",
27
+ };
28
+ }
29
+
30
+ const noop = (): void => {};
31
+
32
+ describe("AnnotatableFileView", () => {
33
+ test("commenting a file line hands up an anchor that quotes the line", async () => {
34
+ const onAddComment = mock((_anchor: Anchor, _body: string) => {});
35
+ const setup = await testRender(
36
+ <AnnotatableFileView
37
+ path="src/add.ts"
38
+ loadContents={() => Promise.resolve(SAMPLE)}
39
+ session={planSession()}
40
+ quickActions={[]}
41
+ observer={false}
42
+ onAddComment={onAddComment}
43
+ onReply={noop}
44
+ onUpdateAnnotation={noop}
45
+ onExit={noop}
46
+ theme={DARK}
47
+ />,
48
+ { width: 64, height: 12 },
49
+ );
50
+
51
+ // Act - select the middle line, type a comment, send it
52
+ await waitForText(setup, "return a + b");
53
+ await dragText(setup, "return a + b", "return a + b", "return a + b".length);
54
+ await typeText(setup, "why not reduce?");
55
+ await pressKey(setup, "RETURN", { meta: true });
56
+
57
+ // Assert - the surface built an anchor quoting the file line and handed it up with the body
58
+ expect(onAddComment).toHaveBeenCalled();
59
+ const [anchor, body] = onAddComment.mock.calls[0]!;
60
+ expect(anchor.quote).toContain("return a + b");
61
+ expect(body).toContain("why not reduce?");
62
+
63
+ setup.renderer.destroy();
64
+ });
65
+
66
+ test("a head-side (deleted-line) note never rebinds onto the current file", async () => {
67
+ const session = planSession();
68
+ // a note left on the removed side of this file: the worktree view must not show it
69
+ session.annotations = [
70
+ {
71
+ id: "h1",
72
+ kind: "comment",
73
+ anchor: { quote: "return a + b", prefix: "", suffix: "" },
74
+ target: { kind: "file", path: "src/add.ts", rev: "head" },
75
+ body: "deleted-side note",
76
+ createdAt: "2026-01-01T00:00:00Z",
77
+ },
78
+ ];
79
+ const setup = await testRender(
80
+ <AnnotatableFileView
81
+ path="src/add.ts"
82
+ loadContents={() => Promise.resolve(SAMPLE)}
83
+ session={session}
84
+ quickActions={[]}
85
+ observer={false}
86
+ onAddComment={noop}
87
+ onReply={noop}
88
+ onUpdateAnnotation={noop}
89
+ onExit={noop}
90
+ theme={DARK}
91
+ />,
92
+ { width: 64, height: 12 },
93
+ );
94
+
95
+ await waitForText(setup, "return a + b");
96
+
97
+ // Assert - the current file shows, but the head-side note is not attached to it
98
+ expect(setup.captureCharFrame()).not.toContain("deleted-side note");
99
+
100
+ setup.renderer.destroy();
101
+ });
102
+ });
@@ -0,0 +1,126 @@
1
+ // A project file shown as its own annotatable surface: the file's lines render through the diff
2
+ // sheet as context rows (one line-number gutter, no +/- sign), so selecting text leaves a comment
3
+ // anchored to the file exactly as a plan or diff comment is. Used where a review session exists to
4
+ // hold the notes; the bare-launch welcome shell uses the read-only FileContentsView instead.
5
+
6
+ import React, { useEffect, useRef, useState } from "react";
7
+ import { makeAnchor, annotationTarget, type Anchor, type ReviewSession } from "@cueloop/schema";
8
+ import type { Theme } from "../theme";
9
+ import type { QuickAction } from "../config";
10
+ import { useComponentTheme } from "./theme-context";
11
+ import { DiffContentView } from "./DiffContentView";
12
+ import { diffRowBlocks, fileContentsRows, marksByRows } from "../view-diff";
13
+
14
+ export interface AnnotatableFileViewProps {
15
+ path: string;
16
+ loadContents: (path: string) => Promise<string | null>;
17
+ session: ReviewSession;
18
+ quickActions: QuickAction[];
19
+ observer: boolean;
20
+ resolved?: boolean;
21
+ suspended?: boolean;
22
+ focusedAnnotationId?: string;
23
+ onFocusAnnotation?: (annotationId: string | undefined) => void;
24
+ onComposingChange?: (composing: boolean) => void;
25
+ onCursorChange?: (rowIndex: number) => void;
26
+ onObserverBlocked?: (reason: "observer" | "resolved") => void;
27
+ /** Persist a comment on this file; the view built the anchor against the file's own lines. */
28
+ onAddComment: (anchor: Anchor, body: string) => void;
29
+ onReply: (rootAnnotationId: string, body: string) => void;
30
+ onUpdateAnnotation: (id: string, body: string) => void;
31
+ onExit: () => void;
32
+ theme?: Theme;
33
+ }
34
+
35
+ /** A finished read for a specific path; `lines` is null when the file could not be read. */
36
+ interface FileLoad {
37
+ path: string;
38
+ lines: string[] | null;
39
+ }
40
+
41
+ export function AnnotatableFileView(props: AnnotatableFileViewProps): React.ReactNode {
42
+ const tokens = useComponentTheme(props.theme);
43
+ const [loaded, setLoaded] = useState<FileLoad | null>(null);
44
+ const loadRef = useRef(props.loadContents);
45
+ useEffect(() => {
46
+ loadRef.current = props.loadContents;
47
+ });
48
+
49
+ useEffect(() => {
50
+ let alive = true;
51
+ void loadRef.current(props.path).then(
52
+ (contents) => {
53
+ if (alive)
54
+ setLoaded({ path: props.path, lines: contents === null ? null : contents.split("\n") });
55
+ },
56
+ () => {
57
+ if (alive) setLoaded({ path: props.path, lines: null });
58
+ },
59
+ );
60
+
61
+ return () => {
62
+ alive = false;
63
+ };
64
+ }, [props.path]);
65
+
66
+ if (loaded === null || loaded.path !== props.path) {
67
+ return (
68
+ <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
69
+ <text fg={tokens.textDim}>loading...</text>
70
+ </box>
71
+ );
72
+ }
73
+ if (loaded.lines === null) {
74
+ return (
75
+ <box
76
+ style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", paddingBottom: 2 }}
77
+ >
78
+ <text fg={tokens.textDim}>File deleted</text>
79
+ </box>
80
+ );
81
+ }
82
+
83
+ const rows = fileContentsRows(props.path, loaded.lines.join("\n"));
84
+ // the file's worktree notes: these rows are the current file, so a head-side (deleted-line) note
85
+ // must stay orphaned here rather than rebind to similar surviving text
86
+ const fileNotes = props.session.annotations.filter((annotation) => {
87
+ const target = annotationTarget(annotation);
88
+
89
+ return target.kind === "file" && target.path === props.path && target.rev === "worktree";
90
+ });
91
+
92
+ return (
93
+ <DiffContentView
94
+ rows={rows}
95
+ session={props.session}
96
+ marks={marksByRows(fileNotes, rows, props.focusedAnnotationId)}
97
+ quickActions={props.quickActions}
98
+ observer={props.observer}
99
+ commentsEnabled
100
+ resolved={props.resolved}
101
+ suspended={props.suspended}
102
+ fileView
103
+ onComposingChange={props.onComposingChange}
104
+ onObserverBlocked={props.onObserverBlocked}
105
+ onCursorChange={props.onCursorChange}
106
+ focusedAnnotationId={props.focusedAnnotationId}
107
+ onFocusAnnotation={props.onFocusAnnotation}
108
+ onAnnotate={(span, body) =>
109
+ props.onAddComment(
110
+ makeAnchor(
111
+ diffRowBlocks(rows),
112
+ span.start.blockIndex,
113
+ span.start.char,
114
+ span.end.char,
115
+ span.end.blockIndex,
116
+ ),
117
+ body,
118
+ )
119
+ }
120
+ onReply={props.onReply}
121
+ onUpdateAnnotation={props.onUpdateAnnotation}
122
+ onExit={props.onExit}
123
+ theme={props.theme}
124
+ />
125
+ );
126
+ }
@@ -116,19 +116,19 @@ function SlashList({
116
116
 
117
117
  if (items.length === 0) return <text fg={tokens.textDim}>no matching actions</text>;
118
118
 
119
+ const nameColumn = Math.min(36, Math.max(16, ...visible.map((item) => item.name.length + 5)));
120
+
119
121
  return (
120
122
  <box style={{ flexDirection: "column" }}>
121
123
  {visible.map((item, offset) => {
122
124
  const index = start + offset;
123
125
  const isSelected = index === selected;
126
+ const name = `${isSelected ? "→ " : " "}/${item.name}`.padEnd(nameColumn);
124
127
 
125
128
  return (
126
- <text key={item.name} style={{ flexShrink: 1 }}>
127
- <span fg={isSelected ? tokens.accent : tokens.textDim}>{isSelected ? "→ " : " "}</span>
128
- <span fg={isSelected ? tokens.text : tokens.textMuted}>
129
- {`action:${item.name}`.padEnd(34)}
130
- </span>
131
- <span fg={tokens.textDim}>{item.description.slice(0, 52)}</span>
129
+ <text key={item.name} style={{ flexShrink: 0, wrapMode: "none" }}>
130
+ <span fg={isSelected ? tokens.accent : tokens.textMuted}>{name}</span>
131
+ <span fg={tokens.textDim}>{item.description}</span>
132
132
  </text>
133
133
  );
134
134
  })}
@@ -47,6 +47,34 @@ export interface AppShellProps {
47
47
  projectWidth?: number;
48
48
  }
49
49
 
50
+ /** The right region's reserved width: the open project pane, nothing when the region is closed, else the collapsed rail. */
51
+ function rightRegionColumns(
52
+ projectOpen: boolean,
53
+ projectWidth: number,
54
+ rightRegionClosed: boolean,
55
+ collapsedRailWidth: number,
56
+ ): number {
57
+ if (projectOpen) return projectWidth;
58
+
59
+ return rightRegionClosed ? 0 : collapsedRailWidth;
60
+ }
61
+
62
+ /** The Thread header's right cluster: the owner actions, plus the reopen control when the region is closed. */
63
+ function threadHeaderRight(
64
+ threadActions: React.ReactNode,
65
+ reopenControl: React.ReactNode,
66
+ showReopen: boolean,
67
+ ): React.ReactNode {
68
+ if (!showReopen) return threadActions;
69
+
70
+ return (
71
+ <box style={{ flexDirection: "row", alignItems: "center" }}>
72
+ {threadActions ? <box style={{ paddingRight: 2 }}>{threadActions}</box> : null}
73
+ {reopenControl}
74
+ </box>
75
+ );
76
+ }
77
+
50
78
  export function AppShell({
51
79
  sidebarOpen,
52
80
  onToggleSidebar,
@@ -72,15 +100,21 @@ export function AppShell({
72
100
  }: AppShellProps): React.ReactNode {
73
101
  const tokens = theme ?? DARK;
74
102
  const { width: terminalWidth } = useTerminalDimensions();
103
+ // with both panels closed the right region collapses to nothing: the reopen toggle
104
+ // rides the thread header, and the middle reclaims the width the rail would have taken
105
+ const rightRegionClosed = !projectOpen && !changesOpen;
75
106
  // Thread and Changes would otherwise split the middle as two flexBasis-0 items, and Yoga adds the
76
107
  // Changes left border on top of its share: at even widths the halves come out fractional, the
77
108
  // Changes side rounds up, and the row overflows a cell under the Project border. Sizing the Thread
78
109
  // pane to a whole number leaves Changes the lone flex item, which lays out exactly.
79
110
  const collapsedRailWidth = 4;
80
- const middleWidth =
81
- terminalWidth -
82
- (sidebarOpen ? threadsWidth : 0) -
83
- (projectOpen ? projectWidth : collapsedRailWidth);
111
+ const rightRegionWidth = rightRegionColumns(
112
+ projectOpen,
113
+ projectWidth,
114
+ rightRegionClosed,
115
+ collapsedRailWidth,
116
+ );
117
+ const middleWidth = terminalWidth - (sidebarOpen ? threadsWidth : 0) - rightRegionWidth;
84
118
  const threadPaneWidth =
85
119
  changesOpen && !zoomHideThread ? Math.max(20, Math.floor(middleWidth / 2)) : undefined;
86
120
 
@@ -127,6 +161,21 @@ export function AppShell({
127
161
  </box>
128
162
  );
129
163
 
164
+ // the reopen control - a divider tick then the toggle - shared by the collapsed
165
+ // rail and, when the rail is suppressed, the thread header
166
+ const reopenRightControl = (
167
+ <>
168
+ <text fg={tokens.border}>{"│"}</text>
169
+ <IconButton
170
+ glyph={NERD.sidebarRightOff}
171
+ onPress={onToggleRight}
172
+ tip="Toggle Right Sidebar"
173
+ marginLeft={1}
174
+ theme={tokens}
175
+ />
176
+ </>
177
+ );
178
+
130
179
  return (
131
180
  <box
132
181
  style={{
@@ -153,7 +202,12 @@ export function AppShell({
153
202
  {threadTitle ? <text fg={tokens.textDim}>{threadTitle}</text> : null}
154
203
  </box>
155
204
  }
156
- headerRight={threadActions}
205
+ // with the region closed, the reopen toggle rides the thread header instead of an empty column
206
+ headerRight={threadHeaderRight(
207
+ threadActions,
208
+ reopenRightControl,
209
+ rightRegionClosed,
210
+ )}
157
211
  theme={tokens}
158
212
  >
159
213
  {threadPanel}
@@ -186,8 +240,8 @@ export function AppShell({
186
240
  >
187
241
  {projectPanel}
188
242
  </PanelColumn>
189
- ) : (
190
- // collapsed: a divider tick above the continuous header underline holds the reopen toggle;
243
+ ) : rightRegionClosed ? null : (
244
+ // Changes open but Project closed: a divider tick above the continuous header underline holds the reopen toggle;
191
245
  // the rule lives in the header only, never running the pane's full height. The extra column
192
246
  // of right padding keeps the reopen icon off the terminal's last column, which squeezes it
193
247
  <box style={{ flexDirection: "column", width: 4 }}>
@@ -204,14 +258,7 @@ export function AppShell({
204
258
  borderColor: tokens.border,
205
259
  }}
206
260
  >
207
- <text fg={tokens.border}>{"│"}</text>
208
- <IconButton
209
- glyph={NERD.sidebarRightOff}
210
- onPress={onToggleRight}
211
- tip="Toggle Right Sidebar"
212
- marginLeft={1}
213
- theme={tokens}
214
- />
261
+ {reopenRightControl}
215
262
  </box>
216
263
  </box>
217
264
  )}
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { BareWorkbenchFileView, type WorkbenchCommenter } from "./BareWorkbenchFileView";
5
+
6
+ export const meta: StoryMeta = { title: "Surfaces/BareWorkbenchFileView" };
7
+
8
+ const SAMPLE = `export function add(a: number, b: number): number {\n return a + b;\n}\n`;
9
+
10
+ const controller: WorkbenchCommenter = {
11
+ repoReadFile: () => Promise.resolve(SAMPLE),
12
+ commentOnWorkbench: () => Promise.resolve(),
13
+ };
14
+
15
+ export const Loaded: Story = {
16
+ render: () => (
17
+ <BareWorkbenchFileView
18
+ path="src/add.ts"
19
+ controller={controller}
20
+ quickActions={[]}
21
+ onExit={() => {}}
22
+ theme={DARK}
23
+ />
24
+ ),
25
+ expectedColors: [DARK.textDim],
26
+ size: { width: 60, height: 12 },
27
+ };
@@ -0,0 +1,69 @@
1
+ // A project file in the bare launch shell: annotatable like any file, but no thread exists yet. The
2
+ // first comment find-or-creates the per-repo workbench thread (controller.commentOnWorkbench), which
3
+ // opens it - so this ephemeral view lives only until that first note promotes it to a real thread.
4
+
5
+ import React from "react";
6
+ import {
7
+ SCHEMA_VERSION,
8
+ type Anchor,
9
+ type AnnotationTarget,
10
+ type ReviewSession,
11
+ } from "@cueloop/schema";
12
+ import type { Theme } from "../theme";
13
+ import type { QuickAction } from "../config";
14
+ import { AnnotatableFileView } from "./AnnotatableFileView";
15
+
16
+ /** The controller slice this view needs: read a file, and route a comment to the workbench thread. */
17
+ export interface WorkbenchCommenter {
18
+ repoReadFile(path: string): Promise<string | null>;
19
+ commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void>;
20
+ }
21
+
22
+ /** An empty, unsaved thread: it holds no notes, just enough shape for the annotation surface. */
23
+ function draftThread(): ReviewSession {
24
+ return {
25
+ schemaVersion: SCHEMA_VERSION,
26
+ id: "workbench-draft",
27
+ workspace: { repoRoot: "", branch: "main" },
28
+ artifact: { type: "diff", content: "", meta: {} },
29
+ revisions: [],
30
+ annotations: [],
31
+ verdict: null,
32
+ status: "pending",
33
+ createdAt: "1970-01-01T00:00:00.000Z",
34
+ };
35
+ }
36
+
37
+ export interface BareWorkbenchFileViewProps {
38
+ path: string;
39
+ controller: WorkbenchCommenter;
40
+ quickActions: QuickAction[];
41
+ /** Reports whether the composer is open, so the shell suspends its inbox keys while typing. */
42
+ onComposingChange?: (composing: boolean) => void;
43
+ onExit: () => void;
44
+ theme?: Theme;
45
+ }
46
+
47
+ export function BareWorkbenchFileView(props: BareWorkbenchFileViewProps): React.ReactNode {
48
+ return (
49
+ <AnnotatableFileView
50
+ path={props.path}
51
+ loadContents={(path) => props.controller.repoReadFile(path)}
52
+ session={draftThread()}
53
+ quickActions={props.quickActions}
54
+ observer={false}
55
+ onComposingChange={props.onComposingChange}
56
+ onAddComment={(anchor: Anchor, body: string) =>
57
+ void props.controller.commentOnWorkbench(
58
+ anchor,
59
+ { kind: "file", path: props.path, rev: "worktree" },
60
+ body,
61
+ )
62
+ }
63
+ onReply={() => {}}
64
+ onUpdateAnnotation={() => {}}
65
+ onExit={props.onExit}
66
+ theme={props.theme}
67
+ />
68
+ );
69
+ }