@cueloop/client 0.1.0-alpha.72 → 0.1.0-alpha.74

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 (40) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +35 -170
  3. package/src/App.workbench.test.tsx +103 -2
  4. package/src/app-screens.tsx +84 -10
  5. package/src/components/AppShell.brand.test.tsx +58 -0
  6. package/src/components/AppShell.tsx +32 -6
  7. package/src/components/BareWorkbenchFileView.tsx +1 -1
  8. package/src/components/ConfirmCard.tsx +0 -1
  9. package/src/components/GridTabContent.stories.tsx +59 -0
  10. package/src/components/GridTabContent.tsx +165 -0
  11. package/src/components/SettingsDialog.tsx +4 -13
  12. package/src/components/ThreadTree.tsx +6 -18
  13. package/src/components/__snapshots__/stories.test.tsx.snap +112 -68
  14. package/src/components/primitives/Card.tsx +1 -1
  15. package/src/components/primitives/frame.test.ts +5 -5
  16. package/src/components/primitives/frame.ts +5 -7
  17. package/src/components/quick-actions-editor.stories.tsx +2 -0
  18. package/src/components/quick-actions-editor.test.tsx +36 -2
  19. package/src/components/quick-actions-editor.tsx +104 -29
  20. package/src/components/truncate-title.test.ts +31 -0
  21. package/src/components/truncate-title.ts +15 -0
  22. package/src/config.test.ts +36 -0
  23. package/src/config.ts +38 -8
  24. package/src/index.ts +2 -0
  25. package/src/intent-dispatch.test.ts +1 -0
  26. package/src/launch-layout.test.ts +52 -0
  27. package/src/launch-layout.ts +42 -0
  28. package/src/run.ts +10 -0
  29. package/src/serve.ts +31 -0
  30. package/src/thread-controller.live-diff.test.ts +119 -0
  31. package/src/thread-controller.ts +88 -27
  32. package/src/use-changes-workbench.ts +41 -4
  33. package/src/use-remember-layout.ts +31 -0
  34. package/src/use-settings-dialog.tsx +11 -2
  35. package/src/view-diff.test.ts +73 -2
  36. package/src/view-diff.ts +38 -0
  37. package/src/workbench-snapshot.test.ts +81 -0
  38. package/src/workbench-snapshot.ts +29 -0
  39. package/src/components/fade-title.test.ts +0 -49
  40. package/src/components/fade-title.ts +0 -61
@@ -5,9 +5,12 @@
5
5
  // always present when the region is on, Changes rides on top of it, and a thin rail holds the sidebar
6
6
  // toggle when the region is closed. Each pane owns its own header controls; the thread header never does.
7
7
 
8
- import React from "react";
8
+ import React, { useRef } from "react";
9
9
  import { useTerminalDimensions } from "@opentui/react";
10
+ import type { BoxRenderable } from "@opentui/core";
10
11
  import { DARK, type Theme } from "../theme";
12
+ import { useFrameMeasure } from "../use-frame-measure";
13
+ import { truncateTitle } from "./truncate-title";
11
14
  import { PanelColumn } from "./PanelColumn";
12
15
  import { IconButton } from "./primitives/IconButton";
13
16
  import { NERD, HEADER_UNDERLINE_CHARS } from "./primitives/icons";
@@ -16,6 +19,25 @@ import { RootOverlayProvider } from "./RootOverlay";
16
19
 
17
20
  export type ProjectPanelMode = "changes" | "tree";
18
21
 
