@cueloop/client 0.1.0-alpha.67 → 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 (85) 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/ghostty-terminal.test.ts +2 -1
  56. package/src/ghostty-terminal.ts +27 -4
  57. package/src/intent-dispatch.test.ts +2 -0
  58. package/src/intent-dispatch.ts +1 -1
  59. package/src/key-bindings.test.ts +1 -1
  60. package/src/key-bindings.ts +5 -1
  61. package/src/keymap.ts +1 -1
  62. package/src/prototype-browser.ts +8 -0
  63. package/src/prototype-pixels.tsx +35 -0
  64. package/src/ready-signal.test.ts +85 -0
  65. package/src/ready-signal.ts +52 -0
  66. package/src/session-controller.live-diff.test.ts +198 -0
  67. package/src/session-controller.ts +141 -26
  68. package/src/slash-palette.test.ts +54 -0
  69. package/src/slash-palette.ts +32 -18
  70. package/src/test-support.test.ts +12 -0
  71. package/src/test-support.ts +69 -10
  72. package/src/theme-presets.ts +20 -0
  73. package/src/theme.ts +18 -0
  74. package/src/thread-chords.ts +1 -1
  75. package/src/use-annotation-surface.tsx +12 -2
  76. package/src/use-settings-dialog.tsx +4 -4
  77. package/src/view-diff.test.ts +44 -1
  78. package/src/view-diff.ts +81 -0
  79. package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
  80. package/src/components/AnnotationCard.stories.tsx +0 -94
  81. package/src/components/AnnotationCard.tsx +0 -250
  82. package/src/components/MarkerPopover.stories.tsx +0 -58
  83. package/src/components/MarkerPopover.tsx +0 -109
  84. package/src/components/WelcomeSurface.stories.tsx +0 -12
  85. package/src/components/WelcomeSurface.tsx +0 -52
@@ -18,12 +18,12 @@ test("renders the loaded file contents", async () => {
18
18
  await waitForText(setup, "const answer = 42;");
19
19
  });
20
20
 
