fix: address review findings (#3461)

This commit is contained in:
Jinjing
2026-05-29 22:02:44 -07:00
committed by GitHub
parent 388bc59a58
commit a66dc12cb9
14 changed files with 1265 additions and 773 deletions
+3 -2
View File
@@ -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
View File
@@ -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;
}
+97 -144
View File
@@ -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