22
+ /** The thread title on one line: it shrinks with the header and tails off in an ellipsis rather than wrapping. */
23
+ function HeaderTitle({ title, color }: { title: string; color: string }): React.ReactNode {
24
+ const boxRef = useRef<BoxRenderable | null>(null);
25
+ const width = useFrameMeasure(
26
+ () => boxRef.current?.width ?? 0,
27
+ (left, right) => left === right,
28
+ 0,
29
+ );
30
+ const clipped = width > 0 ? truncateTitle(title, width) : title;
31
+
32
+ return (
33
+ <box ref={boxRef} style={{ flexShrink: 1, minWidth: 0 }}>
34
+ <text fg={color} wrapMode="none">
35
+ {clipped}
36
+ </text>
37
+ </box>
38
+ );
39
+ }
40
+
19
41
  export interface AppShellProps {
20
42
  sidebarOpen: boolean;
21
43
  onToggleSidebar: () => void;
@@ -158,7 +180,7 @@ export function AppShell({
158
180
  <IconButton
159
181
  glyph={NERD.sidebarRight}
160
182
  onPress={onToggleRight}
161
- tip="Toggle Right Sidebar"
183
+ tip="Toggle Sidebar"
162
184
  theme={tokens}
163
185
  />
164
186
  </box>
@@ -172,7 +194,7 @@ export function AppShell({
172
194
  <IconButton
173
195
  glyph={NERD.sidebarRightOff}
174
196
  onPress={onToggleRight}
175
- tip="Toggle Right Sidebar"
197
+ tip="Toggle Sidebar"
176
198
  marginLeft={1}
177
199
  theme={tokens}
178
200
  />
@@ -200,9 +222,13 @@ export function AppShell({
200
222
  <PanelColumn
201
223
  width={threadPaneWidth}
202
224
  header={
203
- <box style={{ flexDirection: "row" }}>
204
- {!sidebarOpen ? <box style={{ paddingRight: 2 }}>{brandChrome}</box> : null}
205
- {threadTitle ? <text fg={tokens.textDim}>{threadTitle}</text> : null}
225
+ <box style={{ flexDirection: "row", minWidth: 0 }}>
226
+ {!sidebarOpen ? (
227
+ <box style={{ paddingRight: 2, flexShrink: 0 }}>{brandChrome}</box>
228
+ ) : null}
229
+ {threadTitle ? (
230
+ <HeaderTitle title={threadTitle} color={tokens.textDim} />
231
+ ) : null}
206
232
  </box>
207
233
  }
208
234
  // with the region closed, the reopen toggle rides the thread header instead of an empty column
@@ -15,7 +15,7 @@ export interface WorkbenchCommenter {
15
15
  }
16
16
 
17
17
  /** An empty, unsaved thread: it holds no notes, just enough shape for the annotation surface. */
18
- function draftThread(): Thread {
18
+ export function draftThread(): Thread {
19
19
  return {
20
20
  schemaVersion: SCHEMA_VERSION,
21
21
  id: "workbench-draft",
@@ -98,7 +98,6 @@ export function ConfirmCard({
98
98
 
99
99
  return (
100
100
  <Card
101
- title=" send message "
102
101
  contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 2 : 0)}
103
102
  borderColor={tokens.text}
104
103
  backgroundColor={tokens.elevated}
@@ -0,0 +1,59 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import { DEFAULT_QUICK_ACTIONS } from "../config";
4
+ import type { Story, StoryMeta } from "./story";
5
+ import { GridTabContent, type DiffSurfaceProps } from "./GridTabContent";
6
+ import { fixtureDiffRows, fixtureDiffSession } from "./story-fixtures";
7
+ import { changesTab, fileTab } from "./editor-grid";
8
+
9
+ export const meta: StoryMeta = { title: "Surfaces/GridTabContent" };
10
+
11
+ const noop = (): void => {};
12
+
13
+ function surface(): DiffSurfaceProps {
14
+ return {
15
+ session: fixtureDiffSession({ annotations: [] }),
16
+ quickActions: DEFAULT_QUICK_ACTIONS,
17
+ observer: false,
18
+ onAnnotate: noop,
19
+ onReply: noop,
20
+ onUpdateAnnotation: noop,
21
+ onExit: noop,
22
+ };
23
+ }
24
+
25
+ /** The whole-diff Changes tab: every changed file in one scroll container. */
26
+ export const ChangesTab: Story = {
27
+ render: () => (
28
+ <GridTabContent
29
+ tab={changesTab()}
30
+ rows={fixtureDiffRows()}
31
+ surface={surface()}
32
+ rejectedRows={new Set()}
33
+ dimmed={false}
34
+ readFile={async () => null}
35
+ onAddFileComment={noop}
36
+ theme={DARK}
37
+ />
38
+ ),
39
+ expectedColors: [DARK.text],
40
+ size: { width: 80, height: 18 },
41
+ };
42
+
43
+ /** A single-file diff tab: that file's rows alone, indexed on their own. */
44
+ export const FileDiffTab: Story = {
45
+ render: () => (
46
+ <GridTabContent
47
+ tab={fileTab("store.ts", "src/store.ts", "diff")}
48
+ rows={fixtureDiffRows()}
49
+ surface={surface()}
50
+ rejectedRows={new Set()}
51
+ dimmed={false}
52
+ readFile={async () => null}
53
+ onAddFileComment={noop}
54
+ theme={DARK}
55
+ />
56
+ ),
57
+ expectedColors: [DARK.text],
58
+ size: { width: 80, height: 18 },
59
+ };
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The body an editor tab paints: the whole working-tree/artifact diff for the
3
+ * Changes tab, or a single file's diff (or read-only contents) for a file tab.
4
+ * Shared by the thread workbench and the bare-launch shell so both render a
5
+ * changed file the same way, with comments routed back through one surface.
6
+ */
7
+
8
+ import React, { useMemo } from "react";
9
+ import { type Anchor } from "@cueloop/schema";
10
+ import { dimmedTheme, type Theme } from "../theme";
11
+ import { changesMarks, type DiffRow } from "../view-diff";
12
+ import {
13
+ DiffContentView,
14
+ type DiffFoldControls,
15
+ type DiffContentViewProps,
16
+ } from "./DiffContentView";
17
+ import { AnnotatableFileView } from "./AnnotatableFileView";
18
+ import type { EditorTab } from "./editor-grid";
19
+
20
+ /** The inline-commenting props the diff sheet shares with the thread view, wired once by the app. */
21
+ export type DiffSurfaceProps = Pick<
22
+ DiffContentViewProps,
23
+ | "session"
24
+ | "quickActions"
25
+ | "observer"
26
+ | "commentsEnabled"
27
+ | "resolved"
28
+ | "suspended"
29
+ | "onComposingChange"
30
+ | "onObserverBlocked"
31
+ | "onCursorChange"
32
+ | "focusedAnnotationId"
33
+ | "onFocusAnnotation"
34
+ | "onAnnotate"
35
+ | "onReply"
36
+ | "onUpdateAnnotation"
37
+ | "onExit"
38
+ >;
39
+
40
+ /** The Changes tab body: the whole diff in one scroll container, or a bare hint when nothing changed. */
41
+ export function ChangesTabBody(props: {
42
+ rows: DiffRow[];
43
+ surface: DiffSurfaceProps;
44
+ rejectedRows: Set<number>;
45
+ fold?: DiffFoldControls;
46
+ fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
47
+ split?: boolean;
48
+ dimmed: boolean;
49
+ theme: Theme;
50
+ }): React.ReactNode {
51
+ const session = props.surface.session;
52
+ const marks = useMemo(
53
+ () => changesMarks(session, props.rows, props.surface.focusedAnnotationId),
54
+ [session, props.rows, props.surface.focusedAnnotationId],
55
+ );
56
+
57
+ if (props.rows.length === 0) {
58
+ return (
59
+ <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
60
+ <text fg={props.theme.textDim}>No changes</text>
61
+ </box>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <DiffContentView
67
+ rows={props.rows}
68
+ marks={marks}
69
+ {...props.surface}
70
+ rejectedRows={props.rejectedRows}
71
+ fold={props.fold}
72
+ fileStats={props.fileStats}
73
+ split={props.split}
74
+ theme={props.dimmed ? dimmedTheme(props.theme) : undefined}
75
+ />
76
+ );
77
+ }
78
+
79
+ /** An editor tab's body: the whole diff for the Changes tab, a file's diff or contents for a file tab. */
80
+ export function GridTabContent(props: {
81
+ tab: EditorTab;
82
+ rows: DiffRow[];
83
+ surface: DiffSurfaceProps;
84
+ rejectedRows: Set<number>;
85
+ fold?: DiffFoldControls;
86
+ fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
87
+ split?: boolean;
88
+ dimmed: boolean;
89
+ readFile: (path: string) => Promise<string | null>;
90
+ /** Persist a comment on a project file; the file view built the anchor against its own lines. */
91
+ onAddFileComment: (path: string, anchor: Anchor, body: string) => void;
92
+ theme: Theme;
93
+ }): React.ReactNode {
94
+ const { tab } = props;
95
+ if (tab.kind === "file" && tab.fileView === "contents" && tab.path !== undefined) {
96
+ const filePath = tab.path;
97
+
98
+ return (
99
+ <AnnotatableFileView
100
+ path={filePath}
101
+ loadContents={props.readFile}
102
+ session={props.surface.session}
103
+ quickActions={props.surface.quickActions}
104
+ observer={props.surface.observer}
105
+ resolved={props.surface.resolved}
106
+ suspended={props.surface.suspended}
107
+ focusedAnnotationId={props.surface.focusedAnnotationId}
108
+ onFocusAnnotation={props.surface.onFocusAnnotation}
109
+ onComposingChange={props.surface.onComposingChange}
110
+ onCursorChange={props.surface.onCursorChange}
111
+ onObserverBlocked={props.surface.onObserverBlocked}
112
+ onAddComment={(anchor, body) => props.onAddFileComment(filePath, anchor, body)}
113
+ onReply={props.surface.onReply}
114
+ onUpdateAnnotation={props.surface.onUpdateAnnotation}
115
+ onExit={props.surface.onExit}
116
+ theme={props.theme}
117
+ />
118
+ );
119
+ }
120
+ if (tab.kind !== "file") {
121
+ return (
122
+ <ChangesTabBody
123
+ rows={props.rows}
124
+ surface={props.surface}
125
+ rejectedRows={props.rejectedRows}
126
+ fold={props.fold}
127
+ fileStats={props.fileStats}
128
+ split={props.split}
129
+ dimmed={props.dimmed}
130
+ theme={props.theme}
131
+ />
132
+ );
133
+ }
134
+ // a single-file tab shows that file's rows alone, so its row indices are its own: comments and
135
+ // the caret report back in whole-diff indices, and the fold controls (a band to fold) do not apply
136
+ const fileRowIndices = props.rows.flatMap((row, index) => (row.file === tab.path ? [index] : []));
137
+ const rows = fileRowIndices.map((index) => props.rows[index]!);
138
+ const wholeIndex = (rowIndex: number): number => fileRowIndices[rowIndex] ?? rowIndex;
139
+ const rejectedRows = new Set(
140
+ fileRowIndices.flatMap((index, rowIndex) => (props.rejectedRows.has(index) ? [rowIndex] : [])),
141
+ );
142
+
143
+ return (
144
+ <ChangesTabBody
145
+ rows={rows}
146
+ surface={{
147
+ ...props.surface,
148
+ onCursorChange: (rowIndex) => props.surface.onCursorChange?.(wholeIndex(rowIndex)),
149
+ onAnnotate: (span, body) =>
150
+ props.surface.onAnnotate(
151
+ {
152
+ start: { blockIndex: wholeIndex(span.start.blockIndex), char: span.start.char },
153
+ end: { blockIndex: wholeIndex(span.end.blockIndex), char: span.end.char },
154
+ },
155
+ body,
156
+ ),
157
+ }}
158
+ rejectedRows={rejectedRows}
159
+ fileStats={props.fileStats}
160
+ split={props.split}
161
+ dimmed={props.dimmed}
162
+ theme={props.theme}
163
+ />
164
+ );
165
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The settings overlay: a centered Dialog whose left nav is a tree - a Settings
3
- * group over its categories, plus Keybinds as a sibling leaf - and whose right
2
+ * The settings overlay: a centered Dialog whose left nav lists the categories as
3
+ * first-level leaves (General, Appearance, Actions, Keybinds), and whose right
4
4
  * body shows the active category's typed rows or the keybinds cheatsheet. The
5
5
  * component is controlled: the caller owns the open flag, the active
6
6
  * category/row/zone, and value changes, so the app's keyboard grammar (and the
@@ -48,17 +48,8 @@ export interface SettingsDialogProps {
48
48
  theme?: Theme;
49
49
  }
50
50
 
51
- /** The left nav tree: a Settings group over its categories, then Keybinds as a leaf. */
52
51
  function navTree(categories: SettingsCategory[]): TreeNode[] {
53
- const children = categories
54
- .filter((category) => category.id !== KEYBINDS_CATEGORY_ID)
55
- .map((category) => ({ id: category.id, label: category.name }));
56
- const nodes: TreeNode[] = [{ id: "settings", label: "Settings", children }];
57
- const keybinds = categories.find((category) => category.id === KEYBINDS_CATEGORY_ID);
58
-
59
- if (keybinds !== undefined) nodes.push({ id: keybinds.id, label: keybinds.name });
60
-
61
- return nodes;
52
+ return categories.map((category) => ({ id: category.id, label: category.name }));
62
53
  }
63
54
 
64
55
  function KeybindsBody({
@@ -122,7 +113,7 @@ export function SettingsDialog({
122
113
  <box style={{ flexDirection: "column", width: 20, paddingLeft: 1, paddingRight: 1 }}>
123
114
  <Tree
124
115
  nodes={navTree(categories)}
125
- expandedIds={new Set(["settings"])}
116
+ expandedIds={new Set<string>()}
126
117
  selectedId={category.id}
127
118
  hideIcons
128
119
  onSelect={onCategorySelect}
@@ -2,8 +2,8 @@
2
2
  * The sidebar thread tree: a Pinned / Projects / Threads listing. Selection stays
3
3
  * with the keyboard grammar - the cursor indexes the flat thread order and this
4
4
  * component renders the snapshot. A hovered or selected thread reveals a kebab
5
- * that opens an inline pin / rename / delete menu; long titles fade on the right
6
- * instead of wrapping. App supplies the surrounding chrome.
5
+ * that opens an inline pin / rename / delete menu; a long title clips to an
6
+ * ellipsis rather than wrapping. App supplies the surrounding chrome.
7
7
  */
8
8
 
9
9
  import React, { useState } from "react";
@@ -11,7 +11,7 @@ import type { Theme } from "../theme";
11
11
  import { useComponentTheme } from "./theme-context";
12
12
  import { IconButton } from "./primitives/IconButton";
13
13
  import { NERD } from "./primitives/icons";
14
- import { fadeTitle } from "./fade-title";
14
+ import { truncateTitle } from "./truncate-title";
15
15
  import { threadTitle, type InboxRow } from "./session-tree";
16
16
 
17
17
  export interface ThreadTreeProps {
@@ -94,15 +94,7 @@ interface ThreadRowProps {
94
94
  function ThreadRow(props: ThreadRowProps): React.ReactNode {
95
95
  const { title, selected, pinned, titleWidth, menuOpen, onToggleMenu, tokens, theme } = props;
96
96
  const [hovered, setHovered] = useState(false);
97
- // the tail fades toward the row's own background, so it masks out instead of
98
- // leaving a colored band over the selected (elevated) or hovered (panel) row
99
- const rowBackground = selected ? tokens.elevated : hovered ? tokens.panel : tokens.background;
100
- const segments = fadeTitle(
101
- title,
102
- titleWidth,
103
- selected ? tokens.accent : tokens.textMuted,
104
- rowBackground,
105
- );
97
+ const clippedTitle = truncateTitle(title, titleWidth);
106
98
  const hasActions =
107
99
  props.onPin !== undefined || props.onRename !== undefined || props.onDelete !== undefined;
108
100
 
@@ -116,13 +108,9 @@ function ThreadRow(props: ThreadRowProps): React.ReactNode {
116
108
  backgroundColor: selected ? tokens.elevated : hovered ? tokens.panel : undefined,
117
109
  }}
118
110
  >
119
- <text>
111
+ <text wrapMode="none">
120
112
  <span fg={tokens.textDim}>{pinned ? ` ${NERD.pin} ` : " "}</span>
121
- {segments.map((segment, index) => (
122
- <span key={index} fg={segment.fg}>
123
- {segment.text}
124
- </span>
125
- ))}
113
+ <span fg={selected ? tokens.accent : tokens.textMuted}>{clippedTitle}</span>
126
114
  </text>
127
115
  <box style={{ flexGrow: 1 }} />
128
116
  {(hovered || menuOpen) && hasActions ? (