21
- test("shows a not-found hint when the file cannot be read", async () => {
21
+ test("shows a centered File deleted message when the file cannot be read", async () => {
22
22
  const setup = await testRender(
23
23
  <FileContentsView path="missing.ts" loadContents={() => Promise.resolve(null)} theme={DARK} />,
24
24
  { width: 60, height: 10 },
25
25
  );
26
26
 
27
- await waitForText(setup, "could not read missing.ts");
28
- expect(setup.captureCharFrame()).toContain("could not read missing.ts");
27
+ await waitForText(setup, "File deleted");
28
+ expect(setup.captureCharFrame()).toContain("File deleted");
29
29
  });
@@ -1,9 +1,15 @@
1
- // A Changes file tab in contents mode: a workspace file rendered read-only with line numbers.
2
- // Loads on mount and whenever the path changes; a null read shows a "could not read" hint.
1
+ // A Changes/Project file tab in contents mode: a workspace file rendered read-only with line
2
+ // numbers, for surfaces with no review session to anchor notes to (the bare-launch welcome shell).
3
+ // Loads on mount and whenever the path changes; a null read renders a centered "File deleted".
4
+ // Syntax highlighting comes from the native code renderable (tree-sitter), the language
5
+ // auto-detected from the path; unknown languages simply render unstyled.
3
6
 
7
+ import { ScrollArea } from "./ScrollArea";
4
8
  import React, { useEffect, useRef, useState } from "react";
9
+ import type { CodeRenderable } from "@opentui/core";
5
10
  import type { Theme } from "../theme";
6
11
  import { useComponentTheme } from "./theme-context";
12
+ import { filetypeForPath, syntaxStyleFor } from "./syntax-highlight";
7
13
 
8
14
  export interface FileContentsViewProps {
9
15
  path: string;
@@ -24,6 +30,9 @@ export function FileContentsView({
24
30
  }: FileContentsViewProps): React.ReactNode {
25
31
  const tokens = useComponentTheme(theme);
26
32
  const [loaded, setLoaded] = useState<FileLoad | null>(null);
33
+ // The gutter mirrors the code renderable's line info, so it needs the mounted
34
+ // instance; a state-backed ref rebinds the target once the code renderable exists.
35
+ const [codeTarget, setCodeTarget] = useState<CodeRenderable | null>(null);
27
36
  const loadRef = useRef(loadContents);
28
37
  useEffect(() => {
29
38
  loadRef.current = loadContents;
@@ -53,26 +62,37 @@ export function FileContentsView({
53
62
  );
54
63
  }
55
64
  if (loaded.lines === null) {
65
+ // a file that no longer reads in the Changes/Project view is one the working tree deleted;
66
+ // the bottom pad lifts the text one row so it lines up with the footer-shortened thread empty state
56
67
  return (
57
- <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
58
- <text fg={tokens.textDim}>{`could not read ${path}`}</text>
68
+ <box
69
+ style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", paddingBottom: 2 }}
70
+ >
71
+ <text fg={tokens.textDim}>File deleted</text>
59
72
  </box>
60
73
  );
61
74
  }
62
- const lines = loaded.lines;
75
+ const content = loaded.lines.join("\n");
63
76
 
64
77
  return (
65
- <scrollbox style={{ flexGrow: 1 }} focused={false}>
66
- <box style={{ flexDirection: "column", paddingTop: 1 }}>
67
- {lines.map((line, index) => (
68
- <box key={index} style={{ flexDirection: "row", paddingLeft: 1 }}>
69
- <box style={{ width: 5 }}>
70
- <text fg={tokens.textDim}>{String(index + 1).padStart(4)}</text>
71
- </box>
72
- <text fg={tokens.text}>{line.length > 0 ? line : " "}</text>
73
- </box>
74
- ))}
75
- </box>
76
- </scrollbox>
78
+ <ScrollArea>
79
+ <line-number
80
+ target={codeTarget ?? undefined}
81
+ showLineNumbers
82
+ fg={tokens.textDim}
83
+ minWidth={5}
84
+ paddingRight={1}
85
+ style={{ paddingTop: 1, paddingLeft: 1 }}
86
+ >
87
+ <code
88
+ ref={setCodeTarget}
89
+ content={content}
90
+ filetype={filetypeForPath(path)}
91
+ syntaxStyle={syntaxStyleFor(tokens)}
92
+ selectable={false}
93
+ style={{ wrapMode: "none", fg: tokens.text }}
94
+ />
95
+ </line-number>
96
+ </ScrollArea>
77
97
  );
78
98
  }
@@ -1,10 +1,10 @@
1
1
  import React, { useRef } from "react";
2
2
  import type { ScrollBoxRenderable } from "@opentui/core";
3
3
  import { DARK } from "../theme";
4
- import { SurfaceScrollbar } from "./SurfaceScrollbar";
4
+ import { OverlayScrollbar } from "./OverlayScrollbar";
5
5
  import type { Story, StoryMeta } from "./story";
6
6
 
7
- export const meta: StoryMeta = { title: "Chrome/SurfaceScrollbar" };
7
+ export const meta: StoryMeta = { title: "Chrome/OverlayScrollbar" };
8
8
 
9
9
  function TallSurface(): React.ReactNode {
10
10
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
@@ -21,14 +21,13 @@ function TallSurface(): React.ReactNode {
21
21
  <text key={index} fg={DARK.textMuted}>{`row ${index + 1}`}</text>
22
22
  ))}
23
23
  </scrollbox>
24
- <SurfaceScrollbar scrollbox={scrollRef} />
24
+ <OverlayScrollbar scrollbox={scrollRef} />
25
25
  </box>
26
26
  );
27
27
  }
28
28
 
29
- // The mirrored bar: content three times the viewport puts a thumb a third as tall at the top.
30
- export const ThumbAtTop: Story = {
29
+ // At rest the overlay bar is hidden; the thumb only appears while the surface is scrolling.
30
+ export const AtRest: Story = {
31
31
  render: () => <TallSurface />,
32
- expectedColors: [DARK.textDim],
33
32
  size: { width: 24, height: 8 },
34
33
  };
@@ -6,16 +6,28 @@
6
6
 
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import { testRender } from "@opentui/react/test-utils";
9
+ import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
9
10
  import React from "react";
10
- import { DiffSheet } from "./DiffSheet";
11
+ import { DiffContentView } from "./DiffContentView";
11
12
  import { diffRows, marksByRows } from "../view-diff";
12
13
  import { allowEventLoopUpdates } from "../test-support";
13
14
  import { fixtureDiffSession } from "./story-fixtures";
14
15
  import { DARK } from "../theme";
15
- import { thumbGeometry } from "./SurfaceScrollbar";
16
+ import { thumbGeometry } from "./OverlayScrollbar";
16
17
 
17
18
  const noop = (): void => {};
18
19
 
20
+ function findById(node: Renderable, id: string): Renderable | undefined {
21
+ if (node.id === id) return node;
22
+ for (const child of node.getChildren()) {
23
+ const found = findById(child, id);
24
+
25
+ if (found) return found;
26
+ }
27
+
28
+ return undefined;
29
+ }
30
+
19
31
  function hex(color: { toInts(): [number, number, number, number] }): string {
20
32
  const [red, green, blue] = color.toInts();
21
33
 
@@ -69,7 +81,7 @@ describe("the diff sheet's scrollbar", () => {
69
81
  });
70
82
  const width = 60;
71
83
  const setup = await testRender(
72
- <DiffSheet
84
+ <DiffContentView
73
85
  rows={rows}
74
86
  session={session}
75
87
  marks={marksByRows(session.annotations, rows)}
@@ -85,17 +97,33 @@ describe("the diff sheet's scrollbar", () => {
85
97
 
86
98
  allowEventLoopUpdates();
87
99
  await setup.waitForVisualIdle();
100
+ // the overlay bar hides at rest; a scroll reveals the thumb
101
+ const scroller = findById(setup.renderer.root, "diff-scroll");
102
+
103
+ if (!(scroller instanceof ScrollBoxRenderable))
104
+ throw new Error("diff-scroll is not a scrollbox");
105
+ const countThumbRows = (): number =>
106
+ setup.captureSpans().lines.filter((line) => {
107
+ let column = 0;
108
+
109
+ for (const span of line.spans) {
110
+ column += span.text.length;
111
+ if (column >= width) return span.bg !== undefined && hex(span.bg) === DARK.textDim;
112
+ }
113
+
114
+ return false;
115
+ }).length;
116
+
117
+ // the overlay bar reveals on a scroll and hides after an idle delay; alternate the offset to
118
+ // re-trigger the reveal until the thumb is captured, so a slow CI settle never races the hide
119
+ let thumbRows = 0;
120
+
121
+ for (let attempt = 0; attempt < 20 && thumbRows === 0; attempt++) {
122
+ scroller.scrollTo(attempt % 2 === 0 ? 4 : 5);
123
+ await setup.waitForVisualIdle();
124
+ thumbRows = countThumbRows();
125
+ }
88
126
  const frame = setup.captureCharFrame().split("\n");
89
- const thumbRows = setup.captureSpans().lines.filter((line) => {
90
- let column = 0;
91
-
92
- for (const span of line.spans) {
93
- column += span.text.length;
94
- if (column >= width) return span.bg !== undefined && hex(span.bg) === DARK.textDim;
95
- }
96
-
97
- return false;
98
- }).length;
99
127
 
100
128
  // Assert - the thumb occupies the last column at the top (scroll offset 0), and the dot sits
101
129
  // to its left in the rail, so the order reads content, dots, scrollbar
@@ -1,29 +1,29 @@
1
1
  /**
2
2
  * A one-cell vertical scrollbar that mirrors a scrollbox's scroll state, so the bar can sit
3
3
  * where the layout wants it (the panel's rightmost column, past the discussion dots) instead
4
- * of inside the scrollbox's own viewport row. Reads the scroll position each frame, draws the
5
- * thumb, and a click or drag on the track scrolls the box.
4
+ * of inside the scrollbox's own viewport row. It is an overlay: the thumb appears while the
5
+ * surface is scrolling and hides once scrolling has been idle, so a resting surface shows none.
6
6
  */
7
7
 
8
- import React, { useRef, type RefObject } from "react";
8
+ import React, { useEffect, useRef, useState, type RefObject } from "react";
9
9
  import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
10
10
  import type { Theme } from "../theme";
11
11
  import { useFrameMeasure } from "../use-frame-measure";
12
12
  import { useComponentTheme } from "./theme-context";
13
13
 
14
+ const HIDE_DELAY_MS = 1500;
15
+
14
16
  interface ScrollState {
15
17
  top: number;
16
18
  height: number;
17
19
  viewport: number;
18
20
  }
19
21
 
20
- /** The thumb's row and height for a viewport of `track` rows over the scroll state. */
21
22
  export interface ThumbGeometry {
22
23
  row: number;
23
24
  height: number;
24
25
  }
25
26
 
26
- /** Where the thumb sits: proportional to the scroll offset, never shorter than one row, or null when nothing scrolls. */
27
27
  export function thumbGeometry(state: ScrollState, track: number): ThumbGeometry | null {
28
28
  if (track <= 0 || state.height <= state.viewport || state.viewport <= 0) return null;
29
29
  const height = Math.max(1, Math.round((state.viewport / state.height) * track));
@@ -33,14 +33,19 @@ export function thumbGeometry(state: ScrollState, track: number): ThumbGeometry
33
33
  return { row: Math.min(track - height, Math.max(0, row)), height };
34
34
  }
35
35
 
36
- export interface SurfaceScrollbarProps {
36
+ export interface OverlayScrollbarProps {
37
37
  scrollbox: RefObject<ScrollBoxRenderable | null>;
38
38
  theme?: Theme;
39
39
  }
40
40
 
41
- export function SurfaceScrollbar({ scrollbox, theme }: SurfaceScrollbarProps): React.ReactNode {
41
+ export function OverlayScrollbar({ scrollbox, theme }: OverlayScrollbarProps): React.ReactNode {
42
42
  const tokens = useComponentTheme(theme);
43
43
  const trackRef = useRef<BoxRenderable | null>(null);
44
+ const [visible, setVisible] = useState(false);
45
+ const draggingRef = useRef(false);
46
+ const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
47
+ const prevTopRef = useRef<number | null>(null);
48
+
44
49
  const state = useFrameMeasure<ScrollState>(
45
50
  () => ({
46
51
  top: scrollbox.current?.scrollTop ?? 0,
@@ -56,25 +61,56 @@ export function SurfaceScrollbar({ scrollbox, theme }: SurfaceScrollbarProps): R
56
61
  (left, right) => left === right,
57
62
  0,
58
63
  );
59
- const thumb = thumbGeometry(state, track);
60
64
 
61
- // a row on the track maps to the scroll offset that puts the thumb's top there
65
+ const reveal = (): void => {
66
+ setVisible(true);
67
+ if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
68
+ hideTimerRef.current = setTimeout(() => {
69
+ if (!draggingRef.current) setVisible(false);
70
+ }, HIDE_DELAY_MS);
71
+ };
72
+
73
+ // reveal on real scroll movement, never on first mount, then hide once idle
74
+ useEffect(() => {
75
+ if (prevTopRef.current !== null && state.top !== prevTopRef.current) reveal();
76
+ prevTopRef.current = state.top;
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, [state.top]);
79
+
80
+ useEffect(
81
+ () => () => {
82
+ if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
83
+ },
84
+ [],
85
+ );
86
+
87
+ const geometry = thumbGeometry(state, track);
88
+ const thumb = visible ? geometry : null;
89
+
62
90
  const scrollToTrackRow = (screenY: number): void => {
63
91
  const box = scrollbox.current;
64
92
 
65
- if (!box || !thumb) return;
66
- const row = screenY - (trackRef.current?.y ?? 0) - Math.floor(thumb.height / 2);
67
- const ratio = Math.min(1, Math.max(0, row / Math.max(1, track - thumb.height)));
93
+ if (!box || !geometry) return;
94
+ const row = screenY - (trackRef.current?.y ?? 0) - Math.floor(geometry.height / 2);
95
+ const ratio = Math.min(1, Math.max(0, row / Math.max(1, track - geometry.height)));
68
96
 
69
97
  box.scrollTo(Math.round(ratio * (state.height - state.viewport)));
98
+ reveal();
70
99
  };
71
100
 
72
101
  return (
73
102
  <box
74
103
  ref={trackRef}
75
104
  style={{ width: 1, flexShrink: 0, flexDirection: "column" }}
76
- onMouseDown={(event) => scrollToTrackRow(event.y)}
105
+ onMouseDown={(event) => {
106
+ draggingRef.current = true;
107
+ scrollToTrackRow(event.y);
108
+ }}
77
109
  onMouseDrag={(event) => scrollToTrackRow(event.y)}
110
+ onMouseUp={() => {
111
+ draggingRef.current = false;
112
+ reveal();
113
+ }}
78
114
  >
79
115
  {thumb ? (
80
116
  <box
@@ -2,6 +2,7 @@
2
2
  // Folders start collapsed and expand on click; a file click opens it as a tab. The caller keys this
3
3
  // on the session id so switching sessions remounts it and never shows the previous repository's paths.
4
4
 
5
+ import { ScrollArea } from "./ScrollArea";
5
6
  import React, { useEffect, useMemo, useRef, useState } from "react";
6
7
  import type { Theme } from "../theme";
7
8
  import { useComponentTheme } from "./theme-context";
@@ -60,7 +61,7 @@ export function ProjectTreeView({
60
61
  }
61
62
 
62
63
  return (
63
- <scrollbox style={{ flexGrow: 1 }} focused={false}>
64
+ <ScrollArea>
64
65
  <Tree
65
66
  nodes={nodes}
66
67
  expandedIds={expandedIds}
@@ -77,6 +78,6 @@ export function ProjectTreeView({
77
78
  }
78
79
  theme={theme}
79
80
  />
80
- </scrollbox>
81
+ </ScrollArea>
81
82
  );
82
83
  }
@@ -1,16 +1,15 @@
1
1
  import React from "react";
2
2
  import { DARK } from "../theme";
3
3
  import type { Story, StoryMeta } from "./story";
4
- import { PrototypeSheet } from "./PrototypeSheet";
4
+ import { PrototypeContentView } from "./PrototypeContentView";
5
5
 
6
- export const meta: StoryMeta = { title: "Surfaces/PrototypeSheet" };
6
+ export const meta: StoryMeta = { title: "Surfaces/PrototypeContentView" };
7
7
 
8
- // The story renderer reports no kitty graphics, so the sheet shows its fallback.
8
+ // The story renderer reports no kitty graphics, so the view shows its fallback.
9
9
  export const Unsupported: Story = {
10
10
  render: () => (
11
- <PrototypeSheet
11
+ <PrototypeContentView
12
12
  prototypePath="/tmp/example.html"
13
- quickActions={[]}
14
13
  canComment={true}
15
14
  onCommentElement={() => {}}
16
15
  />
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Prototype review surface: renders the HTML screenshot into a reserved cell
3
3
  * region via the kitty graphics protocol, and turns a click into a DOM-element
4
- * selection, so the shared marker actions bar and compose card annotate an
5
- * element rather than a text span. The image is painted directly (not through
4
+ * selection that opens the shared compose card - the same edit primitive plan
5
+ * and diff use - so a note anchors to an element rather than a text span. The
6
+ * image is painted directly (not through
6
7
  * OpenTUI's ImageRenderable, which does not display in every terminal): the
7
8
  * region's cells are never repainted, and the picture is re-placed after each
8
9
  * frame so it survives OpenTUI's own draws.
@@ -13,11 +14,9 @@ import type { BoxRenderable } from "@opentui/core";
13
14
  import * as v from "valibot";
14
15
  import { useKeyboard, useRenderer } from "@opentui/react";
15
16
  import type { Theme } from "../theme";
16
- import { quickActionBody, type QuickAction } from "../config";
17
17
  import { useComponentTheme } from "./theme-context";
18
- import { FRAME_BORDER_STYLE } from "./primitives/frame";
19
- import { MarkerPopover } from "./MarkerPopover";
20
- import { AnnotationCard } from "./AnnotationCard";
18
+ import { Composer, DiscussionCard } from "./AnnotationCards";
19
+ import { annotationPaletteFor } from "../annotation-palette";
21
20
  import {
22
21
  cssBoxToCell,
23
22
  imageCellToCss,
@@ -34,9 +33,8 @@ import {
34
33
  type CellRegion,
35
34
  } from "../kitty-image";
36
35
 
37
- export interface PrototypeSheetProps {
36
+ export interface PrototypeContentViewProps {
38
37
  prototypePath: string;
39
- quickActions: QuickAction[];
40
38
  canComment: boolean;
41
39
  onCommentElement: (element: PrototypeElement, body: string) => void;
42
40
  /** Signals when the inline compose owns the keyboard, so the app suppresses
@@ -50,7 +48,7 @@ export interface PrototypeSheetProps {
50
48
 
51
49
  type SheetStatus = "loading" | "ready" | "unsupported" | "error";
52
50
 
53
- const POPOVER_ROWS = 3;
51
+ const OVERLAY_ROWS = 3;
54
52
  /** Compose-card width, so the floating composer reads like the plan/diff one
55
53
  * instead of shrinking to its content. */
56
54
  const COMPOSE_COLS = 46;
@@ -123,16 +121,33 @@ function captureConfig(region: CellRegion, renderer: CaptureRenderer) {
123
121
  };
124
122
  }
125
123
 
126
- function PrototypeSheetImpl({
124
+ /**
125
+ * The terminal's scheme, read from its reading-text color: a dark terminal
126
+ * carries light text, a light terminal dark text. Passed to the renderer as
127
+ * prefers-color-scheme so a theme-aware mockup emerges into the right surface.
128
+ */
129
+ function terminalColorScheme(textColor: string): "dark" | "light" {
130
+ const hex = textColor.replace("#", "");
131
+
132
+ if (hex.length < 6) return "dark";
133
+ const r = parseInt(hex.slice(0, 2), 16);
134
+ const g = parseInt(hex.slice(2, 4), 16);
135
+ const b = parseInt(hex.slice(4, 6), 16);
136
+ const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
137
+
138
+ return luminance > 0.5 ? "dark" : "light";
139
+ }
140
+
141
+ function PrototypeContentViewImpl({
127
142
  prototypePath,
128
- quickActions,
129
143
  canComment,
130
144
  onCommentElement,
131
145
  onComposingChange,
132
146
  hidden = false,
133
147
  theme,
134
- }: PrototypeSheetProps): React.ReactNode {
148
+ }: PrototypeContentViewProps): React.ReactNode {
135
149
  const tokens = useComponentTheme(theme);
150
+ const palette = annotationPaletteFor(tokens);
136
151
  const renderer = useRenderer();
137
152
  const regionRef = useRef<BoxRenderable | null>(null);
138
153
  const browserRef = useRef<PrototypeRenderer | null>(null);
@@ -156,9 +171,7 @@ function PrototypeSheetImpl({
156
171
  top: number;
157
172
  regionColumns: number;
158
173
  } | null>(null);
159
- const [actionsOpen, setActionsOpen] = useState(false);
160
174
  const [composing, setComposing] = useState(false);
161
- const [draftText, setDraftText] = useState("");
162
175
 
163
176
  const setPng = (png: Uint8Array): void => {
164
177
  pngRef.current = png;
@@ -247,6 +260,7 @@ function PrototypeSheetImpl({
247
260
  filePath: prototypePath,
248
261
  viewport: capture.viewport,
249
262
  deviceScaleFactor: capture.deviceScaleFactor,
263
+ colorScheme: terminalColorScheme(tokens.text),
250
264
  });
251
265
 
252
266
  // the sheet may have unmounted while Chromium was launching; close the
@@ -279,9 +293,7 @@ function PrototypeSheetImpl({
279
293
  const clearSelection = (): void => {
280
294
  setSelected(null);
281
295
  setOverlayCell(null);
282
- setActionsOpen(false);
283
296
  setComposing(false);
284
- setDraftText("");
285
297
  };
286
298
 
287
299
  // While the compose textarea owns the keyboard, the global keymap is
@@ -315,11 +327,17 @@ function PrototypeSheetImpl({
315
327
  setSelected(element);
316
328
  setOverlayCell({
317
329
  left: Math.max(0, cell.column - geometry.x),
318
- top: Math.max(0, topRaw < POPOVER_ROWS ? topRaw + 1 : topRaw - POPOVER_ROWS),
330
+ top: Math.max(0, topRaw < OVERLAY_ROWS ? topRaw + 1 : topRaw - OVERLAY_ROWS),
319
331
  regionColumns: geometry.width,
320
332
  });
321
- setActionsOpen(false);
322
- setComposing(false);
333
+ // selecting an element opens the compose card directly, the same edit
334
+ // primitive plan and diff use - there is no intermediate marker toolbar
335
+ if (canComment) {
336
+ onComposingChange?.(true);
337
+ setComposing(true);
338
+ } else {
339
+ setComposing(false);
340
+ }
323
341
  })().catch((error) => {
324
342
  setErrorMessage(error instanceof Error ? error.message : String(error));
325
343
  setStatus("error");
@@ -346,8 +364,8 @@ function PrototypeSheetImpl({
346
364
  clearSelection();
347
365
  };
348
366
 
349
- // the overlay carries an opaque fill so the popover and compose card read as
350
- // solid cards over the image, matching the plan surface
367
+ // the overlay carries an opaque fill so the compose card reads as a solid
368
+ // card over the image, matching the plan surface
351
369
  const overlayStyle = {
352
370
  position: "absolute" as const,
353
371
  left: overlayCell?.left ?? 0,
@@ -370,9 +388,6 @@ function PrototypeSheetImpl({
370
388
  style={{
371
389
  flexGrow: 1,
372
390
  flexDirection: "column",
373
- border: true,
374
- borderStyle: FRAME_BORDER_STYLE,
375
- borderColor: tokens.text,
376
391
  }}
377
392
  >
378
393
  <box
@@ -389,40 +404,25 @@ function PrototypeSheetImpl({
389
404
  <text fg={tokens.textDim}>{statusLine(status, errorMessage)}</text>
390
405
  </box>
391
406
  ) : null}
392
- {selected && !composing ? (
393
- <box style={overlayStyle}>
394
- <MarkerPopover
395
- view={actionsOpen ? "actions" : "toolbar"}
396
- actions={quickActions}
397
- actionIndex={0}
398
- canCut={false}
399
- onComment={() => {
400
- if (!canComment) return;
401
- onComposingChange?.(true);
402
- setComposing(true);
403
- }}
404
- onCut={() => undefined}
405
- onOpenActions={() => setActionsOpen(true)}
406
- onClose={clearSelection}
407
- onPickAction={(index) => commit(quickActionBody(quickActions[index]!))}
408
- onBack={() => setActionsOpen(false)}
409
- theme={theme}
410
- />
411
- </box>
412
- ) : null}
413
407
  {selected && composing ? (
414
408
  <box style={composeStyle}>
415
- <AnnotationCard
416
- kind="comment"
417
- quote={selected.quote}
418
- draft={{
419
- text: draftText,
420
- onInput: setDraftText,
421
- onSave: () => commit(draftText),
422
- onSubmit: () => commit(draftText),
423
- onCancel: clearSelection,
424
- }}
425
- theme={theme}
409
+ <DiscussionCard
410
+ tokens={tokens}
411
+ segments={[
412
+ {
413
+ color: palette.cardEdge,
414
+ node: (
415
+ <Composer
416
+ seed=""
417
+ glyph="●"
418
+ tokens={tokens}
419
+ onSave={commit}
420
+ onReady={() => undefined}
421
+ onInput={() => undefined}
422
+ />
423
+ ),
424
+ },
425
+ ]}
426
426
  />
427
427
  </box>
428
428
  ) : null}
@@ -432,10 +432,10 @@ function PrototypeSheetImpl({
432
432
 
433
433
  /**
434
434
  * Memoized so an unrelated App re-render (status ticks, a rail-width drag) does
435
- * not re-render the sheet; the parent passes a stable onCommentElement so the
435
+ * not re-render the view; the parent passes a stable onCommentElement so the
436
436
  * shallow prop compare holds.
437
437
  */
438
- export const PrototypeSheet = React.memo(PrototypeSheetImpl);
438
+ export const PrototypeContentView = React.memo(PrototypeContentViewImpl);
439
439
 
440
440
  function statusLine(status: SheetStatus, errorMessage: string): string {
441
441
  if (status === "loading") return "rendering prototype…";
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import { ScrollArea } from "./ScrollArea";
4
+ import type { Story, StoryMeta } from "./story";
5
+
6
+ export const meta: StoryMeta = { title: "Chrome/ScrollArea" };
7
+
8
+ // Taller-than-viewport content; the overlay bar stays hidden until the surface scrolls.
9
+ export const AtRest: Story = {
10
+ render: () => (
11
+ <box style={{ width: 24, height: 8, backgroundColor: DARK.background }}>
12
+ <ScrollArea>
13
+ {Array.from({ length: 24 }, (_unused, index) => (
14
+ <text key={index} fg={DARK.textMuted}>{`row ${index + 1}`}</text>
15
+ ))}
16
+ </ScrollArea>
17
+ </box>
18
+ ),
19
+ size: { width: 24, height: 8 },
20
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A scrollbox whose bar follows the global rule: no always-on scrollbar. The built-in bar is
3
+ * hidden and an OverlayScrollbar rides the rightmost column, appearing only while scrolling.
4
+ * Pass scrollRef to keep scrollTo / scrollChildIntoView on the underlying ScrollBox.
5
+ */
6
+
7
+ import React, { useRef, type ReactNode, type RefObject } from "react";
8
+ import type { ScrollBoxRenderable } from "@opentui/core";
9
+ import type { Theme } from "../theme";
10
+ import { OverlayScrollbar } from "./OverlayScrollbar";
11
+
12
+ export interface ScrollAreaProps {
13
+ children: ReactNode;
14
+ focused?: boolean;
15
+ theme?: Theme;
16
+ scrollRef?: RefObject<ScrollBoxRenderable | null>;
17
+ }
18
+
19
+ export function ScrollArea({
20
+ children,
21
+ focused = false,
22
+ theme,
23
+ scrollRef,
24
+ }: ScrollAreaProps): React.ReactNode {
25
+ const innerRef = useRef<ScrollBoxRenderable | null>(null);
26
+ const attach = (node: ScrollBoxRenderable | null): void => {
27
+ innerRef.current = node;
28
+ if (scrollRef) scrollRef.current = node;
29
+ };
30
+
31
+ return (
32
+ <box style={{ flexGrow: 1, flexDirection: "row" }}>
33
+ <scrollbox
34
+ ref={attach}
35
+ style={{ flexGrow: 1 }}
36
+ focused={focused}
37
+ verticalScrollbarOptions={{ visible: false }}
38
+ >
39
+ {children}
40
+ </scrollbox>
41
+ <OverlayScrollbar scrollbox={innerRef} theme={theme} />
42
+ </box>
43
+ );
44
+ }