@cueloop/client 0.1.0-alpha.58 → 0.1.0-alpha.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.tsx +174 -574
- package/src/app-key-state.ts +60 -0
- package/src/app-screens.tsx +266 -0
- package/src/app-view-model.ts +286 -0
- package/src/components/PlanSheet.tsx +2 -62
- package/src/components/plan-sheet-run-style.ts +86 -0
- package/src/config.ts +61 -47
- package/src/intent-dispatch.ts +427 -307
- package/src/keymap.ts +81 -59
- package/src/use-settings-dialog.tsx +263 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
2
|
+
import type { Mode } from "./intent-dispatch";
|
|
3
|
+
import type { KeyState } from "./keymap";
|
|
4
|
+
import type { DisplayBlock } from "./view-plan";
|
|
5
|
+
import type { DiffRow } from "./view-diff";
|
|
6
|
+
import type { WalkFile } from "./walk";
|
|
7
|
+
|
|
8
|
+
export function buildKeyState(params: {
|
|
9
|
+
keys: Record<string, string[]>;
|
|
10
|
+
observer: boolean;
|
|
11
|
+
isOwner: boolean;
|
|
12
|
+
overlay: KeyState["overlay"];
|
|
13
|
+
session: ReviewSession | null;
|
|
14
|
+
isDiff: boolean;
|
|
15
|
+
mode: Mode;
|
|
16
|
+
resolved: boolean;
|
|
17
|
+
inbox: ReviewSession[] | null;
|
|
18
|
+
focusedAnnotationId: string | undefined;
|
|
19
|
+
walk: { index: number } | null;
|
|
20
|
+
walkFileList: WalkFile[];
|
|
21
|
+
rows: DiffRow[];
|
|
22
|
+
cursor: number;
|
|
23
|
+
display: DisplayBlock[];
|
|
24
|
+
}): KeyState {
|
|
25
|
+
const {
|
|
26
|
+
keys,
|
|
27
|
+
observer,
|
|
28
|
+
isOwner,
|
|
29
|
+
overlay,
|
|
30
|
+
session,
|
|
31
|
+
isDiff,
|
|
32
|
+
mode,
|
|
33
|
+
resolved,
|
|
34
|
+
inbox,
|
|
35
|
+
focusedAnnotationId,
|
|
36
|
+
walk,
|
|
37
|
+
walkFileList,
|
|
38
|
+
rows,
|
|
39
|
+
cursor,
|
|
40
|
+
display,
|
|
41
|
+
} = params;
|
|
42
|
+
return {
|
|
43
|
+
keys,
|
|
44
|
+
readOnly: observer,
|
|
45
|
+
canEditPlan: isOwner,
|
|
46
|
+
canSubmitVerdict: isOwner,
|
|
47
|
+
canShare: isOwner,
|
|
48
|
+
overlay,
|
|
49
|
+
view: !session ? "inbox" : isDiff ? "diff" : "plan",
|
|
50
|
+
spanMode: mode.type === "span",
|
|
51
|
+
resolved,
|
|
52
|
+
hasInboxItems: !!inbox?.length,
|
|
53
|
+
annotationCount: session?.annotations.length ?? 0,
|
|
54
|
+
hasFocusedAnnotation: focusedAnnotationId !== undefined,
|
|
55
|
+
walkAtEnd: walk !== null && walk.index >= walkFileList.length,
|
|
56
|
+
cursorAnnotatable: isDiff
|
|
57
|
+
? rows[cursor] !== undefined && rows[cursor]!.kind !== "file" && rows[cursor]!.kind !== "hunk"
|
|
58
|
+
: !!display[cursor]?.work,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import React, { type Dispatch, type SetStateAction } from "react";
|
|
2
|
+
import type { ReviewSession, VerdictKind } from "@cueloop/schema";
|
|
3
|
+
import { returnPaneFor } from "@cueloop/schema";
|
|
4
|
+
import type { Theme } from "./theme";
|
|
5
|
+
import type { Mode } from "./intent-dispatch";
|
|
6
|
+
import type { Intent } from "./keymap";
|
|
7
|
+
import type { ReviewController, ToastState } from "./session-controller";
|
|
8
|
+
import { noteForFile } from "./walk";
|
|
9
|
+
import type { WalkFile } from "./walk";
|
|
10
|
+
import type { CheatsheetSection } from "./key-bindings";
|
|
11
|
+
import type { SettingsCategory, SettingsValues } from "./components/SettingsDialog";
|
|
12
|
+
import type { SettingsNav } from "./use-settings-dialog";
|
|
13
|
+
import { CLIENT_VERSION } from "./version";
|
|
14
|
+
import { ThemeProvider } from "./components/theme-context";
|
|
15
|
+
import { MenuBar } from "./components/MenuBar";
|
|
16
|
+
import { KeybindsDialog } from "./components/KeybindsDialog";
|
|
17
|
+
import { SettingsDialog } from "./components/SettingsDialog";
|
|
18
|
+
import { CompletionOverlay } from "./components/CompletionOverlay";
|
|
19
|
+
import { InboxList } from "./components/InboxList";
|
|
20
|
+
import { ConfirmDialog } from "./components/ConfirmDialog";
|
|
21
|
+
import { PromptDialog } from "./components/PromptDialog";
|
|
22
|
+
import { WalkWizard } from "./components/WalkWizard";
|
|
23
|
+
import { Toast } from "./components/Toast";
|
|
24
|
+
|
|
25
|
+
export function ErrorScreen({ error, theme }: { error: string; theme: Theme }): React.ReactNode {
|
|
26
|
+
return (
|
|
27
|
+
<ThemeProvider theme={theme}>
|
|
28
|
+
<text fg={theme.red}>cueloop: {error}</text>
|
|
29
|
+
</ThemeProvider>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ConnectingScreen({ theme }: { theme: Theme }): React.ReactNode {
|
|
34
|
+
return (
|
|
35
|
+
<ThemeProvider theme={theme}>
|
|
36
|
+
<text fg={theme.textDim}>connecting to the daemon…</text>
|
|
37
|
+
</ThemeProvider>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function MenuChrome(props: {
|
|
42
|
+
menuOpen: boolean;
|
|
43
|
+
menuDialog: "keybinds" | "settings" | null;
|
|
44
|
+
status: string;
|
|
45
|
+
theme: Theme;
|
|
46
|
+
setMenuOpen: Dispatch<SetStateAction<boolean>>;
|
|
47
|
+
setMenuDialog: Dispatch<SetStateAction<"keybinds" | "settings" | null>>;
|
|
48
|
+
keybindsSections: CheatsheetSection[];
|
|
49
|
+
settingsCategories: SettingsCategory[];
|
|
50
|
+
settingsValues: SettingsValues;
|
|
51
|
+
settingsNav: SettingsNav;
|
|
52
|
+
onCategorySelect: (categoryId: string) => void;
|
|
53
|
+
cycleSetting: (rowKey: string) => void;
|
|
54
|
+
}): React.ReactNode {
|
|
55
|
+
const {
|
|
56
|
+
menuOpen,
|
|
57
|
+
menuDialog,
|
|
58
|
+
status,
|
|
59
|
+
theme,
|
|
60
|
+
setMenuOpen,
|
|
61
|
+
setMenuDialog,
|
|
62
|
+
keybindsSections,
|
|
63
|
+
settingsCategories,
|
|
64
|
+
settingsValues,
|
|
65
|
+
settingsNav,
|
|
66
|
+
onCategorySelect,
|
|
67
|
+
cycleSetting,
|
|
68
|
+
} = props;
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<MenuBar
|
|
72
|
+
open={menuOpen}
|
|
73
|
+
version={CLIENT_VERSION}
|
|
74
|
+
status={status}
|
|
75
|
+
onToggle={() => setMenuOpen((isOpen) => !isOpen)}
|
|
76
|
+
onSettings={() => {
|
|
77
|
+
setMenuOpen(false);
|
|
78
|
+
setMenuDialog("settings");
|
|
79
|
+
}}
|
|
80
|
+
onKeybinds={() => {
|
|
81
|
+
setMenuOpen(false);
|
|
82
|
+
setMenuDialog("keybinds");
|
|
83
|
+
}}
|
|
84
|
+
theme={theme}
|
|
85
|
+
/>
|
|
86
|
+
{menuDialog === "keybinds" ? (
|
|
87
|
+
<KeybindsDialog sections={keybindsSections} theme={theme} />
|
|
88
|
+
) : null}
|
|
89
|
+
{menuDialog === "settings" ? (
|
|
90
|
+
<SettingsDialog
|
|
91
|
+
isOpen
|
|
92
|
+
categories={settingsCategories}
|
|
93
|
+
values={settingsValues}
|
|
94
|
+
activeCategoryId={settingsNav.categoryId}
|
|
95
|
+
activeRowIndex={settingsNav.rowIndex}
|
|
96
|
+
activeZone={settingsNav.zone}
|
|
97
|
+
onCategorySelect={onCategorySelect}
|
|
98
|
+
onRowActivate={(row) => cycleSetting(row.key)}
|
|
99
|
+
theme={theme}
|
|
100
|
+
/>
|
|
101
|
+
) : null}
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function InboxScreen(props: {
|
|
107
|
+
inbox: ReviewSession[];
|
|
108
|
+
inboxCursor: number;
|
|
109
|
+
mode: Mode;
|
|
110
|
+
theme: Theme;
|
|
111
|
+
controller: ReviewController;
|
|
112
|
+
setMode: Dispatch<SetStateAction<Mode>>;
|
|
113
|
+
menuChrome: React.ReactNode;
|
|
114
|
+
}): React.ReactNode {
|
|
115
|
+
const { inbox, inboxCursor, mode, theme, controller, setMode, menuChrome } = props;
|
|
116
|
+
const confirming = mode.type === "confirmDelete" ? mode : null;
|
|
117
|
+
return (
|
|
118
|
+
<ThemeProvider theme={theme}>
|
|
119
|
+
<box
|
|
120
|
+
style={{
|
|
121
|
+
flexDirection: "column",
|
|
122
|
+
width: "100%",
|
|
123
|
+
height: "100%",
|
|
124
|
+
backgroundColor: theme.background,
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
{/* mirrors the review header row: same box, position, and accent product
|
|
128
|
+
word, with a " · resume" separator and no Edit/Share toolbar */}
|
|
129
|
+
<box
|
|
130
|
+
style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}
|
|
131
|
+
>
|
|
132
|
+
<box
|
|
133
|
+
style={{
|
|
134
|
+
height: 1,
|
|
135
|
+
backgroundColor: theme.panel,
|
|
136
|
+
paddingLeft: 1,
|
|
137
|
+
flexDirection: "row",
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<text>
|
|
141
|
+
<span fg={theme.accent}>cueloop</span>
|
|
142
|
+
<span fg={theme.textDim}> · resume</span>
|
|
143
|
+
</text>
|
|
144
|
+
</box>
|
|
145
|
+
<box style={{ flexGrow: 1 }} />
|
|
146
|
+
</box>
|
|
147
|
+
<InboxList
|
|
148
|
+
inbox={inbox}
|
|
149
|
+
cursor={inboxCursor}
|
|
150
|
+
onRequestDelete={(id, title) => setMode({ type: "confirmDelete", sessionId: id, title })}
|
|
151
|
+
/>
|
|
152
|
+
{menuChrome}
|
|
153
|
+
<ConfirmDialog
|
|
154
|
+
isOpen={confirming !== null}
|
|
155
|
+
title=" Delete plan "
|
|
156
|
+
message={
|
|
157
|
+
confirming ? `Delete "${confirming.title}"? This removes the plan and its review.` : ""
|
|
158
|
+
}
|
|
159
|
+
onConfirm={() => {
|
|
160
|
+
if (confirming) controller.deleteSession(confirming.sessionId);
|
|
161
|
+
setMode({ type: "normal" });
|
|
162
|
+
}}
|
|
163
|
+
onCancel={() => setMode({ type: "normal" })}
|
|
164
|
+
theme={theme}
|
|
165
|
+
/>
|
|
166
|
+
</box>
|
|
167
|
+
</ThemeProvider>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function CompletionScreen(props: {
|
|
172
|
+
theme: Theme;
|
|
173
|
+
session: ReviewSession;
|
|
174
|
+
verdict: VerdictKind;
|
|
175
|
+
completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
|
|
176
|
+
status: string;
|
|
177
|
+
}): React.ReactNode {
|
|
178
|
+
const { theme, session, verdict, completion, status } = props;
|
|
179
|
+
return (
|
|
180
|
+
<ThemeProvider theme={theme}>
|
|
181
|
+
<CompletionOverlay
|
|
182
|
+
verdict={verdict}
|
|
183
|
+
completion={completion}
|
|
184
|
+
status={status}
|
|
185
|
+
returnsTo={
|
|
186
|
+
returnPaneFor(session.artifact.meta.herdrPane)
|
|
187
|
+
? (session.artifact.meta.agent ?? "the agent")
|
|
188
|
+
: undefined
|
|
189
|
+
}
|
|
190
|
+
/>
|
|
191
|
+
</ThemeProvider>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function TrailingOverlays(props: {
|
|
196
|
+
walking: boolean;
|
|
197
|
+
walk: { index: number } | null;
|
|
198
|
+
walkFileList: WalkFile[];
|
|
199
|
+
viewedPaths: Set<string>;
|
|
200
|
+
session: ReviewSession;
|
|
201
|
+
terminalWidth: number;
|
|
202
|
+
theme: Theme;
|
|
203
|
+
mode: Mode;
|
|
204
|
+
toast: ToastState | null;
|
|
205
|
+
setMode: Dispatch<SetStateAction<Mode>>;
|
|
206
|
+
dispatch: (intent: Intent) => void;
|
|
207
|
+
}): React.ReactNode {
|
|
208
|
+
const {
|
|
209
|
+
walking,
|
|
210
|
+
walk,
|
|
211
|
+
walkFileList,
|
|
212
|
+
viewedPaths,
|
|
213
|
+
session,
|
|
214
|
+
terminalWidth,
|
|
215
|
+
theme,
|
|
216
|
+
mode,
|
|
217
|
+
toast,
|
|
218
|
+
setMode,
|
|
219
|
+
dispatch,
|
|
220
|
+
} = props;
|
|
221
|
+
return (
|
|
222
|
+
<>
|
|
223
|
+
{walking && walk !== null ? (
|
|
224
|
+
<WalkWizard
|
|
225
|
+
files={walkFileList}
|
|
226
|
+
index={walk.index}
|
|
227
|
+
viewedPaths={viewedPaths}
|
|
228
|
+
note={
|
|
229
|
+
walkFileList[walk.index] !== undefined
|
|
230
|
+
? noteForFile(session.annotations, walkFileList[walk.index]!.path)
|
|
231
|
+
: undefined
|
|
232
|
+
}
|
|
233
|
+
terminalWidth={terminalWidth}
|
|
234
|
+
onSubmitRequest={() => {
|
|
235
|
+
dispatch({ type: "walkLeave" });
|
|
236
|
+
dispatch({ type: "openSubmit" });
|
|
237
|
+
}}
|
|
238
|
+
onBack={() => dispatch({ type: "walkBack" })}
|
|
239
|
+
/>
|
|
240
|
+
) : null}
|
|
241
|
+
{mode.type === "rename" ? (
|
|
242
|
+
<PromptDialog
|
|
243
|
+
isOpen
|
|
244
|
+
title=" Rename author "
|
|
245
|
+
label="Display name for this collaborator:"
|
|
246
|
+
value={mode.text}
|
|
247
|
+
placeholder="their name"
|
|
248
|
+
onInput={(text) => setMode({ ...mode, text })}
|
|
249
|
+
theme={theme}
|
|
250
|
+
/>
|
|
251
|
+
) : null}
|
|
252
|
+
{mode.type === "nameSelf" ? (
|
|
253
|
+
<PromptDialog
|
|
254
|
+
isOpen
|
|
255
|
+
title=" Welcome "
|
|
256
|
+
label="Your name (optional) - it attributes the notes you leave:"
|
|
257
|
+
value={mode.text}
|
|
258
|
+
placeholder="your name"
|
|
259
|
+
onInput={(text) => setMode({ ...mode, text })}
|
|
260
|
+
theme={theme}
|
|
261
|
+
/>
|
|
262
|
+
) : null}
|
|
263
|
+
{toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
|
|
264
|
+
</>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
2
|
+
import type { ReviewSession, VerdictKind } from "@cueloop/schema";
|
|
3
|
+
import { activeSpanState, reviewerAnnotations, type Mode } from "./intent-dispatch";
|
|
4
|
+
import { displayText, type DisplayBlock } from "./view-plan";
|
|
5
|
+
import type { DiffRow } from "./view-diff";
|
|
6
|
+
import type { Intent, KeyState } from "./keymap";
|
|
7
|
+
import type { Completion, ReviewController } from "./session-controller";
|
|
8
|
+
import type { QuickAction } from "./config";
|
|
9
|
+
import type { WalkFile } from "./walk";
|
|
10
|
+
import { viewedCount } from "./walk";
|
|
11
|
+
import type { PlanComposeState, PlanPopoverState } from "./components/PlanSheet";
|
|
12
|
+
import type { DiffComposeState } from "./components/DiffSheet";
|
|
13
|
+
import type { ConfirmCardProps } from "./components/ConfirmCard";
|
|
14
|
+
import type { RailCardEdit } from "./components/ReviewRail";
|
|
15
|
+
import type { BreadcrumbItem } from "./components/Breadcrumb";
|
|
16
|
+
import { REVIEW_COMPACT_WIDTH, resolveReviewWidth, type ReviewPanelMode } from "./review-panel";
|
|
17
|
+
|
|
18
|
+
export function computeRoleCapabilities(
|
|
19
|
+
readOnly: boolean,
|
|
20
|
+
role: "owner" | "observer" | "collaborator",
|
|
21
|
+
): { observer: boolean; isOwner: boolean } {
|
|
22
|
+
const observer = readOnly || role === "observer";
|
|
23
|
+
return { observer, isOwner: !observer && role === "owner" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function deriveReviewFlags(session: ReviewSession | null): {
|
|
27
|
+
isDiff: boolean;
|
|
28
|
+
isPrototype: boolean;
|
|
29
|
+
resolved: boolean;
|
|
30
|
+
} {
|
|
31
|
+
return {
|
|
32
|
+
isDiff: session?.artifact.type === "diff",
|
|
33
|
+
isPrototype: session?.artifact.type === "prototype",
|
|
34
|
+
resolved: session?.status === "resolved",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isWalking(isDiff: boolean, walk: { index: number } | null): boolean {
|
|
39
|
+
return isDiff && walk !== null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveOverlay(
|
|
43
|
+
mode: Mode,
|
|
44
|
+
completionPhase: Completion["phase"],
|
|
45
|
+
walking: boolean,
|
|
46
|
+
): KeyState["overlay"] {
|
|
47
|
+
if (mode.type === "compose" || mode.type === "railEdit") return "compose";
|
|
48
|
+
if (mode.type === "submit") return "submit";
|
|
49
|
+
if (mode.type === "confirmDelete") return "confirm";
|
|
50
|
+
if (mode.type === "rename" || mode.type === "nameSelf") return "prompt";
|
|
51
|
+
if (mode.type === "spanActions") return "spanActions";
|
|
52
|
+
if (completionPhase === "prompt") return "completion-prompt";
|
|
53
|
+
if (completionPhase === "counting") return "completion-counting";
|
|
54
|
+
if (walking) return "walk";
|
|
55
|
+
return "none";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isCompletionOverlayPhase(
|
|
59
|
+
completion: Completion,
|
|
60
|
+
): completion is { phase: "prompt" } | { phase: "counting"; remaining: number } {
|
|
61
|
+
return completion.phase === "prompt" || completion.phase === "counting";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function computePendingCount(session: ReviewSession): number {
|
|
65
|
+
return reviewerAnnotations(session).length + (session.workingCopy !== undefined ? 1 : 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function computeRailFootprint(
|
|
69
|
+
reviewMode: ReviewPanelMode,
|
|
70
|
+
reviewWidth: number,
|
|
71
|
+
terminalWidth: number,
|
|
72
|
+
): number {
|
|
73
|
+
if (reviewMode === "hidden") return 0;
|
|
74
|
+
return (
|
|
75
|
+
1 +
|
|
76
|
+
(reviewMode === "compact"
|
|
77
|
+
? REVIEW_COMPACT_WIDTH
|
|
78
|
+
: resolveReviewWidth(reviewWidth, terminalWidth))
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function buildHeaderItems(params: {
|
|
83
|
+
session: ReviewSession;
|
|
84
|
+
resolved: boolean;
|
|
85
|
+
observer: boolean;
|
|
86
|
+
role: "owner" | "observer" | "collaborator";
|
|
87
|
+
}): BreadcrumbItem[] {
|
|
88
|
+
const { session, resolved, observer, role } = params;
|
|
89
|
+
return [
|
|
90
|
+
{ label: "cueloop", tone: "accent" },
|
|
91
|
+
...(resolved
|
|
92
|
+
? [
|
|
93
|
+
{
|
|
94
|
+
label: `resolved: ${session.verdict!.kind.replace("_", " ")}`,
|
|
95
|
+
tone: "green" as const,
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
: []),
|
|
99
|
+
...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
|
|
100
|
+
...(role === "collaborator"
|
|
101
|
+
? [{ label: "shared · your notes save as you go", tone: "dim" as const }]
|
|
102
|
+
: []),
|
|
103
|
+
{
|
|
104
|
+
label: `${session.artifact.meta.title ?? session.artifact.meta.planPath ?? session.id} · rev ${session.revisions.length}`,
|
|
105
|
+
tone: "dim",
|
|
106
|
+
},
|
|
107
|
+
{ label: `submitted by ${session.artifact.meta.agent ?? "unknown"}`, tone: "dim" },
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildRenderFlags(params: {
|
|
112
|
+
session: ReviewSession;
|
|
113
|
+
isOwner: boolean;
|
|
114
|
+
isDiff: boolean;
|
|
115
|
+
isPrototype: boolean;
|
|
116
|
+
resolved: boolean;
|
|
117
|
+
menuOpen: boolean;
|
|
118
|
+
menuDialog: "keybinds" | "settings" | null;
|
|
119
|
+
resolvedIds: Set<string>;
|
|
120
|
+
}): {
|
|
121
|
+
showOwnerActions: boolean;
|
|
122
|
+
prototypeCanComment: boolean;
|
|
123
|
+
chromeHidden: boolean;
|
|
124
|
+
prototypePath: string;
|
|
125
|
+
railResolvedIds: Set<string> | null;
|
|
126
|
+
} {
|
|
127
|
+
const { session, isOwner, isDiff, isPrototype, resolved, menuOpen, menuDialog, resolvedIds } =
|
|
128
|
+
params;
|
|
129
|
+
return {
|
|
130
|
+
showOwnerActions: isOwner && !isDiff && !resolved,
|
|
131
|
+
prototypeCanComment: isOwner && !resolved,
|
|
132
|
+
chromeHidden: menuOpen || menuDialog !== null,
|
|
133
|
+
prototypePath: session.artifact.meta.prototypePath ?? "",
|
|
134
|
+
railResolvedIds: isDiff || isPrototype ? null : resolvedIds,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function buildActiveSpan(
|
|
139
|
+
mode: Mode,
|
|
140
|
+
isDiff: boolean,
|
|
141
|
+
): { displayIndex: number; start: number; end: number } | null {
|
|
142
|
+
const markedSpan = activeSpanState(mode);
|
|
143
|
+
if (markedSpan)
|
|
144
|
+
return { displayIndex: markedSpan.displayIndex, start: markedSpan.start, end: markedSpan.end };
|
|
145
|
+
if (mode.type === "compose" && !isDiff)
|
|
146
|
+
return { displayIndex: mode.displayIndex, start: mode.start, end: mode.end };
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface DraftHandlerDeps {
|
|
151
|
+
liveInput: MutableRefObject<string>;
|
|
152
|
+
setMode: Dispatch<SetStateAction<Mode>>;
|
|
153
|
+
dispatch: (intent: Intent) => void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function buildComposeState(
|
|
157
|
+
deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; display: DisplayBlock[] },
|
|
158
|
+
): PlanComposeState | null {
|
|
159
|
+
const { mode, isDiff, display, liveInput, setMode, dispatch } = deps;
|
|
160
|
+
if (mode.type !== "compose" || isDiff) return null;
|
|
161
|
+
return {
|
|
162
|
+
kind: mode.kind,
|
|
163
|
+
displayIndex: mode.displayIndex,
|
|
164
|
+
quote: displayText(display[mode.displayIndex]!).slice(mode.start, mode.end),
|
|
165
|
+
draft: {
|
|
166
|
+
text: mode.text,
|
|
167
|
+
onInput: (text: string) => {
|
|
168
|
+
liveInput.current = text;
|
|
169
|
+
setMode({ ...mode, text });
|
|
170
|
+
},
|
|
171
|
+
onSave: () => dispatch({ type: "saveCompose" }),
|
|
172
|
+
onCancel: () => dispatch({ type: "closeOverlay" }),
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function buildDiffComposeState(
|
|
178
|
+
deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; rows: DiffRow[] },
|
|
179
|
+
): DiffComposeState | null {
|
|
180
|
+
const { mode, isDiff, rows, liveInput, setMode, dispatch } = deps;
|
|
181
|
+
if (mode.type !== "compose" || !isDiff) return null;
|
|
182
|
+
return {
|
|
183
|
+
kind: mode.kind,
|
|
184
|
+
rowIndex: mode.displayIndex,
|
|
185
|
+
quote: rows[mode.displayIndex]?.text.replace(/\n$/, "") ?? "",
|
|
186
|
+
draft: {
|
|
187
|
+
text: mode.text,
|
|
188
|
+
onInput: (text: string) => {
|
|
189
|
+
liveInput.current = text;
|
|
190
|
+
setMode({ ...mode, text });
|
|
191
|
+
},
|
|
192
|
+
onSave: () => dispatch({ type: "saveCompose" }),
|
|
193
|
+
onCancel: () => dispatch({ type: "closeOverlay" }),
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function buildPopoverState(deps: {
|
|
199
|
+
mode: Mode;
|
|
200
|
+
isDiff: boolean;
|
|
201
|
+
quickActions: QuickAction[];
|
|
202
|
+
isOwner: boolean;
|
|
203
|
+
observer: boolean;
|
|
204
|
+
resolved: boolean;
|
|
205
|
+
controller: ReviewController;
|
|
206
|
+
dispatch: (intent: Intent) => void;
|
|
207
|
+
}): PlanPopoverState | null {
|
|
208
|
+
const { mode, isDiff, quickActions, isOwner, observer, resolved, controller, dispatch } = deps;
|
|
209
|
+
const markedSpan = activeSpanState(mode);
|
|
210
|
+
if (!markedSpan || isDiff) return null;
|
|
211
|
+
const spanMutationBlock = (): string | null =>
|
|
212
|
+
observer ? "observer - read-only" : resolved ? "review submitted - read-only" : null;
|
|
213
|
+
return {
|
|
214
|
+
displayIndex: markedSpan.displayIndex,
|
|
215
|
+
view: mode.type === "spanActions" ? ("actions" as const) : ("toolbar" as const),
|
|
216
|
+
actions: quickActions,
|
|
217
|
+
actionIndex: mode.type === "spanActions" ? mode.index : 0,
|
|
218
|
+
canCut: isOwner,
|
|
219
|
+
onComment: () => {
|
|
220
|
+
const blocked = spanMutationBlock();
|
|
221
|
+
if (blocked) return controller.setStatus(blocked);
|
|
222
|
+
dispatch({ type: "openCompose", kind: "comment", from: "span" });
|
|
223
|
+
},
|
|
224
|
+
onCut: () => {
|
|
225
|
+
const blocked = spanMutationBlock();
|
|
226
|
+
if (blocked) return controller.setStatus(blocked);
|
|
227
|
+
if (!isOwner) return;
|
|
228
|
+
dispatch({ type: "spanCut" });
|
|
229
|
+
},
|
|
230
|
+
onOpenActions: () => {
|
|
231
|
+
const blocked = spanMutationBlock();
|
|
232
|
+
if (blocked) return controller.setStatus(blocked);
|
|
233
|
+
dispatch({ type: "openSpanActions" });
|
|
234
|
+
},
|
|
235
|
+
onClose: () => dispatch({ type: "closeOverlay" }),
|
|
236
|
+
onPickAction: (index: number) => {
|
|
237
|
+
const blocked = spanMutationBlock();
|
|
238
|
+
if (blocked) return controller.setStatus(blocked);
|
|
239
|
+
dispatch({ type: "pickSpanAction", index });
|
|
240
|
+
},
|
|
241
|
+
onBack: () => dispatch({ type: "closeSpanActions" }),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function buildSubmitConfirmState(
|
|
246
|
+
deps: DraftHandlerDeps & {
|
|
247
|
+
mode: Mode;
|
|
248
|
+
isDiff: boolean;
|
|
249
|
+
session: ReviewSession;
|
|
250
|
+
walkFileList: WalkFile[];
|
|
251
|
+
viewedPaths: Set<string>;
|
|
252
|
+
},
|
|
253
|
+
): Omit<ConfirmCardProps, "theme"> | null {
|
|
254
|
+
const { mode, isDiff, session, walkFileList, viewedPaths, liveInput, setMode, dispatch } = deps;
|
|
255
|
+
if (mode.type !== "submit") return null;
|
|
256
|
+
return {
|
|
257
|
+
verdict: mode.verdict,
|
|
258
|
+
summary: mode.summary,
|
|
259
|
+
viewedSummary:
|
|
260
|
+
isDiff && session.viewedPaths !== undefined
|
|
261
|
+
? `${viewedCount(walkFileList, viewedPaths)}/${walkFileList.length} files viewed`
|
|
262
|
+
: undefined,
|
|
263
|
+
onInput: (summary: string) => {
|
|
264
|
+
liveInput.current = summary;
|
|
265
|
+
setMode({ ...mode, summary });
|
|
266
|
+
},
|
|
267
|
+
onSelectVerdict: (verdict: VerdictKind) => setMode({ ...mode, verdict }),
|
|
268
|
+
onSubmit: () => dispatch({ type: "submitVerdict" }),
|
|
269
|
+
onCancel: () => dispatch({ type: "closeOverlay" }),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function buildCardEditState(deps: DraftHandlerDeps & { mode: Mode }): RailCardEdit | null {
|
|
274
|
+
const { mode, liveInput, setMode, dispatch } = deps;
|
|
275
|
+
if (mode.type !== "railEdit") return null;
|
|
276
|
+
return {
|
|
277
|
+
id: mode.id,
|
|
278
|
+
text: mode.text,
|
|
279
|
+
onInput: (text: string) => {
|
|
280
|
+
liveInput.current = text;
|
|
281
|
+
setMode({ type: "railEdit", id: mode.id, text });
|
|
282
|
+
},
|
|
283
|
+
onSave: () => dispatch({ type: "saveCompose" }),
|
|
284
|
+
onCancel: () => dispatch({ type: "closeOverlay" }),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
12
12
|
import { useRenderer } from "@opentui/react";
|
|
13
|
-
import {
|
|
13
|
+
import { type ScrollBoxRenderable, type TextRenderable } from "@opentui/core";
|
|
14
14
|
import type { ReviewSession } from "@cueloop/schema";
|
|
15
15
|
import {
|
|
16
16
|
blockRuns,
|
|
@@ -31,18 +31,7 @@ import { CodeBlock } from "./CodeBlock";
|
|
|
31
31
|
import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
|
|
32
32
|
import { MarkerPopover, type MarkerPopoverProps } from "./MarkerPopover";
|
|
33
33
|
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
34
|
-
|
|
35
|
-
/** A cut block reads as removed: struck through and grayed, never red. */
|
|
36
|
-
const CUT_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
|
|
37
|
-
|
|
38
|
-
/** Inline-emphasis attributes, reused per run so the render loop allocates none. */
|
|
39
|
-
const STRONG_ATTRIBUTES = createTextAttributes({ bold: true });
|
|
40
|
-
const EM_ATTRIBUTES = createTextAttributes({ italic: true });
|
|
41
|
-
const STRIKE_ATTRIBUTES = createTextAttributes({ strikethrough: true });
|
|
42
|
-
const LINK_ATTRIBUTES = createTextAttributes({ underline: true });
|
|
43
|
-
/** Block-level base attributes: headings read bold, blockquotes read italic. */
|
|
44
|
-
const HEADING_ATTRIBUTES = createTextAttributes({ bold: true });
|
|
45
|
-
const QUOTE_ATTRIBUTES = createTextAttributes({ italic: true });
|
|
34
|
+
import { runStyle } from "./plan-sheet-run-style";
|
|
46
35
|
|
|
47
36
|
/** The popover toolbar card is 3 rows tall and sits flush above the marked words. */
|
|
48
37
|
const POPOVER_ROWS_ABOVE = 3;
|
|
@@ -405,52 +394,3 @@ function tagLabel(block: DisplayBlock): "new" | "edited" {
|
|
|
405
394
|
function tagColor(block: DisplayBlock, tokens: Theme): string {
|
|
406
395
|
return block.type === "add" ? tokens.green : tokens.accent;
|
|
407
396
|
}
|
|
408
|
-
|
|
409
|
-
function runStyle(
|
|
410
|
-
run: StyleRun,
|
|
411
|
-
block: DisplayBlock,
|
|
412
|
-
tokens: Theme,
|
|
413
|
-
): { fg?: string; bg?: string; attributes?: number } {
|
|
414
|
-
// a cut block reads as removed: every run struck through and grayed, never red
|
|
415
|
-
if (block.type === "del") return { fg: tokens.textDim, attributes: CUT_ATTRIBUTES };
|
|
416
|
-
const isHeading = block.kind === "h1" || block.kind === "h2" || block.kind === "h3";
|
|
417
|
-
const isQuote = block.kind === "quote";
|
|
418
|
-
// headings are all bold; level reads from descending brightness alone (a
|
|
419
|
-
// terminal cannot scale font size), leaving the salmon accent to annotations
|
|
420
|
-
const headingFg =
|
|
421
|
-
block.kind === "h1"
|
|
422
|
-
? tokens.text
|
|
423
|
-
: block.kind === "h2"
|
|
424
|
-
? tokens.textMuted
|
|
425
|
-
: block.kind === "h3"
|
|
426
|
-
? tokens.textDim
|
|
427
|
-
: undefined;
|
|
428
|
-
// block-level base: headings bold, quotes muted italic; inline roles compose on top
|
|
429
|
-
const baseFg = headingFg ?? (isQuote ? tokens.textMuted : tokens.text);
|
|
430
|
-
const baseAttributes = (isHeading ? HEADING_ATTRIBUTES : 0) | (isQuote ? QUOTE_ATTRIBUTES : 0);
|
|
431
|
-
const withBase = (roleAttributes: number): number => baseAttributes | roleAttributes;
|
|
432
|
-
switch (run.role) {
|
|
433
|
-
case "ins":
|
|
434
|
-
return { fg: tokens.insertedForeground, attributes: baseAttributes || undefined };
|
|
435
|
-
case "del":
|
|
436
|
-
return { fg: tokens.deletedForeground };
|
|
437
|
-
case "mark-comment":
|
|
438
|
-
return { fg: tokens.text, bg: tokens.markCommentBackground };
|
|
439
|
-
case "mark-focus":
|
|
440
|
-
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
441
|
-
case "kspan":
|
|
442
|
-
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
443
|
-
case "strong":
|
|
444
|
-
return { fg: baseFg, attributes: withBase(STRONG_ATTRIBUTES) };
|
|
445
|
-
case "em":
|
|
446
|
-
return { fg: baseFg, attributes: withBase(EM_ATTRIBUTES) };
|
|
447
|
-
case "code":
|
|
448
|
-
return { fg: tokens.text, bg: tokens.elevated };
|
|
449
|
-
case "strike":
|
|
450
|
-
return { fg: tokens.textMuted, attributes: withBase(STRIKE_ATTRIBUTES) };
|
|
451
|
-
case "link":
|
|
452
|
-
return { fg: tokens.blue, attributes: withBase(LINK_ATTRIBUTES) };
|
|
453
|
-
default:
|
|
454
|
-
return { fg: baseFg, attributes: baseAttributes || undefined };
|
|
455
|
-
}
|
|
456
|
-
}
|