@plannotator/ui 0.36.0 → 0.38.0
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/HANDOFF.md +6 -4
- package/README.md +57 -1
- package/components/ActionMenu.tsx +22 -24
- package/components/AnnotationToolbar.tsx +3 -1
- package/components/ApproveDropdown.tsx +15 -18
- package/components/CommentPopover.tsx +9 -4
- package/components/DecisionControl.tsx +626 -0
- package/components/DocBadges.tsx +9 -6
- package/components/FloatingQuickLabelPicker.tsx +4 -1
- package/components/KeyboardShortcuts.tsx +1 -0
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/Settings.tsx +67 -6
- package/components/StickyHeaderLane.tsx +16 -40
- package/components/ToolbarButtons.tsx +31 -9
- package/components/Viewer.tsx +277 -95
- package/components/VimTargetReticle.tsx +1 -1
- package/components/blocks/AlertBlock.tsx +51 -4
- package/components/compactHeaderLayout.ts +52 -0
- package/config/reviewView.ts +1 -0
- package/config/settings.ts +48 -3
- package/configure.ts +9 -0
- package/hooks/useAnnotationHighlighter.ts +31 -19
- package/hooks/useDismissablePopover.ts +65 -0
- package/hooks/useVimDocumentFocus.ts +6 -0
- package/package.json +22 -22
- package/shortcuts/decisionControl.shortcuts.ts +31 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +13 -0
- package/styles.css +1 -1
- package/theme.css +11 -0
- package/utils/alertTitle.ts +69 -0
- package/utils/decisionSpec.ts +430 -0
- package/utils/platform.ts +16 -0
- package/utils/vimScroll.ts +1 -1
- package/sprite_package_additional/index.html +0 -34
- package/sprite_package_new/index.html +0 -34
- package/sprite_package_pulluphang/index.html +0 -34
package/theme.css
CHANGED
|
@@ -912,6 +912,17 @@ html:has([data-pn-compact-touch-layout='true'])
|
|
|
912
912
|
gap: 0.5rem;
|
|
913
913
|
}
|
|
914
914
|
|
|
915
|
+
/* Percentages in translate use the toolbar's own width. Correct only its
|
|
916
|
+
* horizontal overflow; leave the existing transform animation untouched. */
|
|
917
|
+
html:has([data-pn-compact-touch-layout='true'])
|
|
918
|
+
[data-pn-annotation-toolbar-centered='true'] {
|
|
919
|
+
translate: clamp(
|
|
920
|
+
calc(var(--pn-viewport-offset-left, 0px) + var(--pn-safe-left, 0px) - var(--pn-annotation-toolbar-anchor-x) + 50%),
|
|
921
|
+
0px,
|
|
922
|
+
calc(var(--pn-viewport-offset-left, 0px) + var(--pn-viewport-width, 100vw) - var(--pn-safe-right, 0px) - var(--pn-annotation-toolbar-anchor-x) - 50%)
|
|
923
|
+
) 0;
|
|
924
|
+
}
|
|
925
|
+
|
|
915
926
|
html:has([data-pn-compact-touch-layout='true'])
|
|
916
927
|
[data-pn-touch-target]:not(:disabled):not([aria-disabled='true']):active {
|
|
917
928
|
filter: brightness(0.92);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The title-line grammar for GitHub alerts (`> [!NOTE|TIP|IMPORTANT|WARNING|CAUTION]`).
|
|
3
|
+
*
|
|
4
|
+
* The first body line of an alert is a TITLE LINE when the whole line matches:
|
|
5
|
+
*
|
|
6
|
+
* [<emoji> ][**<title>**][ <!-- icon: <name> -->]
|
|
7
|
+
*
|
|
8
|
+
* that is, in order: optionally one emoji (followed by one space when anything
|
|
9
|
+
* follows it); optionally a bold-only run `**title**` with no other characters
|
|
10
|
+
* around it; optionally one space and an HTML comment `<!-- icon: name -->`
|
|
11
|
+
* where name is `[A-Za-z0-9][A-Za-z0-9_-]*`; at least one of the three
|
|
12
|
+
* present; trailing whitespace ignored. It only counts when the NEXT body line
|
|
13
|
+
* is empty (the `>` line GitHub needs to start a new paragraph) or the block
|
|
14
|
+
* ends there. Anything else is ordinary body and the alert renders as before.
|
|
15
|
+
*
|
|
16
|
+
* Decided from bytes alone, so a host editor can apply the same rule to the
|
|
17
|
+
* raw lines it decorates. Kept pure and dependency-free for that reason.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface AlertTitleLine {
|
|
21
|
+
/** The leading emoji, when present (one grapheme, ZWJ/modifier sequences included). */
|
|
22
|
+
emoji?: string;
|
|
23
|
+
/** The bold-only title text, when present (the `**` marks stripped). */
|
|
24
|
+
title?: string;
|
|
25
|
+
/** The `<!-- icon: name -->` name, when present. Never rendered as text. */
|
|
26
|
+
icon?: string;
|
|
27
|
+
/** The body after the title line and its separating empty line. */
|
|
28
|
+
rest: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// One emoji: a regional-indicator pair (flag) or a pictographic base with
|
|
32
|
+
// optional skin-tone / variation / keycap marks, joined by ZWJ to more of the
|
|
33
|
+
// same. Deliberately not the whole Unicode emoji property (which would admit
|
|
34
|
+
// bare digits and `#`).
|
|
35
|
+
const EMOJI =
|
|
36
|
+
'(?:\\p{RI}\\p{RI}|\\p{Extended_Pictographic}(?:\\p{Emoji_Modifier}|\\uFE0F|\\u20E3)*' +
|
|
37
|
+
'(?:\\u200D\\p{Extended_Pictographic}(?:\\p{Emoji_Modifier}|\\uFE0F)*)*)';
|
|
38
|
+
|
|
39
|
+
const ICON_NAME = '[A-Za-z0-9][A-Za-z0-9_-]*';
|
|
40
|
+
|
|
41
|
+
const TITLE_LINE = new RegExp(
|
|
42
|
+
`^(?:(${EMOJI})(?= |$) ?)?(?:\\*\\*([^*\\n]+?)\\*\\*)?(?: ?<!--\\s*icon:\\s*(${ICON_NAME})\\s*-->)?$`,
|
|
43
|
+
'u',
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Split an alert body into its title line and the rest, or return null when
|
|
48
|
+
* the first line is not a title line by the grammar above.
|
|
49
|
+
*/
|
|
50
|
+
export function parseAlertTitleLine(body: string): AlertTitleLine | null {
|
|
51
|
+
const firstBreak = body.indexOf('\n');
|
|
52
|
+
const first = firstBreak === -1 ? body : body.slice(0, firstBreak);
|
|
53
|
+
const after = firstBreak === -1 ? '' : body.slice(firstBreak + 1);
|
|
54
|
+
|
|
55
|
+
const match = first.trimEnd().match(TITLE_LINE);
|
|
56
|
+
if (!match) return null;
|
|
57
|
+
const [, emoji, title, icon] = match;
|
|
58
|
+
if (!emoji && !title && !icon) return null;
|
|
59
|
+
|
|
60
|
+
// The next line must be empty, or the block must end.
|
|
61
|
+
if (after !== '') {
|
|
62
|
+
const secondBreak = after.indexOf('\n');
|
|
63
|
+
const second = secondBreak === -1 ? after : after.slice(0, secondBreak);
|
|
64
|
+
if (second.trim() !== '') return null;
|
|
65
|
+
const rest = secondBreak === -1 ? '' : after.slice(secondBreak + 1);
|
|
66
|
+
return { emoji, title: title?.trim(), icon, rest };
|
|
67
|
+
}
|
|
68
|
+
return { emoji, title: title?.trim(), icon, rest: '' };
|
|
69
|
+
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure state→spec mapping for the unified header decision control.
|
|
3
|
+
*
|
|
4
|
+
* No React, no DOM, no imports — this is what keeps the state matrix testable
|
|
5
|
+
* in the plain `bun test` lane and keeps "both apps and both states are data,
|
|
6
|
+
* not forked components" true. `DecisionControl.tsx` renders whatever this
|
|
7
|
+
* returns; the apps translate ids into handlers.
|
|
8
|
+
*
|
|
9
|
+
* NOT host-supported surface: like ActionMenu/ConfirmDialog, this module is
|
|
10
|
+
* app-shared chrome and is deliberately absent from the README supported-import
|
|
11
|
+
* list and the strict-consumer tsconfig.
|
|
12
|
+
*
|
|
13
|
+
* Labels, subtitles and confirm strings are the approved prototype's
|
|
14
|
+
* (DESIGN_final-proposal.html `spec()`), authoritative over any older branch
|
|
15
|
+
* or mock copy — except where a later maintainer ruling supersedes it: the
|
|
16
|
+
* non-gate empty menu carries ONE composer ("Send a note…"), and no
|
|
17
|
+
* user-facing string uses an em dash.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type DecisionActionId =
|
|
21
|
+
| 'primary' // the left segment
|
|
22
|
+
| 'note-with-approval' // "Approve with a note…" (approval flows only)
|
|
23
|
+
| 'request-changes' // "Request changes…"
|
|
24
|
+
| 'note-with-feedback' // "Send with a note…"
|
|
25
|
+
| 'approve-with-notes' // review + gate-annotate; capability-gated
|
|
26
|
+
| 'discard-and-finish'; // "Done/Approve, discard n annotations…"
|
|
27
|
+
|
|
28
|
+
export type DecisionTone = 'success' | 'primary' | 'neutral' | 'destructive';
|
|
29
|
+
|
|
30
|
+
export interface DecisionPrimary {
|
|
31
|
+
id: 'primary';
|
|
32
|
+
label: string; // 'Done' | 'Approve' | 'Send Feedback' | 'Post Comments'
|
|
33
|
+
shortLabel?: string; // 'Send' — the lg-breakpoint label
|
|
34
|
+
mobileLabel?: string; // compact/touch row label
|
|
35
|
+
title: string; // tooltip / aria description
|
|
36
|
+
tone: Exclude<DecisionTone, 'destructive'>;
|
|
37
|
+
icon?: 'check' | 'send';
|
|
38
|
+
count?: number; // rendered as the inline pill; omitted when 0
|
|
39
|
+
/**
|
|
40
|
+
* Platform self-approval (PR6, §3.4): rendered dimmed but NOT disabled.
|
|
41
|
+
* The reason surfaces through the shared Tooltip + aria-describedby (the
|
|
42
|
+
* native title is deliberately dropped when muted, pinned by test), and
|
|
43
|
+
* every invocation path (click, Mod+Enter, compact row) is a no-op. The
|
|
44
|
+
* caret stays live so the menu's non-approve paths remain reachable.
|
|
45
|
+
*/
|
|
46
|
+
muted?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DecisionComposer {
|
|
50
|
+
title: string; // popover back-button title, e.g. 'Send with a note'
|
|
51
|
+
actionLabel: string; // the composer's own button, e.g. 'Send feedback with note'
|
|
52
|
+
tone: Exclude<DecisionTone, 'destructive'>;
|
|
53
|
+
icon?: 'check' | 'send';
|
|
54
|
+
placeholder: string; // 'Add a note...'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DecisionConfirm {
|
|
58
|
+
title: string;
|
|
59
|
+
message: string;
|
|
60
|
+
confirmText: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DecisionMenuItem {
|
|
64
|
+
id: Exclude<DecisionActionId, 'primary'>;
|
|
65
|
+
label: string;
|
|
66
|
+
subtitle: string;
|
|
67
|
+
tone: DecisionTone;
|
|
68
|
+
icon?: 'check' | 'send';
|
|
69
|
+
dividerBefore?: boolean;
|
|
70
|
+
composer?: DecisionComposer; // present ⇒ the item morphs the popover
|
|
71
|
+
confirm?: DecisionConfirm; // present ⇒ the item raises one confirm
|
|
72
|
+
/**
|
|
73
|
+
* Platform self-approval (PR6, §3.4): the row renders disabled with the
|
|
74
|
+
* reason in its subtitle. Muted rather than removed, so the menu still
|
|
75
|
+
* documents the path and no state is a dead end (the non-approve rows
|
|
76
|
+
* stay live).
|
|
77
|
+
*/
|
|
78
|
+
muted?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DecisionSpec {
|
|
82
|
+
primary: DecisionPrimary;
|
|
83
|
+
items: DecisionMenuItem[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface DecisionSpecInput {
|
|
87
|
+
app: 'annotate' | 'review';
|
|
88
|
+
/** Annotate: `gate`. Review: always true — review's primary decision IS approval. */
|
|
89
|
+
gate: boolean;
|
|
90
|
+
/** The count rendered in the pill and interpolated into labels. */
|
|
91
|
+
count: number;
|
|
92
|
+
/**
|
|
93
|
+
* Whether there is anything to send. Deliberately separate from `count`:
|
|
94
|
+
* annotate counts direct edits / saved-file changes / attachments as
|
|
95
|
+
* feedback with count 0 (`hasFeedbackContent` in the annotate app).
|
|
96
|
+
*/
|
|
97
|
+
hasFeedback: boolean;
|
|
98
|
+
/** Does the runtime deliver feedback on approve? Gates every approve-carrying item. */
|
|
99
|
+
approvalNotesSupported: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* M1 ruling: the session's feedback was already delivered through the
|
|
102
|
+
* annotate agent terminal, which is why `hasFeedback` reads false. The
|
|
103
|
+
* empty-flip state keeps its `Done` primary AND its transport (the outer
|
|
104
|
+
* agent's stdout consumer may never have seen the terminal delivery, so
|
|
105
|
+
* the full payload still posts) — only the copy changes, because "reviewed
|
|
106
|
+
* with no feedback" would be a lie in that state. Copy is free prose,
|
|
107
|
+
* NOT frozen.
|
|
108
|
+
*/
|
|
109
|
+
feedbackDelivered?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* PR6 (§3.4): presence selects the platform (PR/MR destination) arm — the
|
|
112
|
+
* same DecisionSpec shape with NO composer and NO confirm items, ever:
|
|
113
|
+
* every platform action opens the existing ReviewSubmissionDialog, whose
|
|
114
|
+
* own general-comment textarea is the only note field on that side (a
|
|
115
|
+
* second composer would double-post via buildFileScopedBody).
|
|
116
|
+
* `approvalNotesSupported` is deliberately ignored by this arm — the
|
|
117
|
+
* platform posts to the forge API natively — so approve-carrying items
|
|
118
|
+
* gate only on `selfAuthored`, muted rather than removed.
|
|
119
|
+
*/
|
|
120
|
+
platform?: DecisionPlatformInput;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface DecisionPlatformInput {
|
|
124
|
+
/** Platform display name ('GitHub' / 'GitLab') — named in the self-approval tooltip. */
|
|
125
|
+
label: string;
|
|
126
|
+
/** The PR/MR noun ('PR' / 'MR') the short mute reason uses. */
|
|
127
|
+
mrLabel: string;
|
|
128
|
+
/** The viewer authored this PR/MR: approve paths mute, never disappear. */
|
|
129
|
+
selfAuthored: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const DECISION_NOTE_PLACEHOLDER = 'Add a note...';
|
|
133
|
+
|
|
134
|
+
function annotationNoun(count: number): string {
|
|
135
|
+
return count === 1 ? 'annotation' : 'annotations';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The empty state: no feedback to send, the primary is the positive finish.
|
|
140
|
+
* `approvalFlow` (gate annotate, or review) makes it `Approve`; plain annotate
|
|
141
|
+
* gets `Done`.
|
|
142
|
+
*/
|
|
143
|
+
function buildEmptySpec(input: DecisionSpecInput, approvalFlow: boolean): DecisionSpec {
|
|
144
|
+
// "Approve with a note…" carries a note on the approve channel, which four
|
|
145
|
+
// runtimes still discard — it renders only where the advert says delivery
|
|
146
|
+
// works (never an item that silently drops content). Non-gate annotate has
|
|
147
|
+
// no approve channel and no positive-note item at all: the maintainer ruled
|
|
148
|
+
// the old "Done with a note…" / "Request changes…" pair collapsed into the
|
|
149
|
+
// single "Send a note…" below, because their only difference was framing on
|
|
150
|
+
// the same /api/feedback transport.
|
|
151
|
+
const positive: DecisionMenuItem | null = approvalFlow && input.approvalNotesSupported
|
|
152
|
+
? {
|
|
153
|
+
id: 'note-with-approval',
|
|
154
|
+
label: 'Approve with a note…',
|
|
155
|
+
subtitle: 'Approve and send a short note with it',
|
|
156
|
+
tone: 'success',
|
|
157
|
+
icon: 'check',
|
|
158
|
+
composer: {
|
|
159
|
+
title: 'Approve with a note',
|
|
160
|
+
actionLabel: 'Approve and send note',
|
|
161
|
+
tone: 'success',
|
|
162
|
+
icon: 'check',
|
|
163
|
+
placeholder: DECISION_NOTE_PLACEHOLDER,
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
: null;
|
|
167
|
+
|
|
168
|
+
const requestChanges: DecisionMenuItem = approvalFlow
|
|
169
|
+
? {
|
|
170
|
+
id: 'request-changes',
|
|
171
|
+
// Frozen copy (maintainer-approved): 'Request changes…'.
|
|
172
|
+
label: 'Request changes…',
|
|
173
|
+
subtitle: 'Write overall feedback, sent as a change request',
|
|
174
|
+
tone: 'primary',
|
|
175
|
+
icon: 'send',
|
|
176
|
+
dividerBefore: positive !== null,
|
|
177
|
+
composer: {
|
|
178
|
+
title: 'Request changes',
|
|
179
|
+
actionLabel: 'Send as feedback',
|
|
180
|
+
tone: 'primary',
|
|
181
|
+
icon: 'send',
|
|
182
|
+
placeholder: DECISION_NOTE_PLACEHOLDER,
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
: {
|
|
186
|
+
// Maintainer ruling (empty-menu collapse): the one non-gate composer.
|
|
187
|
+
// Same id and route as the old change request (plain /api/feedback,
|
|
188
|
+
// no approval framing) — only the copy is new. Free prose, NOT frozen.
|
|
189
|
+
id: 'request-changes',
|
|
190
|
+
label: 'Send a note…',
|
|
191
|
+
subtitle: 'Write a note and send it as feedback',
|
|
192
|
+
tone: 'primary',
|
|
193
|
+
icon: 'send',
|
|
194
|
+
dividerBefore: false,
|
|
195
|
+
composer: {
|
|
196
|
+
title: 'Send a note',
|
|
197
|
+
actionLabel: 'Send as feedback',
|
|
198
|
+
tone: 'primary',
|
|
199
|
+
icon: 'send',
|
|
200
|
+
placeholder: DECISION_NOTE_PLACEHOLDER,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
primary: approvalFlow
|
|
206
|
+
? {
|
|
207
|
+
id: 'primary',
|
|
208
|
+
// Frozen copy (maintainer-approved): 'Approve'.
|
|
209
|
+
label: 'Approve',
|
|
210
|
+
title: 'Approve: no changes requested',
|
|
211
|
+
tone: 'success',
|
|
212
|
+
icon: 'check',
|
|
213
|
+
}
|
|
214
|
+
: {
|
|
215
|
+
id: 'primary',
|
|
216
|
+
// Frozen copy (maintainer-approved): 'Done'.
|
|
217
|
+
label: 'Done',
|
|
218
|
+
// M1 ruling: in the agent-terminal delivered state the transport is
|
|
219
|
+
// unchanged (the full payload still posts, because the outer agent
|
|
220
|
+
// on stdout may never have seen the terminal delivery), so the
|
|
221
|
+
// tooltip must not claim "no feedback". Free prose, NOT frozen.
|
|
222
|
+
title: input.feedbackDelivered
|
|
223
|
+
? 'Finish: sends the session record (feedback already shared in the terminal)'
|
|
224
|
+
: 'Finish: records that you reviewed with no feedback',
|
|
225
|
+
// Maintainer ruling (post-demo): Done without a gate is a positive
|
|
226
|
+
// finish, NOT an approval — no success tone, no check icon, so it
|
|
227
|
+
// can never be mistaken for the gate/review Approve.
|
|
228
|
+
tone: 'neutral',
|
|
229
|
+
},
|
|
230
|
+
items: positive ? [positive, requestChanges] : [requestChanges],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The feedback state: something to send, the primary is `Send Feedback`.
|
|
236
|
+
* Identical across apps; only the discard item's verb follows the flow.
|
|
237
|
+
*/
|
|
238
|
+
function buildFeedbackSpec(input: DecisionSpecInput, approvalFlow: boolean): DecisionSpec {
|
|
239
|
+
const { count } = input;
|
|
240
|
+
const noun = annotationNoun(count);
|
|
241
|
+
|
|
242
|
+
const items: DecisionMenuItem[] = [
|
|
243
|
+
{
|
|
244
|
+
id: 'note-with-feedback',
|
|
245
|
+
label: 'Send with a note…',
|
|
246
|
+
subtitle:
|
|
247
|
+
count > 0
|
|
248
|
+
? `Add an overall note on top of your ${count} ${noun}`
|
|
249
|
+
: 'Add an overall note on top of your feedback',
|
|
250
|
+
tone: 'primary',
|
|
251
|
+
icon: 'send',
|
|
252
|
+
composer: {
|
|
253
|
+
title: 'Send with a note',
|
|
254
|
+
actionLabel: 'Send feedback with note',
|
|
255
|
+
tone: 'primary',
|
|
256
|
+
icon: 'send',
|
|
257
|
+
placeholder: DECISION_NOTE_PLACEHOLDER,
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
// The divider separates "send" alternates from "approve away" alternates —
|
|
263
|
+
// it sits before whichever approve-flavoured item comes first.
|
|
264
|
+
let dividerPending = true;
|
|
265
|
+
|
|
266
|
+
// Capability-gated only (F2 ruling, maintainer-confirmed): at count 0 the feedback is direct edits / saved-file
|
|
267
|
+
// changes / attachments, and a capable approve transport delivers those too,
|
|
268
|
+
// so the item stays offered with zero-form copy — no "0 annotations"
|
|
269
|
+
// language. Subtitles are free prose, NOT frozen.
|
|
270
|
+
if (approvalFlow && input.approvalNotesSupported) {
|
|
271
|
+
items.push({
|
|
272
|
+
id: 'approve-with-notes',
|
|
273
|
+
// Frozen copy (maintainer-approved): 'Approve with notes'.
|
|
274
|
+
label: 'Approve with notes',
|
|
275
|
+
subtitle:
|
|
276
|
+
count > 0
|
|
277
|
+
? `Approve; your ${count} ${noun} ride along as non-blocking guidance`
|
|
278
|
+
: 'Approve; your edits and attachments ride along as non-blocking guidance',
|
|
279
|
+
tone: 'success',
|
|
280
|
+
icon: 'check',
|
|
281
|
+
dividerBefore: dividerPending,
|
|
282
|
+
});
|
|
283
|
+
dividerPending = false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Destructive by definition — it throws the annotations away — so it always
|
|
287
|
+
// carries the one confirm the new model keeps. With count 0 there is nothing
|
|
288
|
+
// to discard and the item is omitted.
|
|
289
|
+
if (count > 0) {
|
|
290
|
+
items.push({
|
|
291
|
+
id: 'discard-and-finish',
|
|
292
|
+
label: approvalFlow
|
|
293
|
+
? `Approve, discard ${count} ${noun}…`
|
|
294
|
+
: `Done, discard ${count} ${noun}…`,
|
|
295
|
+
subtitle: 'Asks to confirm: the annotations are not sent',
|
|
296
|
+
tone: 'destructive',
|
|
297
|
+
icon: 'check',
|
|
298
|
+
dividerBefore: dividerPending,
|
|
299
|
+
// L5: neutral wording — the count can include findings from other
|
|
300
|
+
// tools, and the non-gate record still carries any direct edits.
|
|
301
|
+
// Free prose, NOT frozen.
|
|
302
|
+
confirm: approvalFlow
|
|
303
|
+
? {
|
|
304
|
+
title: `Discard ${count} ${noun} and approve?`,
|
|
305
|
+
message:
|
|
306
|
+
'These annotations are change requests, including any from other tools. Approving without them tells the agent no changes are needed.',
|
|
307
|
+
// Frozen copy (maintainer-approved): 'Discard & approve'.
|
|
308
|
+
confirmText: 'Discard & approve',
|
|
309
|
+
}
|
|
310
|
+
: {
|
|
311
|
+
title: `Discard ${count} ${noun} and finish?`,
|
|
312
|
+
message:
|
|
313
|
+
'These annotations are change requests, including any from other tools. Finishing without them sends a positive review record; any direct edits still ride along.',
|
|
314
|
+
// Frozen copy (maintainer-approved): 'Discard & finish'.
|
|
315
|
+
confirmText: 'Discard & finish',
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
primary: {
|
|
322
|
+
id: 'primary',
|
|
323
|
+
// Frozen copy (maintainer-approved): 'Send Feedback'.
|
|
324
|
+
label: 'Send Feedback',
|
|
325
|
+
shortLabel: 'Send',
|
|
326
|
+
mobileLabel: 'Send feedback',
|
|
327
|
+
title: 'Send your feedback to the agent',
|
|
328
|
+
tone: 'primary',
|
|
329
|
+
icon: 'send',
|
|
330
|
+
count: count > 0 ? count : undefined,
|
|
331
|
+
},
|
|
332
|
+
items,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The platform (PR) arm — PR6, §3.4, per the approved DESIGN_header-pr-mode
|
|
338
|
+
* mock. Reuses the agent ids so the handler Record stays closed, but every
|
|
339
|
+
* item is composer-less and confirm-less: labels tell the reviewer which mode
|
|
340
|
+
* the ReviewSubmissionDialog opens in, nothing more. `discard-and-finish` is
|
|
341
|
+
* never emitted — the dialog owns what happens to unsent annotations.
|
|
342
|
+
*/
|
|
343
|
+
function buildPlatformSpec(input: DecisionSpecInput, platform: DecisionPlatformInput): DecisionSpec {
|
|
344
|
+
const { count } = input;
|
|
345
|
+
const { selfAuthored } = platform;
|
|
346
|
+
const noun = platform.mrLabel === 'MR' ? 'merge request' : 'pull request';
|
|
347
|
+
// Frozen copy (maintainer-approved): the self-approval mute reason.
|
|
348
|
+
const selfReason = `You can't approve your own ${noun} on ${platform.label}.`;
|
|
349
|
+
const selfReasonShort = `You can't approve your own ${platform.mrLabel}`;
|
|
350
|
+
|
|
351
|
+
if (!input.hasFeedback) {
|
|
352
|
+
return {
|
|
353
|
+
primary: {
|
|
354
|
+
id: 'primary',
|
|
355
|
+
// Frozen copy (maintainer-approved): 'Approve'.
|
|
356
|
+
label: 'Approve',
|
|
357
|
+
title: selfAuthored ? selfReason : 'Approve: no changes needed',
|
|
358
|
+
tone: 'success',
|
|
359
|
+
icon: 'check',
|
|
360
|
+
...(selfAuthored ? { muted: true } : {}),
|
|
361
|
+
},
|
|
362
|
+
items: [
|
|
363
|
+
{
|
|
364
|
+
id: 'note-with-approval',
|
|
365
|
+
// Approved design (PR6 detail confirmed): the empty-state platform
|
|
366
|
+
// menu is "Approve with a comment…" + "Request changes…".
|
|
367
|
+
label: 'Approve with a comment…',
|
|
368
|
+
subtitle: selfAuthored
|
|
369
|
+
? selfReasonShort
|
|
370
|
+
: 'Opens the submission dialog; the comment rides the review body',
|
|
371
|
+
tone: 'success',
|
|
372
|
+
icon: 'check',
|
|
373
|
+
...(selfAuthored ? { muted: true } : {}),
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
id: 'request-changes',
|
|
377
|
+
// Frozen copy (maintainer-approved): 'Request changes…'.
|
|
378
|
+
label: 'Request changes…',
|
|
379
|
+
subtitle: 'Overall feedback, zero line comments, via the dialog',
|
|
380
|
+
tone: 'primary',
|
|
381
|
+
icon: 'send',
|
|
382
|
+
dividerBefore: true,
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
primary: {
|
|
390
|
+
id: 'primary',
|
|
391
|
+
// Frozen copy (maintainer-approved): 'Post Comments'.
|
|
392
|
+
label: 'Post Comments',
|
|
393
|
+
shortLabel: 'Post',
|
|
394
|
+
mobileLabel: 'Post comments',
|
|
395
|
+
title: `Post review to ${platform.label}`,
|
|
396
|
+
tone: 'primary',
|
|
397
|
+
icon: 'send',
|
|
398
|
+
count: count > 0 ? count : undefined,
|
|
399
|
+
},
|
|
400
|
+
items: [
|
|
401
|
+
{
|
|
402
|
+
id: 'approve-with-notes',
|
|
403
|
+
label: 'Approve with comments…',
|
|
404
|
+
subtitle: selfAuthored ? selfReasonShort : 'Submission dialog in approve mode',
|
|
405
|
+
tone: 'success',
|
|
406
|
+
icon: 'check',
|
|
407
|
+
...(selfAuthored ? { muted: true } : {}),
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
id: 'note-with-feedback',
|
|
411
|
+
label: 'Post comments, then…',
|
|
412
|
+
subtitle: 'Request changes or stay neutral, chosen in the dialog',
|
|
413
|
+
tone: 'primary',
|
|
414
|
+
icon: 'send',
|
|
415
|
+
dividerBefore: true,
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function buildDecisionSpec(input: DecisionSpecInput): DecisionSpec {
|
|
422
|
+
// PR6 (§3.4): the platform destination maps onto the same spec shape with
|
|
423
|
+
// no composer items; presence of the arm selects it outright.
|
|
424
|
+
if (input.platform) return buildPlatformSpec(input, input.platform);
|
|
425
|
+
// Review's primary positive decision IS approval, gate flag or not.
|
|
426
|
+
const approvalFlow = input.app === 'review' || input.gate;
|
|
427
|
+
return input.hasFeedback
|
|
428
|
+
? buildFeedbackSpec(input, approvalFlow)
|
|
429
|
+
: buildEmptySpec(input, approvalFlow);
|
|
430
|
+
}
|
package/utils/platform.ts
CHANGED
|
@@ -8,4 +8,20 @@ export const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(
|
|
|
8
8
|
export const modKey = isMac ? '⌘' : 'Ctrl';
|
|
9
9
|
export const altKey = isMac ? '⌥' : 'Alt';
|
|
10
10
|
export const submitHint = isMac ? '⌘↵' : 'Ctrl+Enter';
|
|
11
|
+
/**
|
|
12
|
+
* The primary modifier spelled for PROSE ("Cmd+click"), where `modKey`'s ⌘
|
|
13
|
+
* glyph reads as an artifact rather than a word. Use this in sentences;
|
|
14
|
+
* use `modKey` in key hints and shortcut chips.
|
|
15
|
+
*/
|
|
16
|
+
export const modKeyWord = isMac ? 'Cmd' : 'Ctrl';
|
|
17
|
+
/**
|
|
18
|
+
* The primary modifier's own `KeyboardEvent.key` name, for features that arm
|
|
19
|
+
* on the modifier being HELD rather than on a chord. Pair with `isModKeyHeld`:
|
|
20
|
+
* this identifies the key itself, that reads the held flag off any event.
|
|
21
|
+
*/
|
|
22
|
+
export const modEventKey = isMac ? 'Meta' : 'Control';
|
|
23
|
+
/** Whether the primary modifier is down for this event, on this platform. */
|
|
24
|
+
export function isModKeyHeld(event: { metaKey: boolean; ctrlKey: boolean }): boolean {
|
|
25
|
+
return isMac ? event.metaKey : event.ctrlKey;
|
|
26
|
+
}
|
|
11
27
|
export const isWindows = typeof navigator !== 'undefined' && /^Win/.test(navigator.platform);
|
package/utils/vimScroll.ts
CHANGED
|
@@ -142,7 +142,7 @@ export function scrollVimTargetIntoView(
|
|
|
142
142
|
|
|
143
143
|
const margin = resolveVimScrollMargin(viewportRect.height);
|
|
144
144
|
const stickyBottom = viewport
|
|
145
|
-
.querySelector<HTMLElement>('[data-sticky-actions]')
|
|
145
|
+
.querySelector<HTMLElement>('[data-viewer-document-header], [data-sticky-actions]')
|
|
146
146
|
?.getBoundingClientRect().bottom;
|
|
147
147
|
const topMargin = stickyBottom !== undefined
|
|
148
148
|
? Math.max(margin, stickyBottom - viewportRect.top + 8)
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>Sprite Animation</title>
|
|
7
|
-
<style>
|
|
8
|
-
body {
|
|
9
|
-
display: flex;
|
|
10
|
-
justify-content: center;
|
|
11
|
-
align-items: center;
|
|
12
|
-
height: 100vh;
|
|
13
|
-
background-color: #f0f0f0;
|
|
14
|
-
margin: 0;
|
|
15
|
-
}
|
|
16
|
-
.animation {
|
|
17
|
-
width: 176px;
|
|
18
|
-
height: 96px;
|
|
19
|
-
background: url("sprite.png") left center;
|
|
20
|
-
animation: play 5s steps(24) infinite;
|
|
21
|
-
/* image-rendering: pixelated; */ /* Uncomment if resizing up */
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
@keyframes play {
|
|
25
|
-
to {
|
|
26
|
-
background-position: -4224px 0;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
</style>
|
|
30
|
-
</head>
|
|
31
|
-
<body>
|
|
32
|
-
<div class="animation"></div>
|
|
33
|
-
</body>
|
|
34
|
-
</html>
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>Sprite Animation</title>
|
|
7
|
-
<style>
|
|
8
|
-
body {
|
|
9
|
-
display: flex;
|
|
10
|
-
justify-content: center;
|
|
11
|
-
align-items: center;
|
|
12
|
-
height: 100vh;
|
|
13
|
-
background-color: #f0f0f0;
|
|
14
|
-
margin: 0;
|
|
15
|
-
}
|
|
16
|
-
.animation {
|
|
17
|
-
width: 96px;
|
|
18
|
-
height: 96px;
|
|
19
|
-
background: url("sprite.png") left center;
|
|
20
|
-
animation: play 3s steps(12) infinite;
|
|
21
|
-
/* image-rendering: pixelated; */ /* Uncomment if resizing up */
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
@keyframes play {
|
|
25
|
-
to {
|
|
26
|
-
background-position: -1152px 0;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
</style>
|
|
30
|
-
</head>
|
|
31
|
-
<body>
|
|
32
|
-
<div class="animation"></div>
|
|
33
|
-
</body>
|
|
34
|
-
</html>
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>Sprite Animation</title>
|
|
7
|
-
<style>
|
|
8
|
-
body {
|
|
9
|
-
display: flex;
|
|
10
|
-
justify-content: center;
|
|
11
|
-
align-items: center;
|
|
12
|
-
height: 100vh;
|
|
13
|
-
background-color: #f0f0f0;
|
|
14
|
-
margin: 0;
|
|
15
|
-
}
|
|
16
|
-
.animation {
|
|
17
|
-
width: 96px;
|
|
18
|
-
height: 96px;
|
|
19
|
-
background: url("sprite.png") left center;
|
|
20
|
-
animation: play 3s steps(24) infinite;
|
|
21
|
-
/* image-rendering: pixelated; */ /* Uncomment if resizing up */
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
@keyframes play {
|
|
25
|
-
to {
|
|
26
|
-
background-position: -2304px 0;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
</style>
|
|
30
|
-
</head>
|
|
31
|
-
<body>
|
|
32
|
-
<div class="animation"></div>
|
|
33
|
-
</body>
|
|
34
|
-
</html>
|