mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix: address review findings (#3461)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
## Design System
|
||||
|
||||
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
|
||||
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow `[docs/STYLEGUIDE.md](./docs/STYLEGUIDE.md)`. Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the rewe solution order in its final section.
|
||||
|
||||
## Code Comments: Document the "Why", Briefly
|
||||
|
||||
@@ -37,8 +37,9 @@ Source-control and review changes must consider GitLab and other supported git p
|
||||
## GitHub CLI Usage
|
||||
|
||||
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
|
||||
|
||||
Never commit PR evidence images; attach them to the PR conversation instead.
|
||||
|
||||
## Type Declarations: Prefer `.ts` Over `.d.ts`
|
||||
|
||||
Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for ambient shims (e.g., `env.d.ts`, `vite/client.d.ts`). TypeScript's `skipLibCheck: true` setting applies globally, including to our own `.d.ts` files, which means any unresolved type reference in a `.d.ts` silently becomes `any` at its call sites. Write your types in `.ts` files so the compiler actually checks them. CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-typecheck-hole.md`.
|
||||
Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for ambient shims (e.g., `env.d.ts`, `vite/client.d.ts`). TypeScript's `skipLibCheck: true` setting applies globally, including to our own `.d.ts` files, which means any unresolved type reference in a `.d.ts` silently becomes `any` at its call sites. Write your types in `.ts` files so the compiler actually checks them. CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-typecheck-hole.md`.
|
||||
+181
-151
@@ -2385,194 +2385,218 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
|
||||
overlay first and never reach our buttons. Lifting the whole card creates
|
||||
a stacking context that keeps every nested control clickable. */
|
||||
z-index: 1;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||
border-left: 3px solid color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 5%, var(--editor-surface));
|
||||
padding: 6px 8px 6px 10px;
|
||||
font-family: var(--font-sans, system-ui, sans-serif);
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, var(--border));
|
||||
border-radius: 8px;
|
||||
background: var(--background);
|
||||
padding: 12px 14px;
|
||||
font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.04),
|
||||
0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.dark .orca-diff-comment-card {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, var(--editor-surface));
|
||||
border-color: color-mix(in srgb, var(--foreground) 12%, var(--border));
|
||||
background: color-mix(in srgb, var(--foreground) 2%, var(--editor-surface));
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.2),
|
||||
0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Avatar Column */
|
||||
.orca-diff-comment-avatar-col {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Content Column */
|
||||
.orca-diff-comment-content-col {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Header Row */
|
||||
.orca-diff-comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.orca-diff-comment-meta {
|
||||
font-size: 11px;
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.orca-diff-comment-author-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Meta Group (Name, line, timestamp) */
|
||||
.orca-diff-comment-meta-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0 0 6px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.orca-diff-comment-avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
background: var(--muted);
|
||||
/* Action button pill: segmented control like Notion's Resolve / ... */
|
||||
.orca-diff-comment-actions-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, var(--border));
|
||||
border-radius: 6px;
|
||||
background: var(--background);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.orca-diff-comment-avatar-fallback {
|
||||
.dark .orca-diff-comment-actions-pill {
|
||||
border-color: color-mix(in srgb, var(--foreground) 14%, var(--border));
|
||||
background: color-mix(in srgb, var(--foreground) 4%, var(--editor-surface));
|
||||
}
|
||||
|
||||
.orca-diff-comment-pill-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
height: 24px;
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
color 100ms ease;
|
||||
}
|
||||
|
||||
.orca-diff-comment-pill-btn:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.orca-diff-comment-pill-btn-danger:hover {
|
||||
background: color-mix(in srgb, var(--destructive) 10%, transparent);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.orca-diff-comment-pill-divider {
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background: color-mix(in srgb, var(--foreground) 12%, var(--border));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Avatar design */
|
||||
.orca-diff-comment-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
object-fit: cover;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.orca-diff-comment-avatar-fallback,
|
||||
.orca-diff-comment-avatar-local {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--muted));
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
color: var(--primary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.orca-diff-comment-author {
|
||||
.orca-diff-comment-avatar-local {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, var(--muted));
|
||||
border-color: color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Quote/Reference block: Warm, gorgeous Notion-like callout */
|
||||
.orca-diff-comment-quote {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 4px 0 6px 0;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid color-mix(in srgb, #e7b10a 35%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.orca-diff-comment-quote-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orca-diff-comment-created-at {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.orca-diff-comment-link {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.orca-diff-comment-link:hover {
|
||||
color: var(--foreground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Why: keep edit + delete close together but visually distinct from the meta
|
||||
label. Both share the same subtle button styling; only delete recolours on
|
||||
hover (destructive intent). */
|
||||
.orca-diff-comment-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.orca-diff-comment-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.orca-diff-comment-edit:hover {
|
||||
color: var(--foreground);
|
||||
border-color: color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
}
|
||||
|
||||
/* Why: the trash button is always visible (not hover-only) because the saved
|
||||
note is a persistent, action-bearing card — users should be able to see and
|
||||
target the delete affordance without guessing that hovering reveals it.
|
||||
Pointer-events are explicit since the card lives inside a Monaco view zone
|
||||
where stray handlers higher in the tree have swallowed pointer events in
|
||||
the past. z-index keeps it above the note body for click reliability. */
|
||||
.orca-diff-comment-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.orca-diff-comment-delete:hover {
|
||||
color: var(--destructive);
|
||||
border-color: color-mix(in srgb, var(--destructive) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--destructive) 10%, transparent);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Comment Body */
|
||||
.orca-diff-comment-body {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.orca-diff-comment-review-body {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
/* Why: mirror the saved-note card treatment so the "entering" and "saved"
|
||||
states read as the same object at two stages of the same flow. Same
|
||||
foreground-tinted fill over --editor-surface, same border + left accent
|
||||
bar, same shadow. Kept one level stronger on the shadow because the
|
||||
popover floats above the editor instead of being laid into a view zone. */
|
||||
/* Popover for adding new comment */
|
||||
.orca-diff-comment-popover {
|
||||
position: absolute;
|
||||
left: 56px;
|
||||
right: 24px;
|
||||
max-width: 420px;
|
||||
z-index: 1000;
|
||||
padding: 8px 8px 8px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||
border-left: 3px solid color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 5%, var(--editor-surface));
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, var(--border));
|
||||
border-radius: 8px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
||||
box-shadow:
|
||||
0 10px 32px rgba(0, 0, 0, 0.12),
|
||||
0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dark .orca-diff-comment-popover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, var(--editor-surface));
|
||||
border-color: color-mix(in srgb, var(--foreground) 14%, var(--border));
|
||||
background: color-mix(in srgb, var(--foreground) 2%, var(--editor-surface));
|
||||
box-shadow:
|
||||
0 10px 32px rgba(0, 0, 0, 0.3),
|
||||
0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.orca-diff-comment-popover-label {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.orca-diff-comment-popover-textarea {
|
||||
@@ -2581,19 +2605,28 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||
border-radius: 4px;
|
||||
/* Why: match the editor surface so the input reads as embedded in the card
|
||||
rather than a pure-white/pure-black cutout pasted on top. */
|
||||
background: var(--editor-surface);
|
||||
padding: 8px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 12%, var(--border));
|
||||
border-radius: 6px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
outline: none;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--muted-foreground, #737373) 34%, transparent) transparent;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.dark .orca-diff-comment-popover-textarea {
|
||||
border-color: color-mix(in srgb, var(--foreground) 16%, var(--border));
|
||||
background: color-mix(in srgb, var(--foreground) 3%, var(--editor-surface));
|
||||
}
|
||||
|
||||
.orca-diff-comment-popover-textarea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
}
|
||||
|
||||
.orca-diff-comment-popover-textarea::-webkit-scrollbar {
|
||||
@@ -2610,13 +2643,10 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.orca-diff-comment-popover-textarea:focus {
|
||||
border-color: color-mix(in srgb, var(--primary) 60%, var(--border));
|
||||
}
|
||||
|
||||
.orca-diff-comment-popover-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -211,8 +211,7 @@
|
||||
}
|
||||
|
||||
.markdown-review-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
position: relative;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
@@ -254,7 +253,7 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-review-toolbar-button:hover,
|
||||
.markdown-review-toolbar-button:hover:not(:disabled),
|
||||
.markdown-review-toolbar-button[aria-expanded='true'],
|
||||
.markdown-review-icon-button:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--border) 82%, transparent);
|
||||
@@ -262,6 +261,11 @@
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.markdown-review-toolbar-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.markdown-review-icon-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
@@ -281,157 +285,49 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.markdown-review-panel {
|
||||
display: flex;
|
||||
width: 310px;
|
||||
min-width: 260px;
|
||||
max-width: 36%;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--background) 90%, var(--editor-surface));
|
||||
}
|
||||
|
||||
.markdown-review-panel-header {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
||||
padding: 0 8px 0 12px;
|
||||
}
|
||||
|
||||
.markdown-review-panel-title,
|
||||
.markdown-review-panel-actions {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.markdown-review-panel-title {
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-review-panel-actions {
|
||||
flex-shrink: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.markdown-review-note-list {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.markdown-review-empty {
|
||||
padding: 8px 4px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.markdown-review-note {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.markdown-review-note + .markdown-review-note {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.markdown-review-note.is-active {
|
||||
border-color: color-mix(in srgb, var(--foreground) 28%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
}
|
||||
|
||||
.markdown-review-note-anchor {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
padding: 4px 5px 6px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-review-note-anchor:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.markdown-review-note-line {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.markdown-review-note-excerpt {
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.markdown-review-note .orca-diff-comment-card {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.markdown-review-panel {
|
||||
width: 260px;
|
||||
max-width: 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 560px) {
|
||||
.markdown-review-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 32;
|
||||
width: min(310px, 82cqw);
|
||||
min-width: 0;
|
||||
max-width: calc(100cqw - 36px);
|
||||
box-shadow: -10px 0 24px rgb(0 0 0 / 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-annotation-block {
|
||||
.markdown-annotation-block,
|
||||
.markdown-annotation-list-block {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(260px, min(34cqw, 360px));
|
||||
column-gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.markdown-annotation-block > :not(.markdown-annotation-controls),
|
||||
.markdown-annotation-list-content {
|
||||
grid-column: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.markdown-annotation-block.has-review-notes > :not(.markdown-annotation-controls),
|
||||
.markdown-annotation-list-block.has-review-notes > .markdown-annotation-list-content {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markdown-annotation-block:hover > .markdown-annotation-controls > .markdown-annotation-add,
|
||||
.markdown-annotation-block:focus-within > .markdown-annotation-controls > .markdown-annotation-add,
|
||||
li:hover > .markdown-annotation-controls > .markdown-annotation-add,
|
||||
li:focus-within > .markdown-annotation-controls > .markdown-annotation-add {
|
||||
.markdown-annotation-list-block:hover > .markdown-annotation-controls > .markdown-annotation-add,
|
||||
.markdown-annotation-list-block:focus-within
|
||||
> .markdown-annotation-controls
|
||||
> .markdown-annotation-add {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.markdown-annotation-controls {
|
||||
margin-top: 6px;
|
||||
/* Why: note cards must share the document scroll with their source block;
|
||||
laying the rail into each block avoids a separate right-side viewport. */
|
||||
position: relative;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-annotation-add {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: -26px;
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -462,8 +358,20 @@ li:focus-within > .markdown-annotation-controls > .markdown-annotation-add {
|
||||
|
||||
.markdown-annotation-composer,
|
||||
.markdown-annotation-card {
|
||||
max-width: 520px;
|
||||
margin-top: 6px;
|
||||
max-width: none;
|
||||
margin-top: 0;
|
||||
scroll-margin: 12px;
|
||||
}
|
||||
|
||||
.markdown-annotation-note-stack {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.markdown-annotation-composer + .markdown-annotation-note-stack {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.markdown-annotation-card.is-active > .orca-diff-comment-card {
|
||||
@@ -471,6 +379,31 @@ li:focus-within > .markdown-annotation-controls > .markdown-annotation-add {
|
||||
background: color-mix(in srgb, var(--foreground) 8%, var(--editor-surface));
|
||||
}
|
||||
|
||||
.markdown-annotation-card.is-attention > .orca-diff-comment-card {
|
||||
animation: markdown-note-attention 900ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes markdown-note-attention {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--ring) 55%, transparent),
|
||||
0 0 0 8px color-mix(in srgb, var(--ring) 16%, transparent);
|
||||
}
|
||||
|
||||
35% {
|
||||
transform: translateX(-3px);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--ring) 45%, transparent),
|
||||
0 0 0 5px color-mix(in srgb, var(--ring) 12%, transparent);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-annotation-composer {
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||
border-radius: 6px;
|
||||
@@ -479,6 +412,26 @@ li:focus-within > .markdown-annotation-controls > .markdown-annotation-add {
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
@container (max-width: 760px) {
|
||||
.markdown-annotation-block,
|
||||
.markdown-annotation-list-block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-annotation-controls {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.markdown-annotation-add {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.markdown-annotation-composer,
|
||||
.markdown-annotation-note-stack {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
|
||||
@@ -73,6 +73,27 @@
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
|
||||
.rich-markdown-annotation-selection-active {
|
||||
animation: rich-markdown-source-attention 900ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes rich-markdown-source-attention {
|
||||
0% {
|
||||
background: color-mix(in srgb, var(--annotation-highlight) 44%, transparent);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--annotation-highlight) 16%, transparent);
|
||||
}
|
||||
|
||||
42% {
|
||||
background: color-mix(in srgb, var(--annotation-highlight) 34%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--annotation-highlight) 12%, transparent);
|
||||
}
|
||||
|
||||
100% {
|
||||
background: color-mix(in srgb, var(--annotation-highlight) 22%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rich-markdown-editor-shell.has-rich-markdown-review-notes .rich-markdown-editor {
|
||||
padding-right: min(360px, 34%);
|
||||
}
|
||||
@@ -129,7 +150,6 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 16px;
|
||||
bottom: 0;
|
||||
width: min(300px, 30%);
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
@@ -142,6 +162,36 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.rich-markdown-review-note-card.is-active > .orca-diff-comment-card {
|
||||
border-color: color-mix(in srgb, var(--foreground) 32%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 8%, var(--editor-surface));
|
||||
}
|
||||
|
||||
.rich-markdown-review-note-card.is-attention > .orca-diff-comment-card {
|
||||
animation: rich-markdown-note-attention 900ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes rich-markdown-note-attention {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--ring) 55%, transparent),
|
||||
0 0 0 8px color-mix(in srgb, var(--ring) 16%, transparent);
|
||||
}
|
||||
|
||||
35% {
|
||||
transform: translateX(-3px);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--ring) 45%, transparent),
|
||||
0 0 0 5px color-mix(in srgb, var(--ring) 12%, transparent);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.rich-markdown-review-note-send {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { CornerDownLeft, Pencil, Trash } from 'lucide-react'
|
||||
import { CornerDownLeft, Pencil, Trash, FileText } from 'lucide-react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Why: the saved-note card lives inside a Monaco view zone's DOM node.
|
||||
// useDiffCommentDecorator creates a React root per zone and renders this
|
||||
@@ -17,11 +16,11 @@ import { cn } from '@/lib/utils'
|
||||
type Props = {
|
||||
lineNumber: number
|
||||
startLine?: number
|
||||
label?: string
|
||||
label?: string | null
|
||||
quote?: string
|
||||
body: string
|
||||
sentAt?: number
|
||||
author?: string
|
||||
authorAvatarUrl?: string
|
||||
createdAtLabel?: string
|
||||
url?: string
|
||||
onDelete?: () => void
|
||||
@@ -38,10 +37,10 @@ export function DiffCommentCard({
|
||||
lineNumber,
|
||||
startLine,
|
||||
label,
|
||||
quote,
|
||||
body,
|
||||
sentAt,
|
||||
author,
|
||||
authorAvatarUrl,
|
||||
createdAtLabel,
|
||||
url,
|
||||
onDelete,
|
||||
@@ -107,7 +106,11 @@ export function DiffCommentCard({
|
||||
|
||||
const trimmedDraft = draft.trim()
|
||||
const canSubmit = !submitting && trimmedDraft.length > 0 && trimmedDraft !== body
|
||||
const lineLabel = label ?? getDiffCommentLineLabel({ lineNumber, startLine }).toLowerCase()
|
||||
const lineLabel =
|
||||
label === undefined ? getDiffCommentLineLabel({ lineNumber, startLine }).toLowerCase() : label
|
||||
const metaText = [author || 'Note', lineLabel, createdAtLabel || (sentAt ? 'sent' : null)]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!canSubmit || !onSubmitEdit) {
|
||||
@@ -132,135 +135,135 @@ export function DiffCommentCard({
|
||||
|
||||
return (
|
||||
<div className="orca-diff-comment-card">
|
||||
<div className="orca-diff-comment-header">
|
||||
<span className="orca-diff-comment-meta">
|
||||
{author ? 'Review comment' : 'Note'} · {lineLabel}
|
||||
{sentAt ? ' · sent' : ''}
|
||||
</span>
|
||||
<div className="orca-diff-comment-actions">
|
||||
{!editing && headerActions}
|
||||
{onSubmitEdit && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
className="orca-diff-comment-edit"
|
||||
title="Edit note"
|
||||
aria-label="Edit note"
|
||||
<div className="orca-diff-comment-content-col">
|
||||
{/* Header Row */}
|
||||
<div className="orca-diff-comment-header">
|
||||
<div className="orca-diff-comment-meta-group">{metaText}</div>
|
||||
|
||||
{/* Action buttons pill (only shown if not editing) */}
|
||||
{!editing && (
|
||||
<div
|
||||
className="orca-diff-comment-actions-pill"
|
||||
onMouseDown={(ev) => ev.stopPropagation()}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
handleStartEdit()
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
className="orca-diff-comment-delete"
|
||||
title="Delete note"
|
||||
aria-label="Delete note"
|
||||
onMouseDown={(ev) => ev.stopPropagation()}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
<Trash className="size-3.5" />
|
||||
</button>
|
||||
{headerActions}
|
||||
{headerActions && (url || onSubmitEdit || onDelete) && (
|
||||
<span className="orca-diff-comment-pill-divider" />
|
||||
)}
|
||||
{url && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="orca-diff-comment-pill-btn"
|
||||
title="Open in browser"
|
||||
aria-label="Open in browser"
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
void window.api.shell.openUrl(url)
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
{(onSubmitEdit || onDelete) && (
|
||||
<span className="orca-diff-comment-pill-divider" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{onSubmitEdit && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="orca-diff-comment-pill-btn"
|
||||
title="Edit note"
|
||||
aria-label="Edit note"
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
handleStartEdit()
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
{onDelete && <span className="orca-diff-comment-pill-divider" />}
|
||||
</>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="orca-diff-comment-pill-btn orca-diff-comment-pill-btn-danger"
|
||||
title="Delete note"
|
||||
aria-label="Delete note"
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
<Trash className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{author ? (
|
||||
<div className="orca-diff-comment-author-row">
|
||||
{authorAvatarUrl ? (
|
||||
<img className="orca-diff-comment-avatar" src={authorAvatarUrl} alt="" />
|
||||
) : (
|
||||
<span className="orca-diff-comment-avatar orca-diff-comment-avatar-fallback">
|
||||
{author.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="orca-diff-comment-author">{author}</span>
|
||||
{createdAtLabel ? (
|
||||
<span className="orca-diff-comment-created-at">{createdAtLabel}</span>
|
||||
) : null}
|
||||
{url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="orca-diff-comment-link"
|
||||
onMouseDown={(ev) => ev.stopPropagation()}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
void window.api.shell.openUrl(url)
|
||||
|
||||
{/* Quote Block */}
|
||||
{quote ? (
|
||||
<div className="orca-diff-comment-quote">
|
||||
<FileText className="size-3.5 flex-shrink-0 text-amber-500 mt-0.5" />
|
||||
<div className="orca-diff-comment-quote-text">{quote}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Body or Edit Mode */}
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="orca-diff-comment-popover-textarea"
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value)
|
||||
const el = e.currentTarget
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 240)}px`
|
||||
onContentResizeRef.current?.()
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{editing ? (
|
||||
<>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="orca-diff-comment-popover-textarea"
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value)
|
||||
const el = e.currentTarget
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 240)}px`
|
||||
onContentResizeRef.current?.()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
// Why: plain Enter saves to mirror the new-note popover; Shift
|
||||
// +Enter keeps the newline. IME composition is excluded so a
|
||||
// CJK conversion-confirm keystroke doesn't submit a half-typed
|
||||
// note. Share the canSubmit predicate with the Save button so
|
||||
// Enter doesn't quietly close the editor when empty/unchanged
|
||||
// (the user must explicitly Cancel/Escape).
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
void handleSubmit()
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="orca-diff-comment-popover-footer">
|
||||
<Button variant="ghost" size="sm" onClick={handleCancel} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={!canSubmit}
|
||||
// Why: keep the label "Save" while submitting so the button
|
||||
// doesn't change width mid-flight; the disabled state alone
|
||||
// signals the in-flight save. The title attribute surfaces the
|
||||
// status for assistive tech.
|
||||
title={submitting ? 'Saving…' : undefined}
|
||||
>
|
||||
Save
|
||||
<CornerDownLeft className="ml-1 size-3 opacity-70" />
|
||||
</Button>
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) {
|
||||
return
|
||||
}
|
||||
void handleSubmit()
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="orca-diff-comment-popover-footer">
|
||||
<Button variant="ghost" size="sm" onClick={handleCancel} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={!canSubmit}
|
||||
title={submitting ? 'Saving…' : undefined}
|
||||
>
|
||||
Save
|
||||
<CornerDownLeft className="ml-1 size-3 opacity-70" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={cn('orca-diff-comment-body', author && 'orca-diff-comment-review-body')}>
|
||||
{body}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="orca-diff-comment-body">{body}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { CornerDownLeft } from 'lucide-react'
|
||||
import { CornerDownLeft, User } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// Why: rendered as a DOM sibling overlay inside the editor container rather
|
||||
@@ -119,55 +119,69 @@ export function DiffCommentPopover({
|
||||
onMouseDown={(ev) => ev.stopPropagation()}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
>
|
||||
<div id={labelId} className="orca-diff-comment-popover-label">
|
||||
{title ??
|
||||
(startLine && startLine !== lineNumber
|
||||
? `Lines ${startLine}-${lineNumber}`
|
||||
: `Line ${lineNumber}`)}
|
||||
{/* Left Column: Avatar */}
|
||||
<div className="orca-diff-comment-avatar-col">
|
||||
<span className="orca-diff-comment-avatar orca-diff-comment-avatar-local">
|
||||
<User className="size-3" />
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="orca-diff-comment-popover-textarea"
|
||||
placeholder={placeholder}
|
||||
value={body}
|
||||
onChange={(e) => {
|
||||
setBody(e.target.value)
|
||||
autoResize(e.currentTarget)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
// Why: plain Enter submits so the note popover behaves like a
|
||||
// single-field form. Shift+Enter inserts a newline (browser default)
|
||||
// so multi-line notes are still possible. We also accept
|
||||
// Cmd/Ctrl+Enter as a submit alias so users who learned the old
|
||||
// shortcut aren't silently broken. IME composition (isComposing) is
|
||||
// excluded because Enter during composition only confirms the
|
||||
// conversion candidate — submitting then would send a half-typed
|
||||
// note for CJK/IME users. We guard against a second Enter while an
|
||||
// earlier submit is still awaiting IPC — otherwise it would enqueue
|
||||
// a duplicate addDiffComment call.
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (submitting) {
|
||||
|
||||
{/* Right Column: Content */}
|
||||
<div className="orca-diff-comment-content-col" style={{ gap: '8px' }}>
|
||||
<div id={labelId} className="orca-diff-comment-popover-label">
|
||||
{title ??
|
||||
(startLine && startLine !== lineNumber
|
||||
? `Lines ${startLine}-${lineNumber}`
|
||||
: `Line ${lineNumber}`)}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="orca-diff-comment-popover-textarea"
|
||||
placeholder={placeholder}
|
||||
value={body}
|
||||
onChange={(e) => {
|
||||
setBody(e.target.value)
|
||||
autoResize(e.currentTarget)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
void handleSubmit()
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="orca-diff-comment-popover-footer">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={submitting || body.trim().length === 0}>
|
||||
{submitting ? submittingLabel : submitLabel}
|
||||
{!submitting && <CornerDownLeft className="ml-1 size-3 opacity-70" />}
|
||||
</Button>
|
||||
// Why: plain Enter submits so the note popover behaves like a
|
||||
// single-field form. Shift+Enter inserts a newline (browser default)
|
||||
// so multi-line notes are still possible. We also accept
|
||||
// Cmd/Ctrl+Enter as a submit alias so users who learned the old
|
||||
// shortcut aren't silently broken. IME composition (isComposing) is
|
||||
// excluded because Enter during composition only confirms the
|
||||
// conversion candidate — submitting then would send a half-typed
|
||||
// note for CJK/IME users. We guard against a second Enter while an
|
||||
// earlier submit is still awaiting IPC — otherwise it would enqueue
|
||||
// a duplicate addDiffComment call.
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (submitting) {
|
||||
return
|
||||
}
|
||||
void handleSubmit()
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="orca-diff-comment-popover-footer">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || body.trim().length === 0}
|
||||
>
|
||||
{submitting ? submittingLabel : submitLabel}
|
||||
{!submitting && <CornerDownLeft className="ml-1 size-3 opacity-70" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -70,9 +70,9 @@ type ZoneEntry = {
|
||||
// Why: card chrome (header/meta/border/padding) plus per-line body height. Used
|
||||
// in two places — the initial heightInPx estimate and the live resize during
|
||||
// inline edit — so keep them in lockstep.
|
||||
const ZONE_CHROME_PX = 52
|
||||
const ZONE_LINE_PX = 18
|
||||
const ZONE_MIN_PX = 72
|
||||
const ZONE_CHROME_PX = 68
|
||||
const ZONE_LINE_PX = 20
|
||||
const ZONE_MIN_PX = 88
|
||||
|
||||
function getRenderSignature(comment: DecoratedDiffComment): string {
|
||||
return JSON.stringify({
|
||||
@@ -478,7 +478,6 @@ export function useDiffCommentDecorator({
|
||||
body={comment.body}
|
||||
sentAt={comment.sentAt}
|
||||
author={comment.author}
|
||||
authorAvatarUrl={comment.authorAvatarUrl}
|
||||
createdAtLabel={comment.createdAtLabel}
|
||||
url={comment.url}
|
||||
onDelete={
|
||||
@@ -539,11 +538,10 @@ export function useDiffCommentDecorator({
|
||||
// insertion and does not re-measure the DOM node, so an underestimate
|
||||
// lets the card bleed into the following editor line. The constant
|
||||
// covers fixed chrome (inline wrapper padding ~10, card border 2, card
|
||||
// padding 12, header+meta ~22, trailing breathing room) and the
|
||||
// per-line factor matches the 12px/1.4 body line-height.
|
||||
// padding 12, header+meta ~24, body margin 2) and the per-line factor
|
||||
// matches the 13.5px/1.5 body line-height.
|
||||
const lineCount = c.body.split('\n').length
|
||||
const chromePx = c.author ? ZONE_CHROME_PX + 24 : ZONE_CHROME_PX
|
||||
const heightInPx = Math.max(ZONE_MIN_PX, chromePx + lineCount * ZONE_LINE_PX)
|
||||
const heightInPx = Math.max(ZONE_MIN_PX, ZONE_CHROME_PX + lineCount * ZONE_LINE_PX)
|
||||
|
||||
// Why: suppressMouseDown: false so clicks inside the zone (Delete
|
||||
// button) reach our DOM listeners. With true, Monaco intercepts the
|
||||
|
||||
@@ -78,11 +78,12 @@ import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import { statRuntimePath } from '@/runtime/runtime-file-client'
|
||||
import { buildMarkdownTableOfContents } from './markdown-table-of-contents'
|
||||
import { MarkdownTableOfContentsPanel } from './MarkdownTableOfContentsPanel'
|
||||
import { getDiffCommentLineLabel, isMarkdownComment } from '@/lib/diff-comment-compat'
|
||||
import { isMarkdownComment } from '@/lib/diff-comment-compat'
|
||||
import { DiffCommentCard } from '../diff-comments/DiffCommentCard'
|
||||
import {
|
||||
formatMarkdownReviewCardQuote,
|
||||
formatMarkdownReviewNotes,
|
||||
getMarkdownReviewExcerpt,
|
||||
getMarkdownReviewCardQuote,
|
||||
sortMarkdownReviewNotes,
|
||||
type MarkdownReviewNote
|
||||
} from '@/lib/markdown-review-notes'
|
||||
@@ -125,6 +126,15 @@ type MarkdownPreviewSourceOpenFile = {
|
||||
markdownPreviewSourceFileId?: string
|
||||
}
|
||||
|
||||
function isMarkdownAnnotationNavigationClick(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
return !target.closest(
|
||||
'a,button,input,textarea,select,summary,[contenteditable="true"],.markdown-annotation-controls'
|
||||
)
|
||||
}
|
||||
|
||||
export function findMarkdownPreviewSourceOpenFile(
|
||||
openFiles: MarkdownPreviewSourceOpenFile[],
|
||||
params: {
|
||||
@@ -222,6 +232,30 @@ function getMarkdownPreviewBlockRange(
|
||||
return { startLine, endLine: Math.max(startLine, endLine) }
|
||||
}
|
||||
|
||||
function getMarkdownPreviewReactText(node: React.ReactNode): string {
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
return String(node)
|
||||
}
|
||||
if (!node || typeof node === 'boolean') {
|
||||
return ''
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(getMarkdownPreviewReactText).join(' ')
|
||||
}
|
||||
if (!React.isValidElement(node)) {
|
||||
return ''
|
||||
}
|
||||
const props = node.props as { alt?: unknown; children?: React.ReactNode }
|
||||
if (typeof props.alt === 'string' && props.alt.trim()) {
|
||||
return props.alt
|
||||
}
|
||||
return getMarkdownPreviewReactText(props.children)
|
||||
}
|
||||
|
||||
function getMarkdownPreviewAnnotationQuote(node: React.ReactNode): string | undefined {
|
||||
return formatMarkdownReviewCardQuote(getMarkdownPreviewReactText(node))
|
||||
}
|
||||
|
||||
function hasMarkdownPreviewNestedBlock(node: MarkdownPreviewPositionNode | undefined): boolean {
|
||||
const blockTags = new Set(['p', 'pre', 'table', 'blockquote', 'ul', 'ol'])
|
||||
return Boolean(node?.children?.some((child) => child.tagName && blockTags.has(child.tagName)))
|
||||
@@ -491,10 +525,11 @@ export default function MarkdownPreview({
|
||||
.trim()
|
||||
}, [frontMatter])
|
||||
const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState<string | null>(null)
|
||||
const [reviewPanelOpen, setReviewPanelOpen] = useState(false)
|
||||
const [reviewNotesCopied, setReviewNotesCopied] = useState(false)
|
||||
const reviewNotesCopiedResetTimerRef = useRef<number | null>(null)
|
||||
const [activeReviewCommentId, setActiveReviewCommentId] = useState<string | null>(null)
|
||||
const [attentionReviewCommentId, setAttentionReviewCommentId] = useState<string | null>(null)
|
||||
const attentionReviewCommentTimeoutRef = useRef<number | null>(null)
|
||||
const markdownReviewNotes = useMemo(
|
||||
() => sortMarkdownReviewNotes(markdownComments as MarkdownReviewNote[]),
|
||||
[markdownComments]
|
||||
@@ -773,6 +808,58 @@ export default function MarkdownPreview({
|
||||
}
|
||||
}, [clearReviewNotesCopiedResetTimer, markdownReviewNotes.length, markdownReviewPrompt])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (attentionReviewCommentTimeoutRef.current !== null) {
|
||||
window.clearTimeout(attentionReviewCommentTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pulseRenderedMarkdownReviewNote = useCallback((commentId: string): void => {
|
||||
if (attentionReviewCommentTimeoutRef.current !== null) {
|
||||
window.clearTimeout(attentionReviewCommentTimeoutRef.current)
|
||||
}
|
||||
setAttentionReviewCommentId(null)
|
||||
window.requestAnimationFrame(() => {
|
||||
setAttentionReviewCommentId(commentId)
|
||||
attentionReviewCommentTimeoutRef.current = window.setTimeout(() => {
|
||||
setAttentionReviewCommentId(null)
|
||||
attentionReviewCommentTimeoutRef.current = null
|
||||
}, 900)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const findRenderedMarkdownReviewNoteCard = useCallback(
|
||||
(commentId: string): HTMLElement | null => {
|
||||
const root = rootRef.current
|
||||
if (!root) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
Array.from(root.querySelectorAll<HTMLElement>('[data-markdown-review-note-id]')).find(
|
||||
(candidate) => candidate.dataset.markdownReviewNoteId === commentId
|
||||
) ?? null
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const scrollRenderedMarkdownReviewNoteIntoView = useCallback(
|
||||
(comment: DiffComment): void => {
|
||||
setActiveReviewCommentId(comment.id)
|
||||
pulseRenderedMarkdownReviewNote(comment.id)
|
||||
window.requestAnimationFrame(() => {
|
||||
findRenderedMarkdownReviewNoteCard(comment.id)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
})
|
||||
})
|
||||
},
|
||||
[findRenderedMarkdownReviewNoteCard, pulseRenderedMarkdownReviewNote]
|
||||
)
|
||||
|
||||
const scrollToReviewNote = useCallback((comment: DiffComment): void => {
|
||||
setActiveReviewCommentId(comment.id)
|
||||
const root = rootRef.current
|
||||
@@ -792,17 +879,44 @@ export default function MarkdownPreview({
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, [])
|
||||
|
||||
const getMarkdownCommentsForRange = useCallback(
|
||||
(range: { startLine: number; endLine: number }): DiffComment[] =>
|
||||
markdownComments.filter(
|
||||
(comment) => range.startLine <= comment.lineNumber && comment.lineNumber <= range.endLine
|
||||
),
|
||||
[markdownComments]
|
||||
)
|
||||
|
||||
const handleAnnotatedMarkdownBlockClick = useCallback(
|
||||
(range: { startLine: number; endLine: number }, event: React.MouseEvent<HTMLElement>): void => {
|
||||
if (!isMarkdownAnnotationNavigationClick(event.target)) {
|
||||
return
|
||||
}
|
||||
const commentsForBlock = getMarkdownCommentsForRange(range)
|
||||
const comment =
|
||||
commentsForBlock.find((candidate) => candidate.id !== activeReviewCommentId) ??
|
||||
commentsForBlock[0]
|
||||
if (!comment) {
|
||||
return
|
||||
}
|
||||
scrollRenderedMarkdownReviewNoteIntoView(comment)
|
||||
},
|
||||
[activeReviewCommentId, getMarkdownCommentsForRange, scrollRenderedMarkdownReviewNoteIntoView]
|
||||
)
|
||||
|
||||
const renderAnnotationControls = useCallback(
|
||||
(range: { startLine: number; endLine: number }, blockKey: string): React.ReactNode => {
|
||||
(
|
||||
range: { startLine: number; endLine: number },
|
||||
blockKey: string,
|
||||
annotationQuote?: string
|
||||
): React.ReactNode => {
|
||||
if (!sourceWorktree || sourceRelativePath === null) {
|
||||
return null
|
||||
}
|
||||
if (!markdownAnnotationsEnabled) {
|
||||
return null
|
||||
}
|
||||
const commentsForBlock = markdownComments.filter(
|
||||
(comment) => range.startLine <= comment.lineNumber && comment.lineNumber <= range.endLine
|
||||
)
|
||||
const commentsForBlock = getMarkdownCommentsForRange(range)
|
||||
|
||||
const handleSubmit = async (body: string): Promise<boolean> => {
|
||||
const result = await addDiffComment({
|
||||
@@ -811,6 +925,7 @@ export default function MarkdownPreview({
|
||||
source: 'markdown',
|
||||
startLine: range.startLine === range.endLine ? undefined : range.startLine,
|
||||
lineNumber: range.endLine,
|
||||
...(annotationQuote ? { selectedText: annotationQuote } : {}),
|
||||
body,
|
||||
side: 'modified'
|
||||
})
|
||||
@@ -826,7 +941,7 @@ export default function MarkdownPreview({
|
||||
<button
|
||||
type="button"
|
||||
className="markdown-annotation-add"
|
||||
aria-label={`Add note on line ${range.startLine}`}
|
||||
aria-label="Add note"
|
||||
title="Add note"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -844,33 +959,44 @@ export default function MarkdownPreview({
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : null}
|
||||
{commentsForBlock.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={`markdown-annotation-card ${
|
||||
activeReviewCommentId === comment.id ? 'is-active' : ''
|
||||
}`.trim()}
|
||||
>
|
||||
<DiffCommentCard
|
||||
lineNumber={comment.lineNumber}
|
||||
startLine={comment.startLine}
|
||||
body={comment.body}
|
||||
sentAt={comment.sentAt}
|
||||
onDelete={() => void deleteDiffComment(sourceWorktree.id, comment.id)}
|
||||
onSubmitEdit={(body) => updateDiffComment(sourceWorktree.id, comment.id, body)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="markdown-annotation-note-stack">
|
||||
{commentsForBlock.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
data-markdown-review-note-id={comment.id}
|
||||
className={`markdown-annotation-card ${
|
||||
activeReviewCommentId === comment.id ? 'is-active' : ''
|
||||
} ${attentionReviewCommentId === comment.id ? 'is-attention' : ''}`.trim()}
|
||||
>
|
||||
<DiffCommentCard
|
||||
lineNumber={comment.lineNumber}
|
||||
startLine={comment.startLine}
|
||||
label={null}
|
||||
quote={
|
||||
formatMarkdownReviewCardQuote(comment.selectedText) ??
|
||||
annotationQuote ??
|
||||
getMarkdownReviewCardQuote(content, comment)
|
||||
}
|
||||
body={comment.body}
|
||||
sentAt={comment.sentAt}
|
||||
onDelete={() => void deleteDiffComment(sourceWorktree.id, comment.id)}
|
||||
onSubmitEdit={(body) => updateDiffComment(sourceWorktree.id, comment.id, body)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[
|
||||
activeAnnotationBlockKey,
|
||||
activeReviewCommentId,
|
||||
attentionReviewCommentId,
|
||||
addDiffComment,
|
||||
deleteDiffComment,
|
||||
getMarkdownCommentsForRange,
|
||||
markdownAnnotationsEnabled,
|
||||
markdownComments,
|
||||
content,
|
||||
sourceRelativePath,
|
||||
sourceWorktree,
|
||||
updateDiffComment
|
||||
@@ -888,22 +1014,28 @@ export default function MarkdownPreview({
|
||||
return rendered
|
||||
}
|
||||
const blockKey = `${tagName}:${range.startLine}-${range.endLine}`
|
||||
const controls = renderAnnotationControls(range, blockKey)
|
||||
const controls = renderAnnotationControls(
|
||||
range,
|
||||
blockKey,
|
||||
getMarkdownPreviewAnnotationQuote(rendered)
|
||||
)
|
||||
if (!controls) {
|
||||
return rendered
|
||||
}
|
||||
const hasReviewNotes = getMarkdownCommentsForRange(range).length > 0
|
||||
return (
|
||||
<div
|
||||
className="markdown-annotation-block"
|
||||
className={`markdown-annotation-block ${hasReviewNotes ? 'has-review-notes' : ''}`.trim()}
|
||||
data-source-line={range.startLine}
|
||||
data-source-end-line={range.endLine}
|
||||
onClick={(event) => handleAnnotatedMarkdownBlockClick(range, event)}
|
||||
>
|
||||
{rendered}
|
||||
{controls}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[renderAnnotationControls]
|
||||
[getMarkdownCommentsForRange, handleAnnotatedMarkdownBlockClick, renderAnnotationControls]
|
||||
)
|
||||
|
||||
const components: Components = useMemo(() => {
|
||||
@@ -1253,10 +1385,24 @@ export default function MarkdownPreview({
|
||||
return <li {...props}>{children}</li>
|
||||
}
|
||||
const blockKey = `li:${range.startLine}-${range.endLine}`
|
||||
const hasReviewNotes = getMarkdownCommentsForRange(range).length > 0
|
||||
return (
|
||||
<li {...props} data-source-line={range.startLine} data-source-end-line={range.endLine}>
|
||||
{children}
|
||||
{renderAnnotationControls(range, blockKey)}
|
||||
<li {...props}>
|
||||
<div
|
||||
className={`markdown-annotation-list-block ${
|
||||
hasReviewNotes ? 'has-review-notes' : ''
|
||||
}`.trim()}
|
||||
data-source-line={range.startLine}
|
||||
data-source-end-line={range.endLine}
|
||||
onClick={(event) => handleAnnotatedMarkdownBlockClick(range, event)}
|
||||
>
|
||||
<span className="markdown-annotation-list-content">{children}</span>
|
||||
{renderAnnotationControls(
|
||||
range,
|
||||
blockKey,
|
||||
getMarkdownPreviewAnnotationQuote(children)
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
},
|
||||
@@ -1324,6 +1470,8 @@ export default function MarkdownPreview({
|
||||
isDark,
|
||||
isMac,
|
||||
imageRuntimeContext,
|
||||
getMarkdownCommentsForRange,
|
||||
handleAnnotatedMarkdownBlockClick,
|
||||
markdownDocumentIndex,
|
||||
onOpenDocument,
|
||||
openFile,
|
||||
@@ -1425,9 +1573,15 @@ export default function MarkdownPreview({
|
||||
<button
|
||||
type="button"
|
||||
className="markdown-review-toolbar-button"
|
||||
onClick={() => setReviewPanelOpen((open) => !open)}
|
||||
aria-expanded={reviewPanelOpen}
|
||||
title={reviewPanelOpen ? 'Hide review notes' : 'Show review notes'}
|
||||
onClick={() => {
|
||||
const firstNote = markdownReviewNotes[0]
|
||||
if (firstNote) {
|
||||
scrollToReviewNote(firstNote)
|
||||
}
|
||||
}}
|
||||
disabled={markdownReviewNotes.length === 0}
|
||||
title="Jump to first review note"
|
||||
aria-label="Jump to first review note"
|
||||
>
|
||||
<MessageSquare className="size-3.5" />
|
||||
<span>Review notes</span>
|
||||
@@ -1519,22 +1673,6 @@ export default function MarkdownPreview({
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
{canShowReviewTools && reviewPanelOpen && sourceWorktree ? (
|
||||
<MarkdownReviewNotesPanel
|
||||
notes={markdownReviewNotes}
|
||||
content={renderedContent}
|
||||
activeId={activeReviewCommentId}
|
||||
copied={reviewNotesCopied}
|
||||
onClose={() => setReviewPanelOpen(false)}
|
||||
onCopy={() => void handleCopyMarkdownReviewNotes()}
|
||||
onSelect={scrollToReviewNote}
|
||||
onDelete={(id) => void deleteDiffComment(sourceWorktree.id, id)}
|
||||
onSubmitEdit={(id, body) => updateDiffComment(sourceWorktree.id, id, body)}
|
||||
sendPrompt={unsentMarkdownReviewPrompt}
|
||||
worktreeId={sourceWorktree.id}
|
||||
unsentNotes={unsentMarkdownReviewNotes}
|
||||
/>
|
||||
) : null}
|
||||
{showTableOfContents ? (
|
||||
<MarkdownTableOfContentsPanel
|
||||
items={tableOfContentsItems}
|
||||
@@ -1546,128 +1684,7 @@ export default function MarkdownPreview({
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownReviewNotesPanel({
|
||||
notes,
|
||||
content,
|
||||
activeId,
|
||||
copied,
|
||||
onClose,
|
||||
onCopy,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onSubmitEdit,
|
||||
sendPrompt,
|
||||
worktreeId,
|
||||
unsentNotes
|
||||
}: {
|
||||
notes: MarkdownReviewNote[]
|
||||
content: string
|
||||
activeId: string | null
|
||||
copied: boolean
|
||||
onClose: () => void
|
||||
onCopy: () => void
|
||||
onSelect: (note: MarkdownReviewNote) => void
|
||||
onDelete: (id: string) => void
|
||||
onSubmitEdit: (id: string, body: string) => Promise<boolean>
|
||||
sendPrompt: string
|
||||
worktreeId: string
|
||||
unsentNotes: readonly MarkdownReviewNote[]
|
||||
}): React.JSX.Element {
|
||||
const clearDeliveredDiffComments = useAppStore((s) => s.clearDeliveredDiffComments)
|
||||
return (
|
||||
<aside className="markdown-review-panel">
|
||||
<div className="markdown-review-panel-header">
|
||||
<div className="markdown-review-panel-title">
|
||||
<MessageSquare className="size-3.5" />
|
||||
<span>Review notes</span>
|
||||
<span className="markdown-review-count">{notes.length}</span>
|
||||
</div>
|
||||
<div className="markdown-review-panel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="markdown-review-icon-button"
|
||||
onClick={onCopy}
|
||||
disabled={notes.length === 0}
|
||||
title="Copy notes for agent"
|
||||
aria-label="Copy notes for agent"
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="markdown-review-icon-button"
|
||||
disabled={unsentNotes.length === 0}
|
||||
title={unsentNotes.length === 0 ? 'All notes sent' : 'Send notes to a new agent'}
|
||||
aria-label="Send notes to a new agent"
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<QuickLaunchAgentMenuItems
|
||||
worktreeId={worktreeId}
|
||||
groupId={worktreeId}
|
||||
onFocusTerminal={focusTerminalTabSurface}
|
||||
prompt={sendPrompt}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
onPromptDelivered={() => void clearDeliveredDiffComments(worktreeId, unsentNotes)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
type="button"
|
||||
className="markdown-review-icon-button"
|
||||
onClick={onClose}
|
||||
title="Close notes"
|
||||
aria-label="Close notes"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="markdown-review-note-list scrollbar-sleek">
|
||||
{notes.length === 0 ? (
|
||||
<div className="markdown-review-empty">No review notes for this file.</div>
|
||||
) : (
|
||||
notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className={`markdown-review-note ${activeId === note.id ? 'is-active' : ''}`.trim()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="markdown-review-note-anchor"
|
||||
onClick={() => onSelect(note)}
|
||||
>
|
||||
<span className="markdown-review-note-line">
|
||||
{getDiffCommentLineLabel(note, true)}
|
||||
</span>
|
||||
<span className="markdown-review-note-excerpt">
|
||||
{getMarkdownReviewExcerpt(content, note).replace(/^> /gm, '') || 'No preview'}
|
||||
</span>
|
||||
</button>
|
||||
<DiffCommentCard
|
||||
lineNumber={note.lineNumber}
|
||||
startLine={note.startLine}
|
||||
body={note.body}
|
||||
sentAt={note.sentAt}
|
||||
onDelete={() => onDelete(note.id)}
|
||||
onSubmitEdit={(body) => onSubmitEdit(note.id, body)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownAnnotationComposer({
|
||||
lineNumber,
|
||||
startLine,
|
||||
onCancel,
|
||||
onSubmit
|
||||
}: {
|
||||
@@ -1684,10 +1701,6 @@ function MarkdownAnnotationComposer({
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const label =
|
||||
startLine !== undefined && startLine !== lineNumber
|
||||
? `Lines ${startLine}-${lineNumber}`
|
||||
: `Line ${lineNumber}`
|
||||
const trimmed = body.trim()
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
@@ -1707,7 +1720,7 @@ function MarkdownAnnotationComposer({
|
||||
|
||||
return (
|
||||
<div className="markdown-annotation-composer" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="orca-diff-comment-popover-label">{label}</div>
|
||||
<div className="orca-diff-comment-popover-label">Selected text</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="orca-diff-comment-popover-textarea"
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
formatMarkdownReviewNotes,
|
||||
getMarkdownReviewCardQuote,
|
||||
sortMarkdownReviewNotes,
|
||||
type MarkdownReviewNote
|
||||
} from '@/lib/markdown-review-notes'
|
||||
@@ -73,6 +74,11 @@ import {
|
||||
richMarkdownAnnotationHighlightPluginKey,
|
||||
type RichMarkdownAnnotationHighlightRange
|
||||
} from './rich-markdown-annotation-highlight'
|
||||
import {
|
||||
shouldExpandRichMarkdownReviewRail,
|
||||
stackRichMarkdownReviewNotePositions,
|
||||
type RichMarkdownReviewNotePosition
|
||||
} from './rich-markdown-review-note-layout'
|
||||
|
||||
type RichMarkdownEditorProps = {
|
||||
fileId: string
|
||||
@@ -173,6 +179,13 @@ function shouldFocusEmptyEditorFromSurfaceClick(
|
||||
return !target.closest('.rich-markdown-editor-shell button, .rich-markdown-editor-shell input')
|
||||
}
|
||||
|
||||
function isRichMarkdownReviewNoteNavigationClick(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
return !target.closest('button,input,textarea,select,a,[contenteditable="true"]')
|
||||
}
|
||||
|
||||
function isRichMarkdownContextCommandTarget(
|
||||
payload: RichMarkdownContextMenuCommandPayload,
|
||||
root: HTMLElement | null
|
||||
@@ -216,11 +229,6 @@ type RichMarkdownAnnotationTarget = RichMarkdownComposerState & {
|
||||
buttonLeft: number
|
||||
}
|
||||
|
||||
type RichMarkdownNotePosition = {
|
||||
comment: DiffComment
|
||||
top: number
|
||||
}
|
||||
|
||||
function countMarkdownLines(value: string): number {
|
||||
if (value.length === 0) {
|
||||
return 1
|
||||
@@ -292,7 +300,7 @@ function clampRichMarkdownAnnotationTarget(
|
||||
}
|
||||
|
||||
function clearRichMarkdownNotePositions(
|
||||
setNotePositions: React.Dispatch<React.SetStateAction<RichMarkdownNotePosition[]>>
|
||||
setNotePositions: React.Dispatch<React.SetStateAction<RichMarkdownReviewNotePosition[]>>
|
||||
): void {
|
||||
setNotePositions((current) => (current.length === 0 ? current : []))
|
||||
}
|
||||
@@ -386,28 +394,83 @@ function getRichMarkdownAnnotationHighlightRanges(
|
||||
editor: Editor,
|
||||
comments: readonly DiffComment[],
|
||||
markdownSourceLineOffset: number
|
||||
): RichMarkdownAnnotationHighlightRange[] {
|
||||
return comments.flatMap((comment) =>
|
||||
getRichMarkdownAnnotationHighlightRangesForComment(editor, comment, markdownSourceLineOffset)
|
||||
)
|
||||
}
|
||||
|
||||
function getRichMarkdownAnnotationHighlightRangesForComment(
|
||||
editor: Editor,
|
||||
comment: DiffComment,
|
||||
markdownSourceLineOffset: number
|
||||
): RichMarkdownAnnotationHighlightRange[] {
|
||||
const blocks = buildRichMarkdownCommentBlocks(editor)
|
||||
return comments.flatMap((comment) => {
|
||||
const selectedText = comment.selectedText?.trim()
|
||||
if (!selectedText) {
|
||||
return []
|
||||
}
|
||||
const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset)
|
||||
const block = blocks.find(
|
||||
(candidate) => candidate.startLine <= bodyLineNumber && bodyLineNumber <= candidate.endLine
|
||||
const selectedText = comment.selectedText?.trim()
|
||||
if (!selectedText) {
|
||||
return []
|
||||
}
|
||||
const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset)
|
||||
const block = blocks.find(
|
||||
(candidate) => candidate.startLine <= bodyLineNumber && bodyLineNumber <= candidate.endLine
|
||||
)
|
||||
if (block) {
|
||||
const blockRanges = findRichMarkdownTextRanges(
|
||||
collectRichMarkdownTextChars(editor, block.from, block.to),
|
||||
selectedText
|
||||
)
|
||||
if (block) {
|
||||
const blockRanges = findRichMarkdownTextRanges(
|
||||
collectRichMarkdownTextChars(editor, block.from, block.to),
|
||||
selectedText
|
||||
)
|
||||
if (blockRanges.length > 0) {
|
||||
return blockRanges
|
||||
}
|
||||
if (blockRanges.length > 0) {
|
||||
return blockRanges
|
||||
}
|
||||
return findRichMarkdownTextRanges(collectRichMarkdownTextChars(editor), selectedText)
|
||||
})
|
||||
}
|
||||
return findRichMarkdownTextRanges(collectRichMarkdownTextChars(editor), selectedText)
|
||||
}
|
||||
|
||||
function getRichMarkdownCommentAtPos(
|
||||
editor: Editor,
|
||||
comments: readonly DiffComment[],
|
||||
markdownSourceLineOffset: number,
|
||||
pos: number
|
||||
): DiffComment | null {
|
||||
return (
|
||||
comments.find((comment) =>
|
||||
getRichMarkdownAnnotationHighlightRangesForComment(
|
||||
editor,
|
||||
comment,
|
||||
markdownSourceLineOffset
|
||||
).some((range) => range.from <= pos && pos <= range.to)
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function getRichMarkdownCommentAnchorTop(
|
||||
editor: Editor,
|
||||
comment: DiffComment,
|
||||
block: RichMarkdownCommentBlock,
|
||||
containerRect: DOMRect,
|
||||
containerScrollTop: number,
|
||||
markdownSourceLineOffset: number
|
||||
): number | null {
|
||||
try {
|
||||
const ranges = getRichMarkdownAnnotationHighlightRangesForComment(
|
||||
editor,
|
||||
comment,
|
||||
markdownSourceLineOffset
|
||||
)
|
||||
// Why: range notes should sort by the start of the selected text. Anchoring
|
||||
// to the end puts overlapping ranges with the same final line in creation
|
||||
// order, so a 43-45 card can render above a 41-45 card.
|
||||
const anchorPos =
|
||||
ranges.length > 0
|
||||
? Math.min(...ranges.map((range) => Math.min(range.from, range.to)))
|
||||
: block.from
|
||||
const coords = editor.view.coordsAtPos(
|
||||
Math.max(1, Math.min(anchorPos, editor.state.doc.content.size))
|
||||
)
|
||||
return coords.top - containerRect.top + containerScrollTop
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getRichMarkdownSelectionRange(editor: Editor): RichMarkdownComposerState {
|
||||
@@ -425,6 +488,24 @@ function getRichMarkdownSelectionRange(editor: Editor): RichMarkdownComposerStat
|
||||
}
|
||||
}
|
||||
|
||||
function hasRichMarkdownCommentForRange(
|
||||
comments: readonly DiffComment[],
|
||||
target: Pick<RichMarkdownAnnotationTarget, 'lineNumber' | 'selectedText' | 'startLine'>,
|
||||
markdownSourceLineOffset: number
|
||||
): boolean {
|
||||
const startLine = (target.startLine ?? target.lineNumber) + markdownSourceLineOffset
|
||||
const endLine = target.lineNumber + markdownSourceLineOffset
|
||||
const selectedText = target.selectedText.trim()
|
||||
return comments.some((comment) => {
|
||||
const commentStartLine = comment.startLine ?? comment.lineNumber
|
||||
return (
|
||||
commentStartLine === startLine &&
|
||||
comment.lineNumber === endLine &&
|
||||
(comment.selectedText?.trim() ?? '') === selectedText
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function getCurrentRichMarkdownSelectionRect(root: HTMLElement): DOMRect | null {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
@@ -460,12 +541,13 @@ function getRichMarkdownAnnotationTarget(
|
||||
const popoverWidth = 420
|
||||
const left = Math.max(56, rootRect.width - popoverWidth - 24)
|
||||
const buttonTop = Math.max(8, rect.bottom - rootRect.top + 6)
|
||||
const popoverTop = Math.max(8, Math.min(buttonTop + 28, rootRect.height - 220))
|
||||
return {
|
||||
...getRichMarkdownSelectionRange(editor),
|
||||
from: editor.state.selection.from,
|
||||
to: editor.state.selection.to,
|
||||
selectedText,
|
||||
top: buttonTop + 28,
|
||||
top: popoverTop,
|
||||
left,
|
||||
buttonTop,
|
||||
buttonLeft: Math.max(56, rootRect.width - 42)
|
||||
@@ -562,9 +644,16 @@ export default function RichMarkdownEditor({
|
||||
null
|
||||
)
|
||||
const [reviewRailOpen, setReviewRailOpen] = useState(false)
|
||||
const [notePositions, setNotePositions] = useState<RichMarkdownNotePosition[]>([])
|
||||
const [activeReviewCommentId, setActiveReviewCommentId] = useState<string | null>(null)
|
||||
const [attentionReviewCommentId, setAttentionReviewCommentId] = useState<string | null>(null)
|
||||
const [notePositions, setNotePositions] = useState<RichMarkdownReviewNotePosition[]>([])
|
||||
const annotationPopoverRef = useRef<RichMarkdownAnnotationTarget | null>(null)
|
||||
const canAnnotateRichMarkdownRef = useRef(false)
|
||||
const markdownCommentsRef = useRef<DiffComment[]>([])
|
||||
const notePositionsRef = useRef<RichMarkdownReviewNotePosition[]>([])
|
||||
const markdownSourceLineOffsetRef = useRef(markdownSourceLineOffset)
|
||||
const attentionReviewCommentTimeoutRef = useRef<number | null>(null)
|
||||
const sourceAttentionTimeoutRef = useRef<number | null>(null)
|
||||
const annotationTargetFrameRef = useRef<number | null>(null)
|
||||
const notePositionsFrameRef = useRef<number | null>(null)
|
||||
const isEditingLinkRef = useRef(false)
|
||||
@@ -598,6 +687,11 @@ export default function RichMarkdownEditor({
|
||||
)
|
||||
const hasMarkdownComments = markdownComments.length > 0
|
||||
const reviewRailVisible = hasMarkdownComments && reviewRailOpen
|
||||
const reviewRailExpanded = shouldExpandRichMarkdownReviewRail({
|
||||
hasReviewNotes: hasMarkdownComments,
|
||||
reviewRailOpen,
|
||||
hasDraftNote: annotationPopover !== null
|
||||
})
|
||||
const tableOfContentsItems = useMemo(() => buildMarkdownTableOfContents(content), [content])
|
||||
const flatTableOfContentsItems = useMemo(
|
||||
() => flattenMarkdownTocItems(tableOfContentsItems),
|
||||
@@ -614,6 +708,9 @@ export default function RichMarkdownEditor({
|
||||
isEditingLinkRef.current = isEditingLink
|
||||
annotationPopoverRef.current = annotationPopover
|
||||
canAnnotateRichMarkdownRef.current = canAnnotateRichMarkdown
|
||||
markdownCommentsRef.current = markdownComments
|
||||
notePositionsRef.current = notePositions
|
||||
markdownSourceLineOffsetRef.current = markdownSourceLineOffset
|
||||
|
||||
const flushPendingSerialization = useCallback(() => {
|
||||
if (serializeTimerRef.current === null) {
|
||||
@@ -651,27 +748,64 @@ export default function RichMarkdownEditor({
|
||||
setAnnotationTarget(null)
|
||||
return
|
||||
}
|
||||
setAnnotationTarget(getRichMarkdownAnnotationTarget(nextEditor, root))
|
||||
const target = getRichMarkdownAnnotationTarget(nextEditor, root)
|
||||
if (
|
||||
target &&
|
||||
hasRichMarkdownCommentForRange(
|
||||
markdownCommentsRef.current,
|
||||
target,
|
||||
markdownSourceLineOffsetRef.current
|
||||
)
|
||||
) {
|
||||
setAnnotationTarget(null)
|
||||
return
|
||||
}
|
||||
setAnnotationTarget(target)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (attentionReviewCommentTimeoutRef.current !== null) {
|
||||
window.clearTimeout(attentionReviewCommentTimeoutRef.current)
|
||||
}
|
||||
if (sourceAttentionTimeoutRef.current !== null) {
|
||||
window.clearTimeout(sourceAttentionTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pulseRichMarkdownReviewNote = useCallback((commentId: string): void => {
|
||||
if (attentionReviewCommentTimeoutRef.current !== null) {
|
||||
window.clearTimeout(attentionReviewCommentTimeoutRef.current)
|
||||
}
|
||||
setAttentionReviewCommentId(null)
|
||||
window.requestAnimationFrame(() => {
|
||||
setAttentionReviewCommentId(commentId)
|
||||
attentionReviewCommentTimeoutRef.current = window.setTimeout(() => {
|
||||
setAttentionReviewCommentId(null)
|
||||
attentionReviewCommentTimeoutRef.current = null
|
||||
}, 900)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const syncNotePositions = useCallback((): void => {
|
||||
const ed = editorRef.current
|
||||
const root = rootRef.current
|
||||
const container = scrollContainerRef.current
|
||||
if (
|
||||
!reviewRailVisible ||
|
||||
!canAnnotateRichMarkdown ||
|
||||
!ed ||
|
||||
!root ||
|
||||
!container ||
|
||||
markdownComments.length === 0
|
||||
) {
|
||||
clearRichMarkdownNotePositions(setNotePositions)
|
||||
return
|
||||
}
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const blocks = buildRichMarkdownCommentBlocks(ed)
|
||||
const nextPositions = markdownComments
|
||||
.map((comment): RichMarkdownNotePosition | null => {
|
||||
.map((comment): RichMarkdownReviewNotePosition | null => {
|
||||
const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset)
|
||||
const block = blocks.find(
|
||||
(candidate) =>
|
||||
@@ -680,29 +814,31 @@ export default function RichMarkdownEditor({
|
||||
if (!block) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const coords = ed.view.coordsAtPos(Math.min(block.to, ed.state.doc.content.size))
|
||||
return {
|
||||
comment,
|
||||
top: Math.max(8, coords.bottom - rootRect.top + 6)
|
||||
}
|
||||
} catch {
|
||||
const top = getRichMarkdownCommentAnchorTop(
|
||||
ed,
|
||||
comment,
|
||||
block,
|
||||
containerRect,
|
||||
container.scrollTop,
|
||||
markdownSourceLineOffset
|
||||
)
|
||||
if (top === null) {
|
||||
return null
|
||||
}
|
||||
return { comment, top }
|
||||
})
|
||||
.filter((position): position is RichMarkdownNotePosition => position !== null)
|
||||
.sort(
|
||||
(left, right) => left.top - right.top || left.comment.createdAt - right.comment.createdAt
|
||||
)
|
||||
let nextOpenTop = 0
|
||||
setNotePositions(
|
||||
nextPositions.map((position) => {
|
||||
const top = Math.max(position.top, nextOpenTop)
|
||||
const estimatedHeight = 72 + position.comment.body.split('\n').length * 18
|
||||
nextOpenTop = top + estimatedHeight + 8
|
||||
return { ...position, top }
|
||||
})
|
||||
)
|
||||
.filter((position): position is RichMarkdownReviewNotePosition => position !== null)
|
||||
|
||||
const measuredHeights = new Map<string, number>()
|
||||
for (const pos of nextPositions) {
|
||||
const id = pos.comment.id
|
||||
const el = container.querySelector(`[data-rich-markdown-review-note-id="${id}"]`)
|
||||
if (el) {
|
||||
measuredHeights.set(id, el.getBoundingClientRect().height)
|
||||
}
|
||||
}
|
||||
|
||||
setNotePositions(stackRichMarkdownReviewNotePositions(nextPositions, measuredHeights))
|
||||
}, [canAnnotateRichMarkdown, markdownComments, markdownSourceLineOffset, reviewRailVisible])
|
||||
|
||||
const requestSyncNotePositions = useCallback((): void => {
|
||||
@@ -719,6 +855,105 @@ export default function RichMarkdownEditor({
|
||||
})
|
||||
}, [reviewRailVisible, syncNotePositions])
|
||||
|
||||
const scrollRichMarkdownReviewNoteCardIntoView = useCallback(
|
||||
(commentId: string): void => {
|
||||
setReviewRailOpen(true)
|
||||
setActiveReviewCommentId(commentId)
|
||||
pulseRichMarkdownReviewNote(commentId)
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const container = scrollContainerRef.current
|
||||
const card = container?.querySelector<HTMLElement>(
|
||||
`[data-rich-markdown-review-note-id="${CSS.escape(commentId)}"]`
|
||||
)
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
const position = notePositionsRef.current.find((item) => item.comment.id === commentId)
|
||||
const cardHeight = card?.offsetHeight ?? 72
|
||||
const cardTop = position?.top ?? card?.offsetTop
|
||||
if (cardTop === undefined) {
|
||||
return
|
||||
}
|
||||
const targetTop = cardTop - Math.max(0, (container.clientHeight - cardHeight) / 2)
|
||||
container.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' })
|
||||
})
|
||||
})
|
||||
},
|
||||
[pulseRichMarkdownReviewNote]
|
||||
)
|
||||
|
||||
const pulseRichMarkdownSourceRange = useCallback(
|
||||
(range: RichMarkdownAnnotationHighlightRange): void => {
|
||||
const ed = editorRef.current
|
||||
if (!ed) {
|
||||
return
|
||||
}
|
||||
if (sourceAttentionTimeoutRef.current !== null) {
|
||||
window.clearTimeout(sourceAttentionTimeoutRef.current)
|
||||
}
|
||||
ed.view.dispatch(
|
||||
ed.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, {
|
||||
activeRange: null
|
||||
})
|
||||
)
|
||||
window.requestAnimationFrame(() => {
|
||||
const currentEditor = editorRef.current
|
||||
if (!currentEditor) {
|
||||
return
|
||||
}
|
||||
currentEditor.view.dispatch(
|
||||
currentEditor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, {
|
||||
activeRange: range
|
||||
})
|
||||
)
|
||||
sourceAttentionTimeoutRef.current = window.setTimeout(() => {
|
||||
const latestEditor = editorRef.current
|
||||
if (latestEditor) {
|
||||
latestEditor.view.dispatch(
|
||||
latestEditor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, {
|
||||
activeRange: null
|
||||
})
|
||||
)
|
||||
}
|
||||
sourceAttentionTimeoutRef.current = null
|
||||
}, 900)
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const scrollRichMarkdownReviewNoteSourceIntoView = useCallback(
|
||||
(comment: DiffComment): void => {
|
||||
const ed = editorRef.current
|
||||
const container = scrollContainerRef.current
|
||||
if (!ed || !container) {
|
||||
return
|
||||
}
|
||||
const ranges = getRichMarkdownAnnotationHighlightRangesForComment(
|
||||
ed,
|
||||
comment,
|
||||
markdownSourceLineOffsetRef.current
|
||||
)
|
||||
if (ranges.length === 0) {
|
||||
return
|
||||
}
|
||||
const from = Math.min(...ranges.map((range) => Math.min(range.from, range.to)))
|
||||
const to = Math.max(...ranges.map((range) => Math.max(range.from, range.to)))
|
||||
const maxPos = ed.state.doc.content.size
|
||||
const startCoords = ed.view.coordsAtPos(Math.max(1, Math.min(from, maxPos)))
|
||||
const endCoords = ed.view.coordsAtPos(Math.max(1, Math.min(to, maxPos)))
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const sourceTop = startCoords.top - containerRect.top + container.scrollTop
|
||||
const sourceBottom = endCoords.bottom - containerRect.top + container.scrollTop
|
||||
const targetTop = (sourceTop + sourceBottom) / 2 - container.clientHeight / 2
|
||||
setActiveReviewCommentId(comment.id)
|
||||
container.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' })
|
||||
pulseRichMarkdownSourceRange({ from, to })
|
||||
},
|
||||
[pulseRichMarkdownSourceRange]
|
||||
)
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: richMarkdownExtensions,
|
||||
@@ -781,7 +1016,20 @@ export default function RichMarkdownEditor({
|
||||
handleClick: (view, pos, event) => {
|
||||
const ed = editorRef.current
|
||||
const modKey = isMac ? event.metaKey : event.ctrlKey
|
||||
if (!ed || !modKey) {
|
||||
if (!ed) {
|
||||
return false
|
||||
}
|
||||
if (!modKey) {
|
||||
const selectedComment = getRichMarkdownCommentAtPos(
|
||||
ed,
|
||||
markdownCommentsRef.current,
|
||||
markdownSourceLineOffsetRef.current,
|
||||
pos
|
||||
)
|
||||
if (!selectedComment) {
|
||||
return false
|
||||
}
|
||||
scrollRichMarkdownReviewNoteCardIntoView(selectedComment.id)
|
||||
return false
|
||||
}
|
||||
// Why: doc links are atom nodes (not marks), so resolve(pos).marks()
|
||||
@@ -1265,6 +1513,10 @@ export default function RichMarkdownEditor({
|
||||
setAnnotationTarget(null)
|
||||
return
|
||||
}
|
||||
if (hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset)) {
|
||||
setAnnotationTarget(null)
|
||||
return
|
||||
}
|
||||
if (ed) {
|
||||
ed.view.dispatch(
|
||||
ed.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, {
|
||||
@@ -1275,9 +1527,12 @@ export default function RichMarkdownEditor({
|
||||
})
|
||||
)
|
||||
}
|
||||
// Why: opening a draft should reserve the notes rail immediately; after
|
||||
// submit, the saved note stays visible instead of landing behind a closed toggle.
|
||||
setReviewRailOpen(true)
|
||||
setAnnotationPopover(target)
|
||||
setAnnotationTarget(null)
|
||||
}, [annotationTarget, canAnnotateRichMarkdown])
|
||||
}, [annotationTarget, canAnnotateRichMarkdown, markdownComments, markdownSourceLineOffset])
|
||||
|
||||
useEffect(() => {
|
||||
handleEmojiPickRef.current = openEmojiMenu
|
||||
@@ -1436,7 +1691,7 @@ export default function RichMarkdownEditor({
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`rich-markdown-editor-shell ${
|
||||
reviewRailVisible ? 'has-rich-markdown-review-notes' : ''
|
||||
reviewRailExpanded ? 'has-rich-markdown-review-notes' : ''
|
||||
}`.trim()}
|
||||
style={{ '--editor-font-zoom-level': editorFontZoomLevel } as React.CSSProperties}
|
||||
>
|
||||
@@ -1452,7 +1707,7 @@ export default function RichMarkdownEditor({
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full overflow-auto scrollbar-editor"
|
||||
className="relative h-full overflow-auto scrollbar-editor"
|
||||
onMouseDown={(event) => {
|
||||
if (!shouldFocusEmptyEditorFromSurfaceClick(event, editorRef.current)) {
|
||||
return
|
||||
@@ -1465,6 +1720,76 @@ export default function RichMarkdownEditor({
|
||||
}}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
{reviewRailVisible && notePositions.length > 0 ? (
|
||||
<div className="rich-markdown-review-note-layer" aria-label="Review notes">
|
||||
{notePositions.map(({ comment, top }) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
data-rich-markdown-review-note-id={comment.id}
|
||||
className={`rich-markdown-review-note-card ${
|
||||
activeReviewCommentId === comment.id ? 'is-active' : ''
|
||||
} ${attentionReviewCommentId === comment.id ? 'is-attention' : ''}`.trim()}
|
||||
style={{ top }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
if (!isRichMarkdownReviewNoteNavigationClick(event.target)) {
|
||||
return
|
||||
}
|
||||
scrollRichMarkdownReviewNoteSourceIntoView(comment)
|
||||
}}
|
||||
>
|
||||
<DiffCommentCard
|
||||
lineNumber={comment.lineNumber}
|
||||
startLine={comment.startLine}
|
||||
label={null}
|
||||
quote={getMarkdownReviewCardQuote(markdownReviewContent, comment)}
|
||||
body={comment.body}
|
||||
sentAt={comment.sentAt}
|
||||
onDelete={() => void deleteDiffComment(worktreeId, comment.id)}
|
||||
onSubmitEdit={(body) => updateDiffComment(worktreeId, comment.id, body)}
|
||||
onContentResize={syncNotePositions}
|
||||
headerActions={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-review-note-send"
|
||||
disabled={Boolean(comment.sentAt)}
|
||||
title={
|
||||
comment.sentAt ? 'Note already sent' : 'Send note to a new agent'
|
||||
}
|
||||
aria-label="Send note to a new agent"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<QuickLaunchAgentMenuItems
|
||||
worktreeId={worktreeId}
|
||||
groupId={worktreeId}
|
||||
onFocusTerminal={focusTerminalTabSurface}
|
||||
prompt={formatMarkdownReviewNotes(
|
||||
[comment as MarkdownReviewNote],
|
||||
markdownReviewContent
|
||||
)}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
onPromptDelivered={() =>
|
||||
void clearDeliveredDiffComments(worktreeId, [
|
||||
comment as MarkdownReviewNote
|
||||
])
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<RichMarkdownSearchBar
|
||||
activeMatchIndex={activeMatchIndex}
|
||||
@@ -1602,63 +1927,6 @@ export default function RichMarkdownEditor({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : null}
|
||||
{reviewRailVisible && notePositions.length > 0 ? (
|
||||
<div className="rich-markdown-review-note-layer" aria-label="Review notes">
|
||||
{notePositions.map(({ comment, top }) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="rich-markdown-review-note-card"
|
||||
style={{ top }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DiffCommentCard
|
||||
lineNumber={comment.lineNumber}
|
||||
startLine={comment.startLine}
|
||||
body={comment.body}
|
||||
sentAt={comment.sentAt}
|
||||
onDelete={() => void deleteDiffComment(worktreeId, comment.id)}
|
||||
onSubmitEdit={(body) => updateDiffComment(worktreeId, comment.id, body)}
|
||||
onContentResize={syncNotePositions}
|
||||
headerActions={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-review-note-send"
|
||||
disabled={Boolean(comment.sentAt)}
|
||||
title={comment.sentAt ? 'Note already sent' : 'Send note to a new agent'}
|
||||
aria-label="Send note to a new agent"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<QuickLaunchAgentMenuItems
|
||||
worktreeId={worktreeId}
|
||||
groupId={worktreeId}
|
||||
onFocusTerminal={focusTerminalTabSurface}
|
||||
prompt={formatMarkdownReviewNotes(
|
||||
[comment as MarkdownReviewNote],
|
||||
markdownReviewContent
|
||||
)}
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
onPromptDelivered={() =>
|
||||
void clearDeliveredDiffComments(worktreeId, [
|
||||
comment as MarkdownReviewNote
|
||||
])
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showTableOfContents ? (
|
||||
<MarkdownTableOfContentsPanel
|
||||
|
||||
@@ -27,14 +27,19 @@ function createAnnotationDecorations(
|
||||
activeRange: RichMarkdownAnnotationHighlightRange | null,
|
||||
noteRanges: RichMarkdownAnnotationHighlightRange[]
|
||||
): DecorationSet {
|
||||
const decorations = [...noteRanges, ...(activeRange ? [activeRange] : [])]
|
||||
const decorations = [
|
||||
...noteRanges.map((range) => ({ range, active: false })),
|
||||
...(activeRange ? [{ range: activeRange, active: true }] : [])
|
||||
]
|
||||
.map((range) => {
|
||||
const from = Math.min(range.from, range.to)
|
||||
const to = Math.max(range.from, range.to)
|
||||
const from = Math.min(range.range.from, range.range.to)
|
||||
const to = Math.max(range.range.from, range.range.to)
|
||||
return from === to
|
||||
? null
|
||||
: Decoration.inline(from, to, {
|
||||
class: 'rich-markdown-annotation-selection'
|
||||
class: range.active
|
||||
? 'rich-markdown-annotation-selection rich-markdown-annotation-selection-active'
|
||||
: 'rich-markdown-annotation-selection'
|
||||
})
|
||||
})
|
||||
.filter((decoration): decoration is Decoration => decoration !== null)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
import {
|
||||
shouldExpandRichMarkdownReviewRail,
|
||||
stackRichMarkdownReviewNotePositions
|
||||
} from './rich-markdown-review-note-layout'
|
||||
|
||||
function makeComment(overrides: Partial<DiffComment>): DiffComment {
|
||||
return {
|
||||
id: 'note',
|
||||
worktreeId: 'wt1',
|
||||
filePath: 'AGENTS.md',
|
||||
source: 'markdown',
|
||||
lineNumber: 45,
|
||||
body: 'note',
|
||||
createdAt: 1,
|
||||
side: 'modified',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('stackRichMarkdownReviewNotePositions', () => {
|
||||
it('keeps overlapping range notes in source-line order when they share an anchor', () => {
|
||||
const stacked = stackRichMarkdownReviewNotePositions([
|
||||
{
|
||||
comment: makeComment({ id: 'later-start', startLine: 43, createdAt: 1 }),
|
||||
top: 100
|
||||
},
|
||||
{
|
||||
comment: makeComment({ id: 'earlier-start', startLine: 41, createdAt: 2 }),
|
||||
top: 100
|
||||
}
|
||||
])
|
||||
|
||||
expect(stacked.map((position) => position.comment.id)).toEqual(['earlier-start', 'later-start'])
|
||||
expect(stacked[0].top).toBe(100)
|
||||
expect(stacked[1].top).toBeGreaterThan(stacked[0].top)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldExpandRichMarkdownReviewRail', () => {
|
||||
it('expands while a markdown note draft is open even before the note is saved', () => {
|
||||
expect(
|
||||
shouldExpandRichMarkdownReviewRail({
|
||||
hasReviewNotes: false,
|
||||
reviewRailOpen: false,
|
||||
hasDraftNote: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps saved notes collapsed until the user opens the rail', () => {
|
||||
expect(
|
||||
shouldExpandRichMarkdownReviewRail({
|
||||
hasReviewNotes: true,
|
||||
reviewRailOpen: false,
|
||||
hasDraftNote: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
|
||||
export type RichMarkdownReviewNotePosition = {
|
||||
comment: DiffComment
|
||||
top: number
|
||||
}
|
||||
|
||||
export type RichMarkdownReviewRailState = {
|
||||
hasReviewNotes: boolean
|
||||
reviewRailOpen: boolean
|
||||
hasDraftNote: boolean
|
||||
}
|
||||
|
||||
const REVIEW_NOTE_GAP_PX = 8
|
||||
const REVIEW_NOTE_BASE_HEIGHT_PX = 58 // new Notion-style card base height (padding, border, gap, header)
|
||||
const REVIEW_NOTE_BODY_LINE_HEIGHT_PX = 20 // new body line-height
|
||||
const REVIEW_NOTE_QUOTE_HEIGHT_PX = 32 // new quote height (border, margins, text)
|
||||
|
||||
function getReviewNoteStartLine(comment: Pick<DiffComment, 'lineNumber' | 'startLine'>): number {
|
||||
return comment.startLine ?? comment.lineNumber
|
||||
}
|
||||
|
||||
export function compareRichMarkdownReviewNotePositions(
|
||||
left: RichMarkdownReviewNotePosition,
|
||||
right: RichMarkdownReviewNotePosition
|
||||
): number {
|
||||
const topCompare = left.top - right.top
|
||||
if (topCompare !== 0) {
|
||||
return topCompare
|
||||
}
|
||||
const startCompare = getReviewNoteStartLine(left.comment) - getReviewNoteStartLine(right.comment)
|
||||
if (startCompare !== 0) {
|
||||
return startCompare
|
||||
}
|
||||
if (left.comment.lineNumber !== right.comment.lineNumber) {
|
||||
return left.comment.lineNumber - right.comment.lineNumber
|
||||
}
|
||||
return left.comment.createdAt - right.comment.createdAt
|
||||
}
|
||||
|
||||
export function stackRichMarkdownReviewNotePositions(
|
||||
positions: readonly RichMarkdownReviewNotePosition[],
|
||||
measuredHeights?: Map<string, number>
|
||||
): RichMarkdownReviewNotePosition[] {
|
||||
let nextOpenTop = 0
|
||||
return [...positions].sort(compareRichMarkdownReviewNotePositions).map((position) => {
|
||||
const top = Math.max(position.top, nextOpenTop)
|
||||
const measured = measuredHeights?.get(position.comment.id)
|
||||
const estimatedHeight =
|
||||
REVIEW_NOTE_BASE_HEIGHT_PX +
|
||||
position.comment.body.split('\n').length * REVIEW_NOTE_BODY_LINE_HEIGHT_PX +
|
||||
(position.comment.selectedText ? REVIEW_NOTE_QUOTE_HEIGHT_PX : 0)
|
||||
const height = measured ?? estimatedHeight
|
||||
nextOpenTop = top + height + REVIEW_NOTE_GAP_PX
|
||||
return { ...position, top }
|
||||
})
|
||||
}
|
||||
|
||||
export function shouldExpandRichMarkdownReviewRail({
|
||||
hasReviewNotes,
|
||||
reviewRailOpen,
|
||||
hasDraftNote
|
||||
}: RichMarkdownReviewRailState): boolean {
|
||||
return hasDraftNote || (hasReviewNotes && reviewRailOpen)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DiffComment } from '../../../shared/types'
|
||||
import {
|
||||
formatMarkdownReviewCardQuote,
|
||||
formatMarkdownReviewNotes,
|
||||
getMarkdownReviewCardQuote,
|
||||
getMarkdownReviewExcerpt,
|
||||
getMarkdownReviewHighlightedText,
|
||||
sortMarkdownReviewNotes,
|
||||
@@ -61,6 +63,16 @@ describe('markdown review notes', () => {
|
||||
expect(highlighted).toBe('two\nthree')
|
||||
})
|
||||
|
||||
it('normalizes card quote text into a short single-line preview', () => {
|
||||
expect(formatMarkdownReviewCardQuote(' Hiring\nupdate for the team ')).toBe(
|
||||
'Hiring update for the team'
|
||||
)
|
||||
expect(
|
||||
getMarkdownReviewCardQuote('one\ntwo broad line\nthree', note({ selectedText: 'broad' }))
|
||||
).toBe('broad')
|
||||
expect(formatMarkdownReviewCardQuote('a'.repeat(120))).toBe(`${'a'.repeat(93)}...`)
|
||||
})
|
||||
|
||||
it('formats a deterministic prompt for terminal agents', () => {
|
||||
const formatted = formatMarkdownReviewNotes(
|
||||
[note({ startLine: 2, lineNumber: 3, body: 'replace "maybe"\nwith specifics' })],
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DiffComment } from '../../../shared/types'
|
||||
import { getDiffCommentLineLabel } from './diff-comment-compat'
|
||||
|
||||
const MAX_EXCERPT_LINES = 8
|
||||
const MAX_CARD_QUOTE_LENGTH = 96
|
||||
|
||||
export type MarkdownReviewNote = DiffComment & { source: 'markdown' }
|
||||
|
||||
@@ -65,6 +66,24 @@ export function getMarkdownReviewHighlightedText(
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function formatMarkdownReviewCardQuote(text: string | null | undefined): string | undefined {
|
||||
const normalized = text?.replace(/\s+/g, ' ').trim()
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
if (normalized.length <= MAX_CARD_QUOTE_LENGTH) {
|
||||
return normalized
|
||||
}
|
||||
return `${normalized.slice(0, MAX_CARD_QUOTE_LENGTH - 3).trimEnd()}...`
|
||||
}
|
||||
|
||||
export function getMarkdownReviewCardQuote(
|
||||
content: string,
|
||||
note: Pick<DiffComment, 'lineNumber' | 'selectedText' | 'startLine'>
|
||||
): string | undefined {
|
||||
return formatMarkdownReviewCardQuote(getMarkdownReviewHighlightedText(content, note))
|
||||
}
|
||||
|
||||
export function formatMarkdownReviewNotes(
|
||||
notes: readonly MarkdownReviewNote[],
|
||||
content: string
|
||||
|
||||
Reference in New Issue
Block a user