From 4bcdf041e1da17ff8994c0a2f1fd48845cd53e32 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:31 -0500 Subject: [PATCH 01/10] Add shared version comparison UI Extend the existing shared review surface with commit and version navigation, whole-diff comparison state, review-history sidebars, and the controls needed to inspect changed files across review iterations. Keep the UI host-driven so Codiff Web can supply provider data without embedding GitLab or local Electron behavior in the components. --- core/App.css | 1111 ++++- core/SharedWalkthroughApp.tsx | 3758 ++++++++++++++++- core/__tests__/App.test.tsx | 11 + core/app/components/CommitRefTooltip.tsx | 83 + core/app/components/CommitScopePanel.tsx | 157 + core/app/components/ReviewCodeView.tsx | 148 +- core/app/components/Sidebar.tsx | 392 +- .../walkthrough/NarrativeSidebar.tsx | 182 +- .../walkthrough/NarrativeWalkthroughView.tsx | 255 +- .../walkthrough/WalkthroughProgress.tsx | 18 +- core/app/components/walkthrough/icons.tsx | 21 + core/index.ts | 21 + core/lib/diff.ts | 9 +- core/lib/narrative-walkthrough.ts | 26 +- core/react.ts | 35 + core/walkthrough.css | 260 +- 16 files changed, 6018 insertions(+), 469 deletions(-) create mode 100644 core/app/components/CommitRefTooltip.tsx create mode 100644 core/app/components/CommitScopePanel.tsx create mode 100644 core/app/components/walkthrough/icons.tsx diff --git a/core/App.css b/core/App.css index a3fc0d5c..57ea8dd4 100644 --- a/core/App.css +++ b/core/App.css @@ -835,6 +835,8 @@ a.review-top-bar-source:focus-visible { align-items: center; display: flex; flex: none; + height: 30px; + justify-content: center; width: auto; } @@ -910,6 +912,12 @@ a.review-top-bar-source:focus-visible { width: 6px; } +.app-shell > .sidebar, +.app-shell > .sidebar-resizer, +.app-shell > .review { + grid-row: 2; +} + .sidebar-toggle-button { -webkit-app-region: no-drag; align-items: center; @@ -948,12 +956,6 @@ a.review-top-bar-source:focus-visible { z-index: 21; } -.app-shell > .sidebar, -.app-shell > .sidebar-resizer, -.app-shell > .review { - grid-row: 2; -} - .sidebar-resizer::before { background: transparent; content: ''; @@ -1225,6 +1227,35 @@ html[data-codiff-platform='darwin'] .sidebar { background: var(--sidebar-vibrancy-tint); } +.sidebar-header { + -webkit-app-region: drag; + border-bottom: 1px solid var(--sidebar-border); + flex: none; + min-width: 0; + padding: 0 14px; +} + +.sidebar-path-row { + align-items: center; + display: flex; + gap: 8px; + min-height: 38px; + min-width: 0; + padding-left: 64px; +} + +.app-shell.window-fullscreen .sidebar-path-row { + padding-left: 0; +} + +.share-shell .sidebar-path-row { + padding-left: 0; +} + +.merge-request-shell .sidebar-path-row { + gap: 6px; +} + .merge-request-nav-button { align-items: center; background: transparent; @@ -1238,7 +1269,348 @@ html[data-codiff-platform='darwin'] .sidebar { justify-content: center; padding: 0; text-decoration: none; - width: 28px; + min-width: 28px; +} +.sidebar-version-compare-button { + padding-inline: 8px; + width: auto; +} + +.version-comparison-section { + display: grid; + gap: 8px; + min-width: 0; + padding: 8px 12px; +} + +.version-picker-pair { + display: grid; + gap: 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.version-picker { + min-width: 0; + position: relative; +} + +.version-picker-label { + color: var(--muted); + display: block; + font: 10px/1.35 var(--font-mono); + margin-bottom: 3px; + text-transform: uppercase; +} + +.version-picker-trigger { + align-items: center; + background: rgb(127 127 127 / 0.08); + border: 1px solid rgb(127 127 127 / 0.18); + border-radius: 6px; + color: var(--sidebar-text); + cursor: pointer; + display: flex; + font: 12px/1.35 var(--font-mono); + justify-content: space-between; + min-width: 0; + padding: 5px 7px; + width: 100%; +} + +.version-picker-positioner { + /* Base UI renders this in a portal, outside the sidebar's clipping context. */ + z-index: 1000; +} + +.version-picker-popover { + background: var(--code-bg); + border: 1px solid rgb(127 127 127 / 0.25); + border-radius: 7px; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.25); + max-height: min(320px, 55vh); + min-width: min(560px, calc(100vw - 32px)); + overflow: auto; + padding: 3px; +} + +.version-picker-popover [role='listbox'] { + display: grid; +} + +.version-picker-option { + align-items: center; + background: transparent; + border: 0; + border-radius: 5px; + color: var(--sidebar-text); + cursor: pointer; + display: grid; + font: 11px/1.5 var(--font-mono); + gap: 5px; + grid-template-columns: 32px 36px minmax(54px, 1fr) auto auto auto minmax(110px, auto); + min-width: 0; + padding: 4px 5px; + text-align: left; + white-space: nowrap; +} + +.version-picker-option:hover, +.version-picker-option[data-highlighted], +.version-picker-option[data-selected] { + background: rgb(127 127 127 / 0.14); +} + +.version-picker-option[data-disabled] { + cursor: not-allowed; + opacity: 0.42; +} + +.version-picker-option code { + overflow: hidden; + text-overflow: ellipsis; +} + +.version-comparison-status { + align-items: center; + color: var(--muted); + display: flex; + font: 11px/1.4 var(--font-mono); + gap: 6px; + min-height: 24px; +} + +.version-comparison-status.error { color: var(--danger); } + +.version-comparison-spinner { + animation: version-comparison-spin 0.8s linear infinite; + border: 2px solid rgb(127 127 127 / 0.25); + border-radius: 50%; + border-top-color: var(--sidebar-text); + height: 12px; + width: 12px; +} + +@keyframes version-comparison-spin { to { transform: rotate(360deg); } } + +.version-picker-head { color: var(--accent, var(--sidebar-text)); font-weight: 700; } +.version-picker-additions { color: var(--diff-addition); } +.version-picker-deletions { color: var(--diff-deletion); } +.version-picker-timing { color: var(--muted); } + +.version-base-movement, +.version-commit-evolution, +.version-walkthrough-structure, +.version-unit-scope { + background: rgb(127 127 127 / 0.06); + border: 1px solid rgb(127 127 127 / 0.14); + border-radius: 7px; + color: var(--sidebar-text); + font: 11px/1.45 var(--font-sans); + padding: 7px; +} + +.version-base-movement a { color: inherit; font-family: var(--font-mono); } +.version-base-movement-stat, +.version-base-movement small, +.version-commit-evolution small, +.version-walkthrough-structure small { color: var(--muted); } +.version-base-movement-stat { + font: 11px/1.45 var(--font-sans); + letter-spacing: 0; + text-transform: none; +} +.version-base-movement small { display: block; margin-top: 3px; } +.version-base-movement-commits { margin-top: 6px; } +.version-base-movement-commits > summary { + color: var(--muted); + cursor: pointer; + font: 11px/1.45 var(--font-sans); + letter-spacing: 0; + list-style: none; + text-transform: none; + user-select: none; +} +.version-base-movement-commits > summary::-webkit-details-marker { display: none; } +.version-base-movement-commits > summary::before { + content: '▸'; + display: inline-block; + margin-right: 4px; + transition: transform 0.12s ease; +} +.version-base-movement-commits[open] > summary::before { transform: rotate(90deg); } +.version-base-movement-commit-list { + margin-top: 5px; + max-height: 220px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 2px; +} +.version-base-movement-commit { + color: inherit; + font: 11px/1.4 var(--font-sans); + letter-spacing: 0; + text-decoration: none; + text-transform: none; +} +.version-base-movement-commit:hover { + background: rgb(127 127 127 / 0.14); +} +.version-commit-evolution > strong { display: block; margin-bottom: 4px; } + +.version-commit-evolution-list { display: grid; gap: 1px; } +.version-commit-unit { + align-items: center; + background: transparent; + border: 0; + border-radius: 5px; + color: inherit; + cursor: pointer; + display: grid; + font: 10px/1.35 var(--font-sans); + gap: 5px; + grid-template-columns: 12px auto minmax(0, 1fr); + padding: 4px; + text-align: left; +} +.version-commit-unit:disabled { cursor: default; } +.version-commit-unit:not(:disabled):hover, +.version-commit-unit[aria-pressed='true'] { background: rgb(127 127 127 / 0.14); } +.version-commit-unit code { font-size: 9px; white-space: nowrap; } +.version-commit-unit > span:nth-child(3) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.version-commit-unit.unchanged { opacity: 0.5; } +.version-commit-unit.unchanged:not(:disabled):hover { opacity: 0.8; } +.version-commit-kind { font-weight: 700; } +.version-commit-kind.added { color: var(--diff-addition); } +.version-commit-kind.removed { color: var(--diff-deletion); } +.version-commit-kind.likely-revised, +.version-commit-kind.revised { + color: #d9962f; +} + +.version-commit-kind.introduced { + color: #3f9a5a; +} +.version-commit-kind.absorbed-into-base { color: var(--muted); } +.version-commit-kind.ambiguous { color: var(--muted); } +.version-commit-kind.unchanged { color: var(--muted); } +.version-commit-unit-block { display: grid; gap: 1px; } +.version-commit-rebase-drivers { + display: grid; + gap: 1px; + margin: 0 0 2px 12px; + padding-left: 6px; + border-left: 1px solid rgb(127 127 127 / 0.22); +} +.version-commit-rebase-drivers-label { + color: var(--muted); + font: 9px/1.3 var(--font-sans); + padding: 2px 4px 0; +} +.version-commit-rebase-drivers .version-commit-unit { + opacity: 0.88; +} + + +.version-unit-scope { align-items: center; display: flex; justify-content: space-between; } +.version-unit-scope button { background: transparent; border: 0; color: var(--accent, var(--sidebar-text)); cursor: pointer; font-size: 10px; } +.version-tree-commit-scope { + border-bottom: 1px solid var(--sidebar-border); + display: grid; + flex: none; + gap: 5px; + padding: 6px 8px 8px; +} +.version-tree-commit-scope-header { + align-items: center; + display: flex; + justify-content: space-between; +} +.version-tree-commit-scope-header > span { + color: var(--muted); + font: 600 10px/1.35 var(--font-sans); +} +.version-tree-commit-scope-header button { + background: transparent; + border: 0; + color: var(--accent, var(--sidebar-text)); + cursor: pointer; + font: 10px/1.35 var(--font-sans); + padding: 2px 0; +} +.version-tree-commit-scope-header button:disabled { + color: var(--muted); + cursor: default; + opacity: 0.55; +} +.version-tree-commit-options { + display: grid; + gap: 1px; + max-height: 190px; + overflow-y: auto; + overscroll-behavior: contain; +} +.version-tree-commit-options label { + align-items: center; + border-radius: 4px; + cursor: pointer; + display: grid; + font: 10px/1.35 var(--font-sans); + gap: 5px; + grid-template-columns: auto auto minmax(0, 1fr); + min-height: 25px; + padding: 3px 4px; +} +.version-tree-commit-options label:hover { + background: rgb(127 127 127 / 0.1); +} +.version-tree-commit-options input { + margin: 0; +} +.version-tree-commit-options code { + font: 9px/1.35 var(--font-mono); + white-space: nowrap; +} +.version-tree-commit-options span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.version-walkthrough-structure { display: grid; gap: 5px; margin-bottom: 10px; } +.version-walkthrough-structure label { align-items: center; display: flex; gap: 5px; } +.version-walkthrough-structure .sidebar-version-compare-button { margin-top: 3px; } + +@media (max-width: 480px) { + .version-picker-popover { min-width: calc(100vw - 24px); } + .version-picker-option { gap: 3px; grid-template-columns: 29px 31px minmax(44px, 1fr) auto auto auto 24px; } + .version-picker-option { + grid-template-columns: 29px 31px minmax(44px, 1fr) auto auto auto; + } + .version-picker-timing { grid-column: 1 / -1; padding-left: 60px; } +} +.sidebar-diff-scope-header { + align-items: center; + display: flex; + flex: none; + justify-content: space-between; + padding: 8px 8px 0; +} + +.sidebar-diff-scope-header > span, +.sidebar-scope-status { + color: var(--muted); + font: 10px/1.35 var(--font-mono); + text-transform: uppercase; +} + +.sidebar-scope-status { + padding: 10px 8px; + text-transform: none; +} + +.sidebar-scope-status.error { + color: var(--danger); } .merge-request-nav-button:not(.merge-request-home-button):hover { @@ -1259,6 +1631,19 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 18px 14px; } +.sidebar-path { + color: var(--sidebar-text); + flex: 1; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + line-height: 1.35; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .sidebar-search-row { display: grid; flex: none; @@ -1266,6 +1651,95 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 10px 8px; } +.sidebar-mode-toggle { + border-bottom: 1px solid var(--sidebar-border); + display: grid; + flex: none; + gap: 6px; + grid-template-columns: minmax(48px, 0.75fr) minmax(96px, 1.35fr) minmax(68px, 0.9fr); + padding: 0 8px; +} + +.share-shell .sidebar-mode-toggle { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.merge-request-shell .sidebar-mode-toggle { + grid-template-columns: minmax(42px, 0.65fr) minmax(88px, 1.05fr) minmax(102px, 1.3fr); +} +.sidebar-commit-selector { + display: grid; + flex: none; + gap: 4px; + padding: 4px 8px 8px; +} + +.sidebar-commit-selector span { + color: var(--muted); + font: 10px/1 var(--font-mono); + text-transform: uppercase; +} + +.sidebar-commit-selector select { + min-width: 0; + width: 100%; +} + +.sidebar-mode-toggle button { + -webkit-app-region: no-drag; + background: transparent; + border: 0; + border-radius: 24px 24px 0 0; + color: var(--muted); + corner-shape: squircle; + cursor: pointer; + display: inline-flex; + gap: 5px; + font: 600 12px/1.35 var(--font-sans); + height: 30px; + align-items: center; + justify-content: center; + min-width: 0; + padding: 0 8px; +} + +.sidebar-mode-toggle button span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-tab-count { + background: rgb(127 127 127 / 0.16); + border-radius: 999px; + color: var(--muted); + flex: none; + font: 700 10px/1 var(--font-mono); + min-width: 16px; + padding: 3px 5px; + text-align: center; +} + +.sidebar-mode-toggle button[aria-selected='true'] .sidebar-tab-count { + background: rgb(127 127 127 / 0.22); + color: var(--sidebar-text); +} + +.sidebar-mode-toggle button:hover:not(:disabled) { + background: rgb(127 127 127 / 0.1); + color: var(--sidebar-text); +} + +.sidebar-mode-toggle button[aria-selected='true'] { + background: rgb(127 127 127 / 0.14); + color: var(--sidebar-text); +} + +.sidebar-mode-toggle button:disabled { + cursor: default; + opacity: 0.45; +} + .sidebar-settings-bar { align-items: center; border-top: 1px solid var(--sidebar-border); @@ -1336,71 +1810,351 @@ html[data-codiff-platform='darwin'] .sidebar { color: var(--sidebar-text); } -.file-tree-shell, -.file-tree { - flex: 1; - display: block; - min-height: 0; +.file-tree-shell, +.file-tree { + flex: 1; + display: block; + min-height: 0; +} + +.file-tree { + height: 100%; +} + +.history-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 2px; + min-height: 0; + overflow: auto; + padding: 8px 6px; +} + +.history-entry { + -webkit-app-region: no-drag; + background: transparent; + border: 0; + border-radius: 8px; + color: var(--text); + corner-shape: squircle; + cursor: pointer; + display: grid; + column-gap: 8px; + grid-template-columns: 72px minmax(0, 1fr); + padding: 3px 5px; + row-gap: 2px; + text-align: left; + width: 100%; +} + +.history-entry:hover, +.history-entry.selected { + background: color-mix(in srgb, var(--tree-selection-bg) 46%, transparent); +} + +.history-entry.selected { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tree-selection-focus) 42%, transparent); +} + +.history-section { + align-items: center; + color: var(--text); + display: flex; + font: 700 11px/1.25 var(--font-sans); + margin-top: 8px; + min-height: 20px; + overflow: hidden; + padding: 4px 10px 1px; + position: relative; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + z-index: 1; +} + +.history-list > .history-section:first-child { + margin-top: 0; +} + +/* Override the generic single-row history label layout. The comparison controls + * deliberately occupy stacked rows so status text never sits beside a picker. */ +.history-section.version-comparison-section { + align-items: stretch; + border-bottom: 1px solid var(--sidebar-border); + display: grid; + flex: none; + gap: 0; + grid-template-rows: auto minmax(0, 1fr); + max-height: min(46vh, 420px); + min-height: 0; + overflow: hidden; + padding: 0; + white-space: normal; +} + +.history-section.version-comparison-section.collapsed { + grid-template-rows: auto; + max-height: none; +} + +.version-comparison-header { + align-items: center; + display: grid; + gap: 6px; + grid-template-columns: minmax(0, 1fr) auto; + min-width: 0; + padding: 8px 10px 8px 8px; +} + +.version-comparison-toggle { + align-items: flex-start; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: grid; + gap: 6px; + grid-template-columns: 12px minmax(0, 1fr); + min-width: 0; + padding: 0; + text-align: left; +} + +.version-comparison-toggle-caret { + color: var(--muted); + font: 11px/1.35 var(--font-mono); + margin-top: 2px; +} + +.version-comparison-toggle-copy { + display: grid; + gap: 2px; + min-width: 0; +} + +.version-comparison-toggle-copy > strong { + color: var(--sidebar-text); + font: 700 11px/1.25 var(--font-sans); + text-transform: uppercase; +} + +.version-comparison-summary { + color: var(--muted); + font: 11px/1.35 var(--font-mono); + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} + +.diff-scope-control { + border: 1px solid var(--sidebar-border); + border-radius: 6px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: hidden; +} + +.diff-scope-control button { + background: transparent; + border: 0; + color: var(--muted); + cursor: pointer; + font: 600 10px/1.25 var(--font-sans); + min-width: 0; + padding: 5px 7px; + white-space: nowrap; +} + +.diff-scope-control button + button { + border-left: 1px solid var(--sidebar-border); +} + +.diff-scope-control button.selected { + background: color-mix(in srgb, var(--tree-selection-bg) 72%, transparent); + color: var(--sidebar-text); +} + +.diff-scope-control button:disabled { + cursor: default; +} + +.version-comparison-endpoint { + align-items: baseline; + display: inline-flex; + gap: 4px; +} + +.version-comparison-exit { + flex: none; +} + +.commit-scope-heading { + background: transparent; + border: 0; + color: var(--muted); + cursor: pointer; + display: flex; + flex-direction: column; + font: 600 10px/1.35 var(--font-sans); + gap: 0; + min-width: 0; + padding: 0; + text-align: left; +} + +.commit-scope-heading-label { + align-items: center; + display: inline-flex; + gap: 4px; +} + +.commit-scope-heading-label svg { + color: var(--muted); + transition: transform 120ms ease; +} + +.commit-scope-heading-label svg.collapsed { + transform: rotate(-90deg); +} + +.commit-scope-heading small { + color: var(--muted); + font: 9px/1.25 var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-comment-section { + border-bottom: 1px solid var(--sidebar-border); + min-width: 0; +} + +.sidebar-comment-section-toggle { + align-items: center; + background: transparent; + border: 0; + color: var(--sidebar-text); + cursor: pointer; + display: flex; + justify-content: space-between; + min-width: 0; + padding: 8px 10px; + text-align: left; + width: 100%; +} + +.sidebar-comment-section-toggle > span { + align-items: baseline; + display: flex; + gap: 6px; + min-width: 0; +} + +.sidebar-comment-section-toggle strong { + font: 700 11px/1.25 var(--font-sans); + text-transform: uppercase; +} + +.sidebar-comment-section-toggle small { + color: var(--muted); + font: 11px/1.25 var(--font-mono); +} + +.sidebar-comment-section-toggle svg { + color: var(--muted); + flex: none; + transition: transform 120ms ease; +} + +.sidebar-comment-section-toggle svg.collapsed { + transform: rotate(-90deg); +} + +.sidebar-comment-section-body { + min-width: 0; } -.file-tree { - height: 100%; +.ai-review-drawer { + border-bottom: 1px solid var(--sidebar-border); + min-width: 0; } -.history-list { - display: flex; - flex-direction: column; - flex: 1; - gap: 2px; - min-height: 0; - overflow: auto; - padding: 8px 6px; +.sidebar-comments-preferences { + border-bottom: 1px solid var(--sidebar-border); + padding: 6px 10px; } -.history-entry { - -webkit-app-region: no-drag; +.ai-review-drawer-toggle { + align-items: center; background: transparent; border: 0; - border-radius: 8px; - color: var(--text); - corner-shape: squircle; + color: inherit; cursor: pointer; - display: grid; - column-gap: 8px; - grid-template-columns: 72px minmax(0, 1fr); - padding: 3px 5px; - row-gap: 2px; + display: flex; + justify-content: space-between; + min-width: 0; + padding: 8px 10px; text-align: left; width: 100%; } -.history-entry:hover, -.history-entry.selected { - background: color-mix(in srgb, var(--tree-selection-bg) 46%, transparent); -} - -.history-entry.selected { - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tree-selection-focus) 42%, transparent); +.ai-review-drawer-toggle > span:first-child { + display: grid; + gap: 2px; + min-width: 0; } -.history-section { - align-items: center; - color: var(--text); - display: flex; - font: 700 11px/1.25 var(--font-sans); - margin-top: 8px; - min-height: 20px; +.ai-review-drawer-toggle small { + color: var(--muted); overflow: hidden; - padding: 4px 10px 1px; - position: relative; text-overflow: ellipsis; - text-transform: uppercase; white-space: nowrap; - z-index: 1; } -.history-list > .history-section:first-child { - margin-top: 0; +.ai-review-drawer-body { + display: grid; + gap: 8px; + max-height: 320px; + overflow: auto; + padding: 0 10px 10px; +} + +.ai-review-list.history-list { + flex: none; + max-height: 280px; + overflow: auto; + padding: 0; +} + +.ai-review-entry { + border-top: 1px solid var(--sidebar-border); + display: grid; + gap: 3px; + padding-top: 8px; +} + +.ai-review-entry small { + color: var(--muted); +} + +.ai-review-entry p { + margin: 0; + white-space: pre-wrap; +} + +.version-comparison-body { + display: grid; + gap: 8px; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + padding: 0 12px 10px; +} + +.version-comparison-section.collapsed .version-comparison-header { + padding-bottom: 8px; } .history-entry-subject { @@ -1540,6 +2294,17 @@ html[data-codiff-platform='darwin'] .sidebar { overflow: visible; } +.walkthrough-generating-main { + display: grid; + gap: 10px; + justify-items: center; +} + +.walkthrough-generating-main > span { + color: var(--muted); + font: 12px/1.4 var(--font-sans); +} + .sidebar-comment-list.history-list { padding-top: 6px; } @@ -1571,12 +2336,23 @@ html[data-codiff-platform='darwin'] .sidebar { } .walkthrough-list { + display: flex; flex: 1; + flex-direction: column; min-height: 0; - overflow: auto; + overflow: hidden; padding: 4px 4px 0; } +.walkthrough-list > .wt-focus { + flex: none; +} + +.walkthrough-list > .wt-toc-scroll { + flex: 1; + min-height: 0; +} + .walkthrough-inline-code { -webkit-box-decoration-break: clone; background: var(--inline-code-bg); @@ -1693,6 +2469,8 @@ html[data-codiff-platform='darwin'] .sidebar { flex: none; gap: 8px; justify-content: flex-end; + min-width: max-content; + white-space: nowrap; } .pull-request-merge-status-badge { @@ -1727,6 +2505,14 @@ html[data-codiff-platform='darwin'] .sidebar { overflow: clip; } +.codiff-source-description-panel:has(.review-submit-popover), +.codiff-source-description-overview-card:has(.review-submit-popover), +.codiff-source-description-overview-status:has(.review-submit-popover) { + overflow: visible; + position: relative; + z-index: 13; +} + .codiff-source-description-panel, .merge-request-comments-source-description { font-size: 14px; @@ -1752,6 +2538,35 @@ html[data-codiff-platform='darwin'] .sidebar { background: var(--code-bg); } +.codiff-source-description-overview { + align-items: start; + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr) minmax(340px, 380px); + padding: 11px 12px 12px; +} + +.codiff-source-description-overview-aside { + min-width: 0; +} + +.codiff-source-description-overview-aside { + display: flex; + flex-direction: column; + gap: 8px; +} + +.codiff-source-description-overview-card { + background: var(--code-bg); + border-radius: var(--codiff-diff-radius); + box-shadow: + 0 0 0 1px var(--file-border), + 6px 18px 80px -54px rgb(0 0 0 / 0.78); + corner-shape: squircle; + min-width: 0; + overflow: clip; +} + .codiff-source-description-footer { padding: 0 8px 8px 50px; } @@ -2011,6 +2826,21 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 11px 12px 48px; } +.merge-request-review-surface { + display: flex; + flex: 1; + flex-direction: column; + gap: 12px; + min-height: 0; + overflow: hidden; + padding: 11px 12px 0; +} + +.merge-request-review-surface > :last-child { + flex: 1; + min-height: 0; +} + .merge-request-comments-source-description { --codiff-diff-radius: 28px; @@ -2700,6 +3530,7 @@ html[data-codiff-platform='darwin'] .sidebar { .codiff-file-comment-button, .codiff-markdown-button, .codiff-button, +.codiff-open-button, .codiff-viewed-button, .codiff-copy-path-button { -webkit-app-region: no-drag; @@ -2743,7 +3574,8 @@ html[data-codiff-platform='darwin'] .sidebar { color: var(--viewed); } -.codiff-button.plan-download-button { +.codiff-button.plan-download-button, +.codiff-open-button.plan-download-button { height: 30px; padding: 0; width: 30px; @@ -2795,6 +3627,7 @@ html[data-codiff-platform='darwin'] .sidebar { .codiff-file-comment-button:hover, .codiff-markdown-button:hover, .codiff-button:hover:not(:disabled), +.codiff-open-button:hover:not(:disabled), .codiff-viewed-button:hover { background: rgb(127 127 127 / 0.11); } @@ -2863,6 +3696,7 @@ html[data-codiff-platform='darwin'] .sidebar { .codiff-load-button, .codiff-markdown-button, .codiff-button, +.codiff-open-button, .codiff-viewed-button { border-radius: 14px; corner-shape: squircle; @@ -2899,14 +3733,92 @@ html[data-codiff-platform='darwin'] .sidebar { color: var(--diff-deletion); } +.diffstat-additions { + color: var(--diff-addition); +} + +.diffstat-deletions { + color: var(--diff-deletion); +} + +.git-commit-ref-trigger { + align-items: center; + color: var(--tree-selection-focus); + cursor: pointer; + display: inline-flex; + min-width: 0; + text-decoration: none; +} + +.git-commit-ref-trigger code { + color: inherit; + font: inherit; + white-space: nowrap; +} + +.git-commit-ref-trigger:focus-visible { + border-radius: 3px; + outline: 2px solid var(--tree-selection-focus); + outline-offset: 2px; +} + +.git-commit-tooltip-positioner { + z-index: 10000; +} + +.git-commit-tooltip { + background: var(--code-bg); + border: 1px solid var(--file-border); + border-radius: 6px; + box-shadow: 0 10px 28px rgb(0 0 0 / 0.22); + color: var(--text); + display: grid; + gap: 5px; + max-width: min(360px, calc(100vw - 24px)); + padding: 9px 10px; +} + +.git-commit-tooltip > strong { + font: 600 11px/1.4 var(--font-sans); + overflow-wrap: anywhere; +} + +.git-commit-tooltip-sha { + color: var(--muted); + font: 10px/1.4 var(--font-mono); + overflow-wrap: anywhere; +} + +.git-commit-tooltip-meta { + color: var(--muted); + font: 10px/1.4 var(--font-sans); +} + +.git-commit-tooltip-diffstat { + display: inline-flex; + font: 600 10px/1.4 var(--font-mono); + gap: 7px; +} + +.git-commit-tooltip-link { + align-items: center; + color: var(--tree-selection-focus); + display: inline-flex; + font: 600 10px/1.4 var(--font-sans); + gap: 5px; + margin-top: 2px; + text-decoration: none; + width: fit-content; +} + .codiff-status-badge.added, .codiff-status-badge.untracked, .codiff-section-badge.unstaged { - color: rgb(31 122 68); + color: var(--diff-addition); } .codiff-status-badge.deleted { - color: rgb(184 49 47); + color: var(--diff-deletion); } .codiff-status-badge.renamed, @@ -2917,6 +3829,8 @@ html[data-codiff-platform='darwin'] .sidebar { .codiff-markdown-button, .codiff-load-button, .codiff-file-comment-button, +.codiff-button, +.codiff-open-button, .codiff-viewed-button { border: 1px solid rgb(127 127 127 / 0.18); flex: none; @@ -2945,19 +3859,17 @@ html[data-codiff-platform='darwin'] .sidebar { opacity: 0.72; } -.codiff-button { +.codiff-button, +.codiff-open-button { box-shadow: 0 0 0 1px rgb(255 255 255 / 0) inset, 0 10px 32px -24px rgb(0 0 0 / 0), 0 8px 20px -16px rgb(0 0 0 / 0); - flex: none; - gap: 7px; transform: scale(1); transition: background 160ms ease, box-shadow 250ms ease, transform 250ms cubic-bezier(0.34, 1.56, 0.64, 1); - white-space: nowrap; } .codiff-button-size-default { @@ -3010,18 +3922,21 @@ html[data-codiff-platform='darwin'] .sidebar { text-decoration: underline; } -.codiff-button:hover:not(:disabled) { +.codiff-button:hover:not(:disabled), +.codiff-open-button:hover:not(:disabled) { box-shadow: 0 10px 32px -24px rgb(0 0 0 / 0.92), 0 8px 20px -16px rgb(0 0 0 / 0.82); transform: scale(1.05); } -.codiff-button:active:not(:disabled):not(:has(.review-submit-toggle:active)) { +.codiff-button:active:not(:disabled):not(:has(.review-submit-toggle:active)), +.codiff-open-button:active:not(:disabled):not(:has(.review-submit-toggle:active)) { transform: scale(0.95); } -.codiff-button:disabled { +.codiff-button:disabled, +.codiff-open-button:disabled { color: var(--muted); cursor: default; opacity: 0.55; @@ -3621,9 +4536,11 @@ diffs-container .review-comment-thread { .review-submit-primary { gap: 7px; padding: 0 9px 0 11px; + white-space: nowrap; } .review-submit-toggle { + flex: none; width: 28px; } @@ -3679,6 +4596,7 @@ diffs-container .review-comment-thread { .review-submit-button.close { color: var(--text); + white-space: nowrap; } .review-submit-popover { @@ -3787,7 +4705,6 @@ diffs-container .review-comment-thread { .loading { color: var(--muted); font-family: var(--font-mono); - font-style: italic; font-size: 13px; transform: translateY(-12px); user-select: none; @@ -3903,6 +4820,7 @@ diffs-container .review-comment-thread { .codiff-load-button, .codiff-file-comment-button, .codiff-button, + .codiff-open-button, .codiff-viewed-button { padding-inline: 9px; } @@ -4177,3 +5095,68 @@ diffs-container .review-comment-thread { display: none; } } + +@container sidebar (max-width: 260px) { + .sidebar-mode-toggle { + gap: 4px; + grid-template-columns: minmax(42px, 0.75fr) minmax(82px, 1.35fr) minmax(58px, 0.9fr); + padding-inline: 8px; + } + + .merge-request-shell .sidebar-mode-toggle { + grid-template-columns: minmax(40px, 0.65fr) minmax(78px, 1.05fr) minmax(88px, 1.3fr); + } + + .sidebar-mode-toggle button { + font-size: 11px; + gap: 4px; + padding-inline: 4px; + } + + .sidebar-tab-count { + min-width: 14px; + padding-inline: 4px; + } +} + + +.walkthrough-regenerate-controls { + align-items: stretch !important; + display: grid !important; + gap: 6px !important; + padding: 8px 12px !important; + text-transform: none !important; + white-space: normal !important; +} +.walkthrough-regenerate-controls > span { + color: var(--muted); + font: 10px/1.35 var(--font-mono); + text-transform: uppercase; +} +.walkthrough-regenerate-controls .merge-request-nav-button { + justify-content: flex-start; + min-height: 28px; + padding: 0 8px; + width: 100%; +} + + +.walkthrough-structure-controls { + align-items: stretch !important; + display: grid !important; + gap: 6px !important; + padding: 8px 12px !important; + text-transform: none !important; + white-space: normal !important; +} +.walkthrough-structure-controls > span { + color: var(--muted); + font: 10px/1.35 var(--font-mono); + text-transform: uppercase; +} +.walkthrough-structure-controls .merge-request-nav-button { + justify-content: flex-start; + min-height: 28px; + padding: 0 8px; + width: 100%; +} diff --git a/core/SharedWalkthroughApp.tsx b/core/SharedWalkthroughApp.tsx index d2a0a018..987b0040 100644 --- a/core/SharedWalkthroughApp.tsx +++ b/core/SharedWalkthroughApp.tsx @@ -1,16 +1,30 @@ +import { Select } from '@base-ui/react/select'; +import { MarkdownEditor, type MarkdownEditorHandle } from '@nkzw/mdx-editor'; +import useRelativeTime from '@nkzw/use-relative-time'; import { ArrowSquareOutIcon as ArrowSquareOut } from '@phosphor-icons/react/ArrowSquareOut'; import { ChatCircleIcon as ChatCircle } from '@phosphor-icons/react/ChatCircle'; import { PathIcon as Path } from '@phosphor-icons/react/Path'; import { TreeStructureIcon as TreeStructure } from '@phosphor-icons/react/TreeStructure'; -import { Trash2 } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { Button } from './app/components/Button.tsx'; -import { ReviewFileTree } from './app/components/FileTree.tsx'; +import type { FileTreeRowDecorationRenderer } from '@pierre/trees'; +import { FileTree, useFileTree } from '@pierre/trees/react'; +import { ChevronDown, Trash2, X } from 'lucide-react'; import { - MergeRequestCommentsView, - SidebarGeneralCommentList, - type ReviewCommenting, -} from './app/components/merge-request/GeneralComments.tsx'; + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from 'react'; +import { Avatar } from './app/components/Avatar.tsx'; +import { Button } from './app/components/Button.tsx'; +import { CommitRefTooltip } from './app/components/CommitRefTooltip.tsx'; +import { CommitScopePanel } from './app/components/CommitScopePanel.tsx'; import { isTerminalPullRequestMergeState, isPullRequestReviewActionDisabled, @@ -18,6 +32,7 @@ import { PullRequestMergeStatusBadge, PullRequestReviewButtons, } from './app/components/Panels.tsx'; +import { ReadOnlyMarkdownView } from './app/components/ReadOnlyMarkdownView.tsx'; import { PullRequestSourceDescription, ReviewCodeView, @@ -31,67 +46,101 @@ import { type WalkthroughBlockScrollTarget, } from './app/components/walkthrough/NarrativeWalkthroughView.tsx'; import { useNarrativeNavigation } from './app/components/walkthrough/useNarrativeNavigation.ts'; -import { WalkthroughDiffSurface } from './app/components/walkthrough/WalkthroughDiffSurface.tsx'; import { WalkthroughProgress } from './app/components/walkthrough/WalkthroughProgress.tsx'; -import { - getCodeFontLineHeight, - normalizeCodeFontSizePreference, - useDocumentAppearance, -} from './app/hooks/useDocumentAppearance.ts'; -import { useResizableSidebar } from './app/hooks/useResizableSidebar.ts'; -import { useReviewCommentDrafts } from './app/hooks/useReviewCommentDrafts.ts'; -import { useReviewFileState } from './app/hooks/useReviewState.ts'; import { createDefaultConfig } from './config/defaults.ts'; import { matchesShortcut } from './config/keymap.ts'; +import type { CodiffKeymap } from './config/types.ts'; import { getAgentLabel } from './lib/app-constants.ts'; -import type { CodeViewInstance, ReviewComment, ReviewScrollTarget } from './lib/app-types.ts'; +import type { + CodeViewInstance, + ReviewComment, + ReviewIdentity, + ReviewScrollTarget, +} from './lib/app-types.ts'; +import { DEFAULT_PADDING } from './lib/code-view-options.ts'; import { fileHasVisibleDiff, + formatTreeLineCount, getDiffLineCount, + getDiffLineCountTitle, + getFirstVisibleSection, + getItemId, getTotalDiffLineCount, isMarkdownFilePath, } from './lib/diff.ts'; -import { compactPath, fuzzyMatches, sortFiles } from './lib/files.ts'; +import { compactPath, fileTreeSort, fuzzyMatches, sortFiles, statusForTree } from './lib/files.ts'; import { isNativeInputTarget } from './lib/keyboard.ts'; import { isGeneratedWalkthroughFile } from './lib/narrative-walkthrough-diff.js'; import { + getCommentKey, getPendingPullRequestReviewComments, getReviewCommentsFromState, - mergeReviewComments, - toSubmittedReviewComment, toPullRequestReviewComment, } from './lib/review-comments.ts'; -import { getSelectedPathFromScroll } from './lib/review-scroll.ts'; import { - SIDEBAR_COLLAPSE_THRESHOLD, + evolutionUnitCommit, + evolutionUnitRebaseDrivers, + versionOptionHeadCommitId, + versionOptionLabelText, +} from './lib/review-history.ts'; +import { + updateReviewIdentityCollapsed, + updateReviewIdentityViewed, +} from './lib/review-identity.ts'; +import { SIDEBAR_DEFAULT_WIDTH, + clampSidebarWidth, readSidebarWidth, writeSidebarWidth, } from './lib/sidebar-width.ts'; -import { getSourceLabel, getSourceKey } from './lib/source.ts'; +import { getShortRef, getSourceLabel, getSourceKey } from './lib/source.ts'; import type { + ChangedFile, + CodiffPreferences, + DiffComparisonAnalysis, + DiffComparisonBaseMovement, + DiffComparisonCommentAssociation, + DiffComparisonView, GitIdentity, + NarrativeWalkthrough, + PullRequestAIReview, PullRequestMergeOptions, PullRequestGeneralComment, PullRequestGeneralCommentThread, PullRequestExistingReviewComment, PullRequestReviewComment, PullRequestReviewEvent, + ReviewAuthor, + ReviewCommitEvolution, + ReviewCommitListEntry, + ReviewCommitSummary, + ReviewEvolutionUnit, + ReviewRebaseDriverCommit, + ReviewStrategySummary, + ReviewVersionOption, RepositoryState, SharedWalkthroughSnapshot, WalkthroughCommitMessageResult, WalkthroughCommitResult, } from './types.ts'; -export { - ReadOnlyGeneralCommentCard, - type ReviewCommenting, -} from './app/components/merge-request/GeneralComments.tsx'; - const emptyReviewComments: ReadonlyArray = []; +const emptyExistingReviewComments: ReadonlyArray = []; const emptyGeneralCommentThreads: ReadonlyArray = []; const emptyPaths = new Set(); const emptyWalkthroughNotes = new Map(); +const showResolvedCommentsStorageKey = 'codiff:web-show-resolved-comments:v1'; +const walkthroughCodeViewBottomInset = 96; +const CODE_FONT_SIZE_DEFAULT = 13; +const defaultSharedPreferences: SharedWalkthroughSnapshot['preferences'] = { + codeFontFamily: 'Fira Code', + codeFontSize: CODE_FONT_SIZE_DEFAULT, + diffStyle: 'split', + showWhitespace: false, + theme: 'system', + wordWrap: false, +}; + const readSharedSidebarWidth = () => typeof localStorage === 'undefined' ? SIDEBAR_DEFAULT_WIDTH : readSidebarWidth(); @@ -101,8 +150,132 @@ const writeSharedSidebarWidth = (width: number) => { } }; -export type ReviewWalkthroughStatus = 'failed' | 'generating' | 'idle' | 'ready'; -export type ReviewMode = 'comments' | 'tree' | 'walkthrough'; +export type MergeRequestWalkthroughStatus = 'failed' | 'generating' | 'idle' | 'ready'; +export type MergeRequestReviewMode = 'comments' | 'commits' | 'tree' | 'walkthrough'; +export type ReviewWalkthroughStatus = MergeRequestWalkthroughStatus; +export type ReviewMode = MergeRequestReviewMode; + +// Optional host-supplied commit / version comparison surfaces for MR review. +// Hosts project provider history into Core contracts from `./types.ts`. +export type MergeRequestCommitListEntry = ReviewCommitListEntry; +export type MergeRequestReviewStrategySummary = ReviewStrategySummary; +export type MergeRequestVersionOption = ReviewVersionOption; +export type MergeRequestVersionCompareSummary = DiffComparisonAnalysis['summary']; +export type MergeRequestVersionCompareCommentAssociation = DiffComparisonCommentAssociation; +export type MergeRequestVersionBaseMovementCommit = NonNullable< + DiffComparisonBaseMovement['commits'] +>[number]; +export type MergeRequestVersionBaseMovement = DiffComparisonBaseMovement; +export type MergeRequestVersionCommitSummary = ReviewCommitSummary; +export type MergeRequestVersionRebaseDriverCommit = ReviewRebaseDriverCommit; +export type MergeRequestVersionCommitEvolutionUnit = ReviewEvolutionUnit; +export type MergeRequestVersionCommitEvolution = ReviewCommitEvolution; +export type MergeRequestVersionCompareView = DiffComparisonView; + +const versionOptionLabel = versionOptionLabelText; +const versionOptionHeadSha = versionOptionHeadCommitId; + +const versionOptionHeadUrl = (version: ReviewVersionOption, projectUrl?: string) => + version.range.head.label.url ?? + (projectUrl + ? `${projectUrl}/-/commit/${encodeURIComponent(version.range.head.commitId)}` + : undefined); + +export type SharedWalkthroughCommenting = { + canComment: boolean; + onDeleteComment: (commentId: string) => Promise; + onDeleteGeneralComment: (commentId: string) => Promise; + onReplyGeneralComment: (threadId: string, body: string) => Promise; + onResolveDiscussion: (discussionId: string, resolved: boolean) => Promise; + onSignIn: () => Promise | void; + onSubmitComment: (comment: PullRequestReviewComment) => Promise; + onSubmitGeneralComment: (body: string) => Promise; + onUpdateComment: (commentId: string, body: string) => Promise; + onUpdateGeneralComment: (commentId: string, body: string) => Promise; +}; +export type ReviewCommenting = SharedWalkthroughCommenting; + +export type MergeRequestReviewAppProps = { + aiReviews?: ReadonlyArray; + commentsError?: string | null; + commentsLoading?: boolean; + commits?: ReadonlyArray; + externalUrl: string; + gitIdentity?: GitIdentity | null; + initialMode?: MergeRequestReviewMode; + onCancelAutoMerge?: () => Promise | void; + onClosePullRequest?: () => Promise | void; + onExitVersionCompare?: () => void; + onGenerateWalkthrough: (options?: { + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + }) => Promise | void; + onHome: () => void; + onLoadCommitDiff?: ( + sha: string, + ) => Promise> | ReadonlyArray; + onLoadVersionCommitDiff?: ( + unitId: string, + ) => Promise> | ReadonlyArray; + onMergePullRequest?: ( + options: PullRequestMergeOptions & { autoMerge: boolean }, + ) => Promise | void; + onModeChange?: (mode: MergeRequestReviewMode) => void; + onOpenVersionCompare?: (options?: { commentId?: string }) => void; + onResolveDiscussion?: (discussionId: string, resolved: boolean) => Promise; + onSubmitComment: (comment: PullRequestReviewComment) => Promise; + onSubmitGeneralComment: (body: string) => Promise; + onSubmitReview: ( + event: PullRequestReviewEvent, + comments: ReadonlyArray, + body?: string, + ) => Promise; + onUpdateComment: (commentId: string, body: string) => Promise; + onUpdateDescription?: (body: string) => Promise | void; + onUpdateGeneralComment: (commentId: string, body: string) => Promise; + onUpdateTitle?: (title: string) => Promise | void; + onUploadDescriptionAsset?: (file: File) => Promise | string; + onVersionCompareRangeChange?: (fromId: string, toId: string) => void; + onVersionWalkthroughStructureChange?: (structure: 'commit-by-commit' | 'whole-diff') => void; + preferences?: Partial< + Pick< + CodiffPreferences, + 'codeFontFamily' | 'codeFontSize' | 'diffStyle' | 'showWhitespace' | 'theme' | 'wordWrap' + > + >; + reviewStrategy?: MergeRequestReviewStrategySummary | null; + selectedCommitSha?: string | null; + settingsBar?: ReactNode; + sourceDescriptionFooterAside?: ReactNode; + state: RepositoryState; + title: string; + versionCommitEvolution?: MergeRequestVersionCommitEvolution | null; + versionCommitEvolutionError?: string | null; + versionCommitEvolutionLoading?: boolean; + versionCompare?: MergeRequestVersionCompareView | null; + versionCompareEnabled?: boolean; + versionCompareError?: string | null; + versionCompareFromId?: string | null; + versionCompareLoading?: boolean; + versionCompareToId?: string | null; + versionHistoryLoading?: boolean; + versions?: ReadonlyArray; + versionWalkthroughStructure?: 'commit-by-commit' | 'whole-diff'; + walkthrough: NarrativeWalkthrough | null; + walkthroughError?: string | null; + walkthroughStatus: MergeRequestWalkthroughStatus; +}; + +const getCodeFontLineHeight = (size: number) => Math.round((size * 20) / 13); + +const normalizeCodeFontSizePreference = (size: number) => + Number.isFinite(size) ? Math.min(32, Math.max(10, Math.round(size))) : CODE_FONT_SIZE_DEFAULT; const getSnapshotReviewComments = ( snapshot: SharedWalkthroughSnapshot, @@ -124,6 +297,893 @@ const getSnapshotReviewComments = ( const noop = () => {}; +const getAuthorDisplayName = (author: ReviewAuthor) => author.name || author.login; +const getGeneralCommentElementId = (commentId: string) => `general-comment:${commentId}`; + +const scrollCommentIntoContainerView = (container: HTMLElement, element: HTMLElement) => { + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const top = + container.scrollTop + + elementRect.top - + containerRect.top - + Math.max(0, (container.clientHeight - elementRect.height) / 2); + + container.scrollTo({ + behavior: 'smooth', + top, + }); +}; +const plainTextCommentPattern = + /|<\/?(?:details|summary)\b[^>]*>|```[\s\S]*?```|`([^`]+)`|\[([^\]]+)\]\([^)]+\)|[*_~>#]+/g; + +const getCommentPreview = (body: string) => { + const preview = body + .replaceAll( + plainTextCommentPattern, + (_, inlineCode: string | undefined, linkText: string | undefined) => + inlineCode ?? linkText ?? ' ', + ) + .replaceAll(/\s+/g, ' ') + .trim(); + return preview || 'Comment'; +}; + +const getAIReviewDecisionLabel = (decision: PullRequestAIReview['decision']) => { + switch (decision) { + case 'approved': + return 'Approved'; + case 'approved-with-comments': + return 'Approved with comments'; + case 'changes-requested': + return 'Changes requested'; + default: + return 'Decision unavailable'; + } +}; + +const formatSubmittedAt = (value: string) => { + const time = Date.parse(value); + return Number.isFinite(time) ? new Date(time).toLocaleString() : value; +}; + +function RelativeSubmittedAtTime({ + submittedAt, + timestamp, +}: { + submittedAt: string; + timestamp: number; +}) { + const relativeTime = useRelativeTime(timestamp); + return ( + + ); +} + +function SubmittedAtTime({ submittedAt }: { submittedAt: string }) { + const timestamp = Date.parse(submittedAt); + if (!Number.isFinite(timestamp)) { + return ( + + ); + } + return ; +} + +export function ReadOnlyGeneralCommentCard({ + className = '', + comment, + focused = false, +}: { + className?: string; + comment: PullRequestGeneralComment; + focused?: boolean; +}) { + const displayName = getAuthorDisplayName(comment.author); + const classes = ['review-comment', 'general-comment-card', focused ? 'focused' : '', className] + .filter(Boolean) + .join(' '); + + return ( +
+ +
+
+ {displayName} + {comment.submittedAt ? : null} +
+ } + value={comment.body} + variant="embedded" + /> +
+
+ ); +} + +function GeneralCommentCard({ + comment, + editDraft, + editError, + editing, + editSubmitting, + focused, + keymap, + onCancelEdit, + onChangeEditDraft, + onDelete, + onSaveEdit, + onStartEdit, +}: { + comment: PullRequestGeneralComment; + editDraft: string; + editError: string | null; + editing: boolean; + editSubmitting: boolean; + focused: boolean; + keymap: CodiffKeymap; + onCancelEdit: () => void; + onChangeEditDraft: (draft: string) => void; + onDelete: (commentId: string) => void; + onSaveEdit: () => void; + onStartEdit: (comment: PullRequestGeneralComment) => void; +}) { + const displayName = getAuthorDisplayName(comment.author); + const canSaveEdit = editing && !editSubmitting && Boolean(editDraft.trim()); + const editorRef = useRef(null); + const handleEditKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (!matchesShortcut(event, keymap, 'submitComment') || !canSaveEdit) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + onSaveEdit(); + }, + [canSaveEdit, keymap, onSaveEdit], + ); + const setEditorRef = useCallback( + (editor: MarkdownEditorHandle | null) => { + editorRef.current = editor; + if (editor && editing) { + requestAnimationFrame(() => { + editor.focus({ defaultSelection: 'rootEnd', preventScroll: true }); + }); + } + }, + [editing], + ); + + useEffect(() => { + if (!editing) { + return; + } + + requestAnimationFrame(() => { + editorRef.current?.focus({ defaultSelection: 'rootEnd', preventScroll: true }); + }); + }, [editing]); + + return ( +
+ +
+
+ {displayName} + {comment.submittedAt ? : null} + {editing ? ( + + + + + ) : ( + <> + {comment.canEdit ? ( + + ) : null} + {comment.canDelete ? ( + + ) : null} + + )} +
+ {editing ? ( + <> + }> + + + {editError ?
{editError}
: null} + + ) : ( + } + value={comment.body} + variant="embedded" + /> + )} +
+
+ ); +} + +function GeneralCommentThreadCard({ + canComment, + editDraft, + editError, + editingCommentId, + editSubmitting, + focusedCommentId, + keymap, + onCancelEdit, + onChangeEditDraft, + onDelete, + onReply, + onResolve, + onSaveEdit, + onStartEdit, + thread, +}: { + canComment: boolean; + editDraft: string; + editError: string | null; + editingCommentId: string | null; + editSubmitting: boolean; + focusedCommentId: string | null; + keymap: CodiffKeymap; + onCancelEdit: () => void; + onChangeEditDraft: (draft: string) => void; + onDelete: (commentId: string) => void; + onReply: (threadId: string, body: string) => Promise; + onResolve: (threadId: string, resolved: boolean) => Promise; + onSaveEdit: () => void; + onStartEdit: (comment: PullRequestGeneralComment) => void; + thread: PullRequestGeneralCommentThread; +}) { + const [replyDraft, setReplyDraft] = useState(''); + const [replyError, setReplyError] = useState(null); + const [replying, setReplying] = useState(false); + const [showReply, setShowReply] = useState(false); + const [resolving, setResolving] = useState(false); + const resolved = thread.isResolved === true; + const submitReply = useCallback(() => { + const body = replyDraft.trim(); + if (!body || replying) { + return; + } + setReplyError(null); + setReplying(true); + void onReply(thread.id, body) + .then(() => { + setReplyDraft(''); + setShowReply(false); + }) + .catch((error: unknown) => { + setReplyError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setReplying(false)); + }, [onReply, replyDraft, replying, thread.id]); + const toggleResolved = useCallback(() => { + if (resolving) { + return; + } + setResolving(true); + void onResolve(thread.id, !resolved) + .catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setResolving(false)); + }, [onResolve, resolved, resolving, thread.id]); + + return ( +
+ {thread.comments.map((comment) => ( + + ))} + {thread.canReply && canComment && !resolved ? ( + showReply ? ( + + ) : ( +
+ +
+ ) + ) : null} + {thread.canResolve ? ( +
+ +
+ ) : null} +
+ ); +} + +type SidebarOverviewComment = { + canResolve: boolean; + comment: PullRequestGeneralComment; + isResolved: boolean; +}; + +function SidebarCommentSection({ + children, + count, + title, +}: { + children: ReactNode; + count: number; + title: string; +}) { + const [expanded, setExpanded] = useState(true); + return ( +
+ + {expanded ?
{children}
: null} +
+ ); +} + +function SidebarOverviewCommentList({ + comments, + focusedCommentId, + onActivateComment, +}: { + comments: ReadonlyArray; + focusedCommentId: string | null; + onActivateComment: (commentId: string) => void; +}) { + return ( +
+ {comments.map(({ canResolve, comment, isResolved }, index) => { + const displayName = getAuthorDisplayName(comment.author); + const selected = comment.id === focusedCommentId; + return ( + + ); + })} +
+ ); +} + +function SidebarCodeCommentList({ + commentAssociations, + comments, + focusedCommentId, + onActivateComment, + onOpenVersionCompareForComment, +}: { + commentAssociations: ReadonlyMap; + comments: ReadonlyArray; + focusedCommentId: string | null; + onActivateComment: (commentId: string) => void; + onOpenVersionCompareForComment?: (commentId: string) => void; +}) { + if (comments.length === 0) { + return
No code comments.
; + } + return ( +
+ {comments.map((comment, index) => { + const association = commentAssociations.get(comment.id); + const versionLabel = comment.versionLabel; + return ( + + ); + })} +
+ ); +} + +function AIReviewDrawer({ + commentsError, + commentsLoading, + onSelectReview, + reviews, + selectedReviewId, +}: { + commentsError: string | null; + commentsLoading: boolean; + onSelectReview: (reviewId: string) => void; + reviews: ReadonlyArray; + selectedReviewId: string | null; +}) { + const [expanded, setExpanded] = useState(false); + const orderedReviews = reviews.toSorted( + (first, second) => Date.parse(second.submittedAt ?? '') - Date.parse(first.submittedAt ?? ''), + ); + const latest = orderedReviews[0]; + + return ( +
+ + {expanded ? ( +
+ {commentsError ?
{commentsError}
: null} + {reviews.length === 0 && !commentsLoading ? ( + No AI review records. + ) : ( +
+ {orderedReviews.map((review, index) => ( + + ))} +
+ )} +
+ ) : null} +
+ ); +} + +function GeneralCommentComposer({ + disabled, + draft, + error, + gitIdentity, + keymap, + onChangeDraft, + onSubmit, + submitting, +}: { + disabled: boolean; + draft: string; + error: string | null; + gitIdentity: GitIdentity | null; + keymap: CodiffKeymap; + onChangeDraft: (draft: string) => void; + onSubmit: () => void; + submitting: boolean; +}) { + const canSubmit = !disabled && !submitting && Boolean(draft.trim()); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (!matchesShortcut(event, keymap, 'submitComment') || !canSubmit) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + onSubmit(); + }, + [canSubmit, keymap, onSubmit], + ); + return ( +
+
+ +
+
+ {gitIdentity?.name || gitIdentity?.email || 'You'} + +
+ }> + + + {error ?
{error}
: null} +
+
+
+ ); +} + +function AIReviewCommentCard({ review }: { review: PullRequestAIReview }) { + const reviewerName = getAuthorDisplayName(review.reviewer); + return ( +
+ +
+
+ {reviewerName} + + {getAIReviewDecisionLabel(review.decision)} + {' · '} + {review.versionLabel ?? review.reviewedHeadSha?.slice(0, 7) ?? 'Unknown version'} + + {review.submittedAt ? : null} +
+ } + value={review.body} + variant="embedded" + /> +
+
+ ); +} + +function MergeRequestCommentsView({ + aiReview, + canComment, + commenting, + commentsError, + commentsLoading, + draft, + editDraft, + editError, + editingCommentId, + editSubmitting, + error, + focusedCommentId, + focusedCommentRequest, + gitIdentity, + keymap, + onCancelEdit, + onChangeDraft, + onChangeEditDraft, + onSaveEdit, + onStartEdit, + onSubmit, + sourceDescription, + submitting, + threads, +}: { + aiReview: PullRequestAIReview | null; + canComment: boolean; + commenting?: SharedWalkthroughCommenting; + commentsError: string | null; + commentsLoading: boolean; + draft: string; + editDraft: string; + editError: string | null; + editingCommentId: string | null; + editSubmitting: boolean; + error: string | null; + focusedCommentId: string | null; + focusedCommentRequest: number; + gitIdentity: GitIdentity | null; + keymap: CodiffKeymap; + onCancelEdit: () => void; + onChangeDraft: (draft: string) => void; + onChangeEditDraft: (draft: string) => void; + onSaveEdit: () => void; + onStartEdit: (comment: PullRequestGeneralComment) => void; + onSubmit: () => void; + sourceDescription?: ReactNode; + submitting: boolean; + threads: ReadonlyArray; +}) { + const commentsRef = useRef(null); + useEffect(() => { + if (focusedCommentId == null) { + return; + } + + const container = commentsRef.current; + const element = document.getElementById(getGeneralCommentElementId(focusedCommentId)); + if (!container || !element) { + return; + } + + scrollCommentIntoContainerView(container, element); + }, [focusedCommentId, focusedCommentRequest]); + + return ( +
+ {commentsLoading ? ( +
+ Loading comments… +
+ ) : commentsError ? ( +
{commentsError}
+ ) : null} + {sourceDescription ? ( +
{sourceDescription}
+ ) : null} + {aiReview ? : null} + {threads.length > 0 ? ( +
+ {threads.map((thread) => ( + { + void commenting?.onDeleteGeneralComment(commentId).catch((error: unknown) => { + window.alert(error instanceof Error ? error.message : String(error)); + }); + }} + onReply={(threadId, body) => + commenting?.onReplyGeneralComment(threadId, body) ?? + Promise.reject(new Error('Replying is unavailable.')) + } + onResolve={(threadId, resolved) => + commenting?.onResolveDiscussion(threadId, resolved) ?? + Promise.reject(new Error('Resolving is unavailable.')) + } + onSaveEdit={onSaveEdit} + onStartEdit={onStartEdit} + thread={thread} + /> + ))} +
+ ) : !aiReview ? ( +
+
+ No comments yet + Add a comment to start the discussion. +
+
+ ) : null} + {canComment ? ( + + ) : commenting ? ( +
+ +
+ ) : null} +
+ ); +} + const disabledCommit = async (): Promise => ({ reason: 'Shared walkthroughs are read-only.', status: 'failed', @@ -134,19 +1194,168 @@ const disabledCommitMessage = async (): Promise status: 'unavailable', }); +function SharedFileTree({ + files, + onActivatePath, + selectedPath, + showWhitespace, +}: { + files: ReadonlyArray; + onActivatePath: (path: string) => void; + selectedPath: string | null; + showWhitespace: boolean; +}) { + const paths = useMemo(() => files.map((file) => file.path), [files]); + const filePathSet = useMemo(() => new Set(paths), [paths]); + const lineCountsByPath = useMemo( + () => new Map(files.map((file) => [file.path, getDiffLineCount(file, showWhitespace)])), + [files, showWhitespace], + ); + const lineCountsByPathRef = useRef(lineCountsByPath); + const renderTreeRowDecoration = useCallback(({ item }) => { + const lineCount = lineCountsByPathRef.current.get(item.path); + return lineCount?.countable + ? { + text: formatTreeLineCount(lineCount), + title: getDiffLineCountTitle(lineCount), + } + : null; + }, []); + const status = useMemo( + () => + files.map((file) => ({ + path: file.path, + status: statusForTree[file.status], + })), + [files], + ); + const { model } = useFileTree({ + flattenEmptyDirectories: true, + gitStatus: status, + initialExpansion: 'open', + initialSelectedPaths: selectedPath ? [selectedPath] : [], + itemHeight: 30, + paths, + renderRowDecoration: renderTreeRowDecoration, + sort: fileTreeSort, + unsafeCSS: ` + :host { + --trees-bg-override: transparent; + --trees-bg-muted-override: var(--hover-wash); + --trees-border-color-override: var(--sidebar-border); + --trees-fg-muted-override: var(--muted); + --trees-fg-override: var(--sidebar-text); + --trees-focus-ring-color-override: var(--tree-selection-focus); + --trees-padding-inline-override: 4px; + --trees-search-bg-override: rgb(127 127 127 / 0.1); + --trees-search-fg-override: var(--sidebar-text); + --trees-selected-bg-override: color-mix(in srgb, var(--tree-selection-bg) 46%, transparent); + --trees-selected-fg-override: var(--sidebar-text); + --trees-selected-focused-border-color-override: color-mix(in srgb, var(--tree-selection-focus) 42%, transparent); + --truncate-marker-background-color: transparent; + color-scheme: var(--codiff-tree-color-scheme, light dark); + color: var(--sidebar-text); + font: 13px/1.35 var(--font-sans); + } + + button[data-type='item'] { + background-color: transparent; + border-radius: 14px; + corner-shape: squircle; + } + + [data-item-section='decoration'] { + color: var(--muted); + font: 600 10px/1 var(--font-mono); + letter-spacing: 0; + } + `, + }); + + useLayoutEffect(() => { + lineCountsByPathRef.current = lineCountsByPath; + if (model.getFileTreeContainer()) { + model.render({}); + } + }, [lineCountsByPath, model]); + + useEffect(() => { + model.resetPaths(paths); + }, [model, paths]); + + useEffect(() => { + model.setGitStatus(status); + }, [model, status]); + + useEffect(() => { + if (!selectedPath) { + return; + } + + for (const path of model.getSelectedPaths()) { + model.getItem(path)?.deselect(); + } + model.getItem(selectedPath)?.select(); + }, [model, selectedPath]); + + const handleTreeClick = useCallback( + (event: MouseEvent) => { + for (const target of event.nativeEvent.composedPath()) { + if (!('getAttribute' in target) || typeof target.getAttribute !== 'function') { + continue; + } + + const path = target.getAttribute('data-item-path'); + if (path && filePathSet.has(path)) { + onActivatePath(path); + return; + } + } + }, + [filePathSet, onActivatePath], + ); + + return ( +
+ +
+ ); +} + export type ReviewSurfaceProps = { - commenting?: ReviewCommenting; + aiReviews?: ReadonlyArray; + commenting?: SharedWalkthroughCommenting; + commentsError?: string | null; + commentsLoading?: boolean; + commits?: ReadonlyArray; externalUrl?: string; gitIdentity?: GitIdentity | null; - initialMode?: ReviewMode; + initialMode?: MergeRequestReviewMode; interactive?: { onCancelAutoMerge?: () => Promise | void; onClosePullRequest?: () => Promise | void; - onGenerateWalkthrough: () => Promise | void; + onExitVersionCompare?: () => void; + onGenerateWalkthrough: (options?: { + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + }) => Promise | void; onHome: () => void; + onLoadCommitDiff?: ( + sha: string, + ) => Promise> | ReadonlyArray; + onLoadVersionCommitDiff?: ( + unitId: string, + ) => Promise> | ReadonlyArray; onMergePullRequest?: ( options: PullRequestMergeOptions & { autoMerge: boolean }, ) => Promise | void; + onOpenVersionCompare?: (options?: { commentId?: string }) => void; onResolveDiscussion?: (discussionId: string, resolved: boolean) => Promise; onSubmitComment: ( comment: PullRequestReviewComment, @@ -162,35 +1371,388 @@ export type ReviewSurfaceProps = { onUpdateGeneralComment: (commentId: string, body: string) => Promise; onUpdateTitle?: (title: string) => Promise | void; onUploadDescriptionAsset?: (file: File) => Promise | string; + onVersionCompareRangeChange?: (fromId: string, toId: string) => void; + reviewStrategy?: MergeRequestReviewStrategySummary | null; walkthroughError?: string | null; - walkthroughStatus: ReviewWalkthroughStatus; + walkthroughStatus: MergeRequestWalkthroughStatus; }; onDeleteShare?: () => Promise | void; - onModeChange?: (mode: ReviewMode) => void; + onModeChange?: (mode: MergeRequestReviewMode) => void; + onVersionWalkthroughStructureChange?: (structure: 'commit-by-commit' | 'whole-diff') => void; providerLabel?: string; repositoryUrl?: string; + selectedCommitSha?: string | null; settingsBar?: ReactNode; signInLabel?: string; snapshot: SharedWalkthroughSnapshot; sourceDescriptionFooterAside?: ReactNode; title?: string; + versionCommitEvolution?: MergeRequestVersionCommitEvolution | null; + versionCommitEvolutionError?: string | null; + versionCommitEvolutionLoading?: boolean; + versionCompare?: MergeRequestVersionCompareView | null; + versionCompareEnabled?: boolean; + versionCompareError?: string | null; + versionCompareFromId?: string | null; + versionCompareLoading?: boolean; + versionCompareToId?: string | null; + versionHistoryLoading?: boolean; + versions?: ReadonlyArray; + versionWalkthroughStructure?: 'commit-by-commit' | 'whole-diff'; +}; + +const shortRelativeTime = (value: string) => { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) { + return ''; + } + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) { + return 'just now'; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h ago`; + } + const days = Math.floor(hours / 24); + if (days < 30) { + return `${days}d ago`; + } + return `${Math.floor(days / 30)}mo ago`; +}; + +const shortVersionAge = (value: string) => { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) { + return '—'; + } + const minutes = Math.max(0, Math.floor((Date.now() - timestamp) / (60 * 1000))); + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h`; + } + const days = Math.floor(hours / 24); + if (days < 14) { + return `${days}d`; + } + if (days < 60) { + return `${Math.floor(days / 7)}w`; + } + if (days < 365) { + return `${Math.floor(days / 30)}mo`; + } + return `${Math.floor(days / 365)}y`; }; +export const formatVersionElapsedDuration = (from: string, to: string) => { + const fromTimestamp = Date.parse(from); + const toTimestamp = Date.parse(to); + if ( + !Number.isFinite(fromTimestamp) || + !Number.isFinite(toTimestamp) || + toTimestamp < fromTimestamp + ) { + return '—'; + } + const minutes = Math.floor((toTimestamp - fromTimestamp) / (60 * 1000)); + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h`; + } + const days = Math.floor(hours / 24); + if (days < 14) { + return `${days}d`; + } + if (days < 60) { + return `${Math.floor(days / 7)}w`; + } + if (days < 365) { + return `${Math.floor(days / 30)}mo`; + } + return `${Math.floor(days / 365)}y`; +}; + +export const resolveVersionWalkthroughFiles = ({ + commitFiles, + structure, + versionFiles, +}: { + commitFiles: ReadonlyArray | null | undefined; + structure: 'commit-by-commit' | 'whole-diff'; + versionFiles: ReadonlyArray; +}): ReadonlyArray => + structure === 'commit-by-commit' ? (commitFiles ?? []) : versionFiles; + +export const combineVersionUnitFiles = ( + unitIds: ReadonlyArray, + filesByUnit: Readonly>>, +): ReadonlyArray => { + const filesByPath = new Map(); + for (const unitId of unitIds) { + for (const file of filesByUnit[unitId] ?? []) { + const existing = filesByPath.get(file.path); + if (!existing) { + filesByPath.set(file.path, { ...file }); + continue; + } + filesByPath.set(file.path, { + ...existing, + fingerprint: `${existing.fingerprint}:${file.fingerprint}`, + oldPath: existing.oldPath ?? file.oldPath, + sections: [...existing.sections, ...file.sections], + status: existing.status === file.status ? existing.status : 'modified', + }); + } + } + return [...filesByPath.values()]; +}; + +const formatSignedBaseInterval = (delta: number | null) => { + if (delta == null) { + return null; + } + const duration = formatVersionElapsedDuration( + new Date(0).toISOString(), + new Date(Math.abs(delta)).toISOString(), + ); + return `new base is ${duration} ${delta >= 0 ? 'newer' : 'older'}`; +}; + +const formatBaseMovementRelationship = ( + relationship: DiffComparisonBaseMovement['relationship'], +) => { + switch (relationship) { + case 'forward': + return 'fast-forward'; + case 'backward': + return 'rewound'; + case 'divergent': + return 'divergent histories'; + default: + return 'relationship unknown'; + } +}; + +const formatBaseMovementCommitCount = ( + movement: Pick, +) => { + const listed = movement.commits?.length ?? 0; + const count = movement.commitsBetween ?? (listed > 0 ? listed : null); + if (count == null) { + return 'Commit count unavailable'; + } + const approximate = movement.truncated || (movement.commitsBetween == null && listed > 0); + return `${approximate ? '≈' : ''}${count} commit${count === 1 ? '' : 's'}`; +}; + +const VersionPicker = ({ + gitLabProjectUrl, + label, + onChange, + otherId, + value, + versions, +}: { + gitLabProjectUrl?: string; + label: string; + onChange: (id: string) => void; + otherId: string | null; + value: string; + versions: ReadonlyArray; +}) => { + const selected = versions.find((version) => version.id === value); + return ( + { + if (nextValue) { + onChange(nextValue); + } + }} + value={value} + > +
+ {label} + + + {() => v{selected?.number ?? selected?.range.head.label.text ?? '—'}} + + + +
+ + + + + {versions.map((version) => { + const disabled = version.id === otherId; + const stat = version.diffStat; + const createdTimestamp = version.createdAt + ? new Date(version.createdAt).toLocaleString() + : 'MR base'; + const previousTimestamp = version.previousCreatedAt + ? new Date(version.previousCreatedAt).toLocaleString() + : null; + const age = version.number === 0 ? null : shortVersionAge(version.createdAt); + const elapsed = + version.previousCreatedAt && version.previousNumber != null + ? formatVersionElapsedDuration(version.previousCreatedAt, version.createdAt) + : null; + const timing = [ + age ? `${age} old` : null, + elapsed ? `${elapsed} since v${version.previousNumber}` : null, + ] + .filter(Boolean) + .join(' · '); + const title = [ + `v${version.number ?? version.range.head.label.text}`, + version.isHead ? 'HEAD' : '', + versionOptionHeadSha(version), + createdTimestamp, + previousTimestamp && version.previousNumber != null + ? `${elapsed} since v${version.previousNumber} (${previousTimestamp})` + : '', + ] + .filter(Boolean) + .join(' · '); + return ( + + + v{version.number ?? version.range.head.label.text} + + {version.isHead ? 'HEAD' : ''} + {version.number === 0 ? ( + base + ) : ( + + )} + + {version.number === 0 ? '' : `+${stat?.additions ?? '…'}`} + + + {version.number === 0 ? '' : `−${stat?.deletions ?? '…'}`} + + + {version.number === 0 + ? '' + : `${stat?.filesChanged ?? '…'} ${stat?.filesChanged === 1 ? 'file' : 'files'}`} + + + {version.number === 0 ? 'MR base' : timing || '—'} + + + ); + })} + + + + +
+ ); +}; + +function VersionComparisonEndpoint({ + gitLabProjectUrl, + version, +}: { + gitLabProjectUrl?: string; + version: MergeRequestVersionOption | null | undefined; +}) { + if (!version) { + return Version; + } + const versionLabel = versionOptionLabel(version); + const headSha = versionOptionHeadSha(version); + return ( + + {versionLabel} + + + ); +} + export function ReviewSurface({ + aiReviews = [], commenting, + commentsError = null, + commentsLoading = false, + commits = [], externalUrl, gitIdentity = null, initialMode, interactive, onDeleteShare, onModeChange, + onVersionWalkthroughStructureChange, providerLabel = 'provider', repositoryUrl, + selectedCommitSha = null, settingsBar, signInLabel = 'Sign in to comment', snapshot, sourceDescriptionFooterAside, title, + versionCommitEvolution = null, + versionCommitEvolutionError = null, + versionCommitEvolutionLoading = false, + versionCompare = null, + versionCompareEnabled = false, + versionCompareError = null, + versionCompareFromId = null, + versionCompareLoading = false, + versionCompareToId = null, + versionHistoryLoading = false, + versions = [], + versionWalkthroughStructure: versionWalkthroughStructureProp, }: ReviewSurfaceProps) { const canComment = commenting?.canComment ?? Boolean(interactive); const deleteShare = useCallback(async () => { @@ -212,6 +1774,144 @@ export function ReviewSurface({ const updateReviewComment = commenting?.onUpdateComment ?? interactive?.onUpdateComment; const updateGeneralDiscussion = commenting?.onUpdateGeneralComment ?? interactive?.onUpdateGeneralComment; + const [selectedPath, setSelectedPath] = useState( + () => snapshot.files[0]?.path ?? null, + ); + const [selectedVersionUnitIds, setSelectedVersionUnitIds] = useState>( + () => new Set(), + ); + const [versionUnitFiles, setVersionUnitFiles] = useState< + Readonly>> + >({}); + const [versionUnitLoadingIds, setVersionUnitLoadingIds] = useState>( + () => new Set(), + ); + const [versionUnitErrors, setVersionUnitErrors] = useState>>({}); + const versionUnitScopeRef = useRef(0); + const selectedVersionUnits = useMemo( + () => + (versionCommitEvolution?.units ?? []).filter((unit) => selectedVersionUnitIds.has(unit.id)), + [selectedVersionUnitIds, versionCommitEvolution], + ); + const selectedVersionUnitFiles = useMemo( + () => + combineVersionUnitFiles( + selectedVersionUnits.map((unit) => unit.id), + versionUnitFiles, + ), + [selectedVersionUnits, versionUnitFiles], + ); + const versionUnitLoading = selectedVersionUnits.some((unit) => + versionUnitLoadingIds.has(unit.id), + ); + const versionUnitError = selectedVersionUnits + .map((unit) => versionUnitErrors[unit.id]) + .find((error): error is string => Boolean(error)); + const [versionWalkthroughStructureState, setVersionWalkthroughStructureState] = useState< + 'commit-by-commit' | 'whole-diff' + >('whole-diff'); + const versionWalkthroughStructure = + versionWalkthroughStructureProp ?? versionWalkthroughStructureState; + const setVersionWalkthroughStructure = (structure: 'commit-by-commit' | 'whole-diff') => { + setVersionWalkthroughStructureState(structure); + onVersionWalkthroughStructureChange?.(structure); + }; + const [versionSectionExpanded, setVersionSectionExpanded] = useState(true); + + useEffect(() => { + setSelectedVersionUnitIds(new Set()); + setVersionUnitFiles({}); + setVersionUnitLoadingIds(new Set()); + setVersionUnitErrors({}); + versionUnitScopeRef.current += 1; + }, [versionCompare?.from.id, versionCompare?.to.id]); + + useEffect(() => { + if (!versionCommitEvolution) { + return; + } + if (versionWalkthroughStructureProp == null) { + setVersionWalkthroughStructureState(versionCommitEvolution.recommendation.suggestedStructure); + onVersionWalkthroughStructureChange?.( + versionCommitEvolution.recommendation.suggestedStructure, + ); + } + }, [ + onVersionWalkthroughStructureChange, + versionCommitEvolution, + versionWalkthroughStructureProp, + ]); + + const loadVersionUnit = useCallback( + (unit: ReviewEvolutionUnit) => { + if (versionUnitFiles[unit.id] || versionUnitLoadingIds.has(unit.id)) { + return; + } + if (!interactive?.onLoadVersionCommitDiff) { + return; + } + const scope = versionUnitScopeRef.current; + setVersionUnitLoadingIds((current) => new Set([...current, unit.id])); + setVersionUnitErrors((current) => { + const { [unit.id]: _error, ...rest } = current; + return rest; + }); + void Promise.resolve(interactive.onLoadVersionCommitDiff(unit.id)) + .then((files) => { + if (versionUnitScopeRef.current !== scope) { + return; + } + setVersionUnitFiles((current) => ({ ...current, [unit.id]: files })); + setSelectedPath((current) => current ?? files[0]?.path ?? null); + }) + .catch((error: unknown) => { + if (versionUnitScopeRef.current !== scope) { + return; + } + setVersionUnitErrors((current) => ({ + ...current, + [unit.id]: error instanceof Error ? error.message : String(error), + })); + }) + .finally(() => { + if (versionUnitScopeRef.current !== scope) { + return; + } + setVersionUnitLoadingIds((current) => { + const next = new Set(current); + next.delete(unit.id); + return next; + }); + }); + }, + [interactive, versionUnitFiles, versionUnitLoadingIds], + ); + const toggleVersionUnit = useCallback( + (unit: ReviewEvolutionUnit) => { + const selected = selectedVersionUnitIds.has(unit.id); + const next = new Set(selectedVersionUnitIds); + if (selected) { + next.delete(unit.id); + } else { + next.add(unit.id); + loadVersionUnit(unit); + } + setSelectedVersionUnitIds(next); + }, + [loadVersionUnit, selectedVersionUnitIds], + ); + const selectOnlyVersionUnit = useCallback( + (unit: ReviewEvolutionUnit) => { + setSelectedVersionUnitIds(new Set([unit.id])); + setSelectedPath(null); + loadVersionUnit(unit); + }, + [loadVersionUnit], + ); + const clearVersionUnits = useCallback(() => { + setSelectedVersionUnitIds(new Set()); + setSelectedPath(versionCompare?.files[0]?.path ?? null); + }, [versionCompare?.files]); const resolveDiscussion = commenting?.onResolveDiscussion ?? interactive?.onResolveDiscussion; const sharedWalkthrough = useMemo( () => ({ @@ -220,17 +1920,60 @@ export function ReviewSurface({ }), [snapshot.walkthrough], ); + const [activeCommitSha, setActiveCommitSha] = useState( + () => selectedCommitSha ?? null, + ); + const [selectedTreeCommitShas, setSelectedTreeCommitShas] = useState>(() => + selectedCommitSha ? new Set([selectedCommitSha]) : new Set(), + ); + const [walkthroughCommitSha, setWalkthroughCommitSha] = useState(null); + const [commitFilesBySha, setCommitFilesBySha] = useState< + Readonly>> + >({}); + const [commitDiffError, setCommitDiffError] = useState(null); + const [commitDiffLoading, setCommitDiffLoading] = useState(false); + const [treeCommitDiffError, setTreeCommitDiffError] = useState(null); + const [treeCommitDiffLoading, setTreeCommitDiffLoading] = useState(false); + const commitLoadRequestRef = useRef(0); + // CBC walkthroughs are authored against unit-scoped commitFiles. Whole-diff + // walkthroughs are authored against the aggregate version comparison. + const walkthroughFiles = useMemo( + () => + versionCompare + ? resolveVersionWalkthroughFiles({ + commitFiles: sharedWalkthrough.commitFiles, + structure: versionWalkthroughStructure, + versionFiles: versionCompare.files, + }) + : walkthroughCommitSha + ? (commitFilesBySha[walkthroughCommitSha] ?? []) + : [...snapshot.files, ...(sharedWalkthrough.commitFiles ?? [])], + [ + commitFilesBySha, + sharedWalkthrough.commitFiles, + snapshot.files, + versionCompare, + walkthroughCommitSha, + ], + ); const navigation = useNarrativeNavigation( sharedWalkthrough, - snapshot.files, - `${snapshot.repository.root}:${getSourceKey(snapshot.repository.source)}`, + walkthroughFiles, + `${snapshot.repository.root}:${getSourceKey(snapshot.repository.source)}:${versionCompare?.from.id ?? ''}:${versionCompare?.to.id ?? ''}`, ); const keymap = useMemo(() => createDefaultConfig().keymap, []); + const [collapsed, setCollapsed] = useState>(() => new Set()); + const [expandedGenerated, setExpandedGenerated] = useState>(() => new Set()); const [fileSearchQuery, setFileSearchQuery] = useState(''); - const [uncontrolledSidebarMode, setUncontrolledSidebarMode] = useState( + const [itemVersionByKey, setItemVersionByKey] = useState>({}); + const [sidebarWidth, setSidebarWidth] = useState(readSharedSidebarWidth); + const [uncontrolledSidebarMode, setUncontrolledSidebarMode] = useState( () => initialMode ?? (interactive ? 'tree' : 'walkthrough'), ); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const isSidebarModeControlled = Boolean(initialMode && onModeChange); + const sidebarMode = + isSidebarModeControlled && initialMode ? initialMode : uncontrolledSidebarMode; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.repeat || !matchesShortcut(event, keymap, 'toggleSidebar')) { @@ -243,29 +1986,40 @@ export function ReviewSurface({ window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [keymap]); - const isSidebarModeControlled = Boolean(initialMode && onModeChange); - const sidebarMode = - isSidebarModeControlled && initialMode ? initialMode : uncontrolledSidebarMode; + useEffect(() => { + if (sidebarMode !== 'walkthrough' || navigation.mode !== 'stop') { + return; + } + const stop = navigation.walkthroughView?.sequence[navigation.index]; + const chapter = navigation.walkthroughView?.chapters.find( + (candidate) => candidate.id === stop?.chapterId, + ); + if (!versionCompare) { + const commitSha = + chapter?.commit?.sha ?? + commits.find((commit) => chapter?.id.startsWith(`${commit.sha}:`))?.sha; + setWalkthroughCommitSha(commitSha ?? null); + } + }, [ + navigation.index, + navigation.mode, + navigation.walkthroughView, + sidebarMode, + commits, + versionCompare, + ]); const [treeScrollTarget, setTreeScrollTarget] = useState(null); - const { - bumpItemVersion, - collapsed, - expandedGenerated, - itemVersionByKey, - selectedPath, - setSelectedPath, - toggleCollapsed, - toggleViewed, - viewed, - } = useReviewFileState({ - initialSelectedPath: snapshot.files[0]?.path ?? null, - }); - const { resizeSidebar, sidebarWidth } = useResizableSidebar({ - collapseThreshold: SIDEBAR_COLLAPSE_THRESHOLD, - onCollapse: () => setSidebarCollapsed(true), - onWidthCommit: writeSharedSidebarWidth, - readWidth: readSharedSidebarWidth, - }); + const [viewed, setViewed] = useState>({}); + const walkthroughGenerationOptionsRef = useRef<{ + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + } | null>(null); const snapshotReviewComments = useMemo(() => getSnapshotReviewComments(snapshot), [snapshot]); const [editedReviewCommentBodies, setEditedReviewCommentBodies] = useState< Readonly> @@ -282,37 +2036,79 @@ export function ReviewSurface({ ); const [localReviewComments, setLocalReviewComments] = useState>(emptyReviewComments); - const reviewComments = useMemo( - () => mergeReviewComments(visibleSnapshotReviewComments, localReviewComments), + const [showResolvedComments, setShowResolvedComments] = useState(() => { + if (typeof localStorage === 'undefined') { + return true; + } + return localStorage.getItem(showResolvedCommentsStorageKey) !== 'false'; + }); + useEffect(() => { + localStorage.setItem(showResolvedCommentsStorageKey, String(showResolvedComments)); + }, [showResolvedComments]); + const allReviewComments = useMemo( + () => [...visibleSnapshotReviewComments, ...localReviewComments], [localReviewComments, visibleSnapshotReviewComments], ); - const { - activeReviewCommentDraftRef, - activeReviewCommentDraftState, - clearCommentFocus, - createComment, - deleteComment: deleteLocalComment, - focusCommentId, - focusCommentRequest, - reviewCommentsRef, - updateActiveReviewCommentDraft, - updateComment, - } = useReviewCommentDrafts({ - canCreateComment: canComment, - comments: reviewComments, - onCommentFileChange: bumpItemVersion, - setComments: setLocalReviewComments, - }); - const generalCommentThreads = snapshot.repository.generalComments ?? emptyGeneralCommentThreads; - const generalComments = useMemo( + const reviewComments = useMemo( + () => + showResolvedComments + ? allReviewComments + : allReviewComments.filter((comment) => comment.isThreadResolved !== true), + [allReviewComments, showResolvedComments], + ); + const sidebarCodeComments = useMemo( + () => + (snapshot.reviewComments ?? emptyExistingReviewComments).filter( + (comment) => showResolvedComments || comment.isThreadResolved !== true, + ), + [showResolvedComments, snapshot.reviewComments], + ); + const reviewCommentsRef = useRef(reviewComments); + const generalCommentThreads = useMemo( + () => + (snapshot.repository.generalComments ?? emptyGeneralCommentThreads).filter( + (thread) => showResolvedComments || thread.isResolved !== true, + ), + [showResolvedComments, snapshot.repository.generalComments], + ); + const overviewComments = useMemo( () => - (snapshot.repository.generalComments ?? emptyGeneralCommentThreads).flatMap( - (thread) => thread.comments, + generalCommentThreads.flatMap((thread) => + thread.comments.map((comment) => ({ + canResolve: thread.canResolve === true, + comment, + isResolved: thread.isResolved === true, + })), ), - [snapshot.repository.generalComments], + [generalCommentThreads], + ); + const generalComments = useMemo( + () => overviewComments.map(({ comment }) => comment), + [overviewComments], ); const generalCommentCount = generalComments.length; - const showCommentsTab = Boolean(commenting || interactive || generalCommentCount > 0); + const orderedAIReviews = useMemo( + () => + aiReviews.toSorted( + (first, second) => + Date.parse(second.submittedAt ?? '') - Date.parse(first.submittedAt ?? ''), + ), + [aiReviews], + ); + const [selectedAIReviewId, setSelectedAIReviewId] = useState( + () => orderedAIReviews[0]?.id ?? null, + ); + useEffect(() => { + setSelectedAIReviewId((current) => + orderedAIReviews.some((review) => review.id === current) + ? current + : (orderedAIReviews[0]?.id ?? null), + ); + }, [orderedAIReviews]); + const selectedAIReview = + orderedAIReviews.find((review) => review.id === selectedAIReviewId) ?? + orderedAIReviews[0] ?? + null; const [generalCommentDraft, setGeneralCommentDraft] = useState(''); const [generalCommentEditDraft, setGeneralCommentEditDraft] = useState(''); const [editingGeneralCommentId, setEditingGeneralCommentId] = useState(null); @@ -322,24 +2118,94 @@ export function ReviewSurface({ const [focusedGeneralCommentId, setFocusedGeneralCommentId] = useState(null); const [generalCommentScrollRequest, setGeneralCommentScrollRequest] = useState(0); const [generalCommentSubmitting, setGeneralCommentSubmitting] = useState(false); + const [focusCommentId, setFocusCommentId] = useState(null); + const [focusCommentRequest, setFocusCommentRequest] = useState(0); + const [activeReviewCommentDraftState, setActiveReviewCommentDraftState] = useState | null>(null); const [pullRequestReviewSubmitting, setPullRequestReviewSubmitting] = useState(null); const [pullRequestCloseSubmitting, setPullRequestCloseSubmitting] = useState(false); const [pullRequestMergeSubmitting, setPullRequestMergeSubmitting] = useState(false); const [walkthroughRequestPending, setWalkthroughRequestPending] = useState(false); const walkthroughRequestPendingRef = useRef(false); + const walkthroughRequestForceRef = useRef(false); + const lastAutoVersionWalkthroughKeyRef = useRef(null); + const [walkthroughRequestForce, setWalkthroughRequestForce] = useState(false); const [walkthroughRequestId, setWalkthroughRequestId] = useState(0); + const activeReviewCommentDraftRef = useRef | null>(null); const interactiveRef = useRef(interactive); + useEffect(() => { + reviewCommentsRef.current = reviewComments; + }, [reviewComments]); + + const activeCommit = useMemo( + () => commits.find((commit) => commit.sha === activeCommitSha) ?? null, + [activeCommitSha, commits], + ); + const activeCommitFiles = activeCommitSha ? (commitFilesBySha[activeCommitSha] ?? null) : null; + const selectedTreeCommitFiles = useMemo( + () => combineVersionUnitFiles([...selectedTreeCommitShas], commitFilesBySha), + [commitFilesBySha, selectedTreeCommitShas], + ); + const versionCompareActive = + versionCompareEnabled === true || + versionCompare != null || + versionCompareLoading || + Boolean(versionCompareError); + const versionCompareChangedPaths = useMemo(() => { + const files = + sidebarMode === 'walkthrough' && versionCompare + ? walkthroughFiles + : selectedVersionUnitIds.size > 0 + ? selectedVersionUnitFiles + : (versionCompare?.files ?? []); + return new Set(files.map((file) => file.path)); + }, [ + selectedVersionUnitFiles, + selectedVersionUnitIds.size, + sidebarMode, + versionCompare, + walkthroughFiles, + ]); + const versionCompareWalkthroughOptions = useMemo( + () => + versionCompare + ? { + versionCompare: { + fromId: versionCompare.from.id, + toId: versionCompare.to.id, + walkthroughStructure: versionWalkthroughStructure, + }, + } + : undefined, + [versionCompare, versionWalkthroughStructure], + ); + const reviewFiles = versionCompare + ? selectedVersionUnitIds.size > 0 + ? selectedVersionUnitFiles + : versionCompare.files + : sidebarMode === 'tree' && selectedTreeCommitShas.size > 0 + ? selectedTreeCommitFiles + : snapshot.files; const visibleFiles = useMemo( () => - sortFiles(snapshot.files).filter( + sortFiles(reviewFiles).filter( (file) => - fuzzyMatches(file.path, fileSearchQuery) && + fuzzyMatches(file.path, sidebarMode === 'tree' ? fileSearchQuery : '') && fileHasVisibleDiff(file, snapshot.preferences.showWhitespace), ), - [fileSearchQuery, snapshot.files, snapshot.preferences.showWhitespace], + [fileSearchQuery, reviewFiles, sidebarMode, snapshot.preferences.showWhitespace], ); + const commentAssociationById = useMemo(() => { + const map = new Map(); + for (const association of versionCompare?.analysis.commentAssociations ?? []) { + map.set(association.commentId, association); + } + return map; + }, [versionCompare]); const totalLineCount = useMemo( () => getTotalDiffLineCount( @@ -347,7 +2213,101 @@ export function ReviewSurface({ ), [snapshot.preferences.showWhitespace, visibleFiles], ); - const showTotalLineCount = sidebarMode !== 'comments' && totalLineCount.countable; + const showTotalLineCount = + sidebarMode !== 'comments' && sidebarMode !== 'commits' && totalLineCount.countable; + + useEffect(() => { + if (selectedCommitSha) { + setActiveCommitSha(selectedCommitSha); + setSelectedTreeCommitShas(new Set([selectedCommitSha])); + } + }, [selectedCommitSha]); + + const commitDiffTargetSha = + sidebarMode === 'walkthrough' || sidebarMode === 'commits' + ? (walkthroughCommitSha ?? activeCommitSha) + : null; + useEffect(() => { + if ( + (sidebarMode !== 'commits' && sidebarMode !== 'walkthrough') || + !commitDiffTargetSha || + !interactive?.onLoadCommitDiff + ) { + return; + } + if (commitFilesBySha[commitDiffTargetSha]) { + return; + } + const requestId = commitLoadRequestRef.current + 1; + commitLoadRequestRef.current = requestId; + setCommitDiffLoading(true); + setCommitDiffError(null); + void Promise.resolve(interactive.onLoadCommitDiff(commitDiffTargetSha)) + .then((files) => { + if (commitLoadRequestRef.current !== requestId) { + return; + } + setCommitFilesBySha((current) => ({ ...current, [commitDiffTargetSha]: files })); + setSelectedPath(files[0]?.path ?? null); + }) + .catch((error: unknown) => { + if (commitLoadRequestRef.current !== requestId) { + return; + } + setCommitDiffError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + if (commitLoadRequestRef.current === requestId) { + setCommitDiffLoading(false); + } + }); + }, [commitDiffTargetSha, commitFilesBySha, interactive, sidebarMode]); + + useEffect(() => { + if ( + sidebarMode !== 'tree' || + versionCompareActive || + selectedTreeCommitShas.size === 0 || + !interactive?.onLoadCommitDiff + ) { + return; + } + const missingShas = [...selectedTreeCommitShas].filter((sha) => !commitFilesBySha[sha]); + if (missingShas.length === 0) { + return; + } + let cancelled = false; + setTreeCommitDiffLoading(true); + setTreeCommitDiffError(null); + void Promise.all( + missingShas.map((sha) => + Promise.resolve(interactive.onLoadCommitDiff!(sha)).then((files) => ({ files, sha })), + ), + ) + .then((results) => { + if (cancelled) { + return; + } + setCommitFilesBySha((current) => ({ + ...current, + ...Object.fromEntries(results.map(({ files, sha }) => [sha, files])), + })); + setSelectedPath((current) => current ?? results[0]?.files[0]?.path ?? null); + }) + .catch((error: unknown) => { + if (!cancelled) { + setTreeCommitDiffError(error instanceof Error ? error.message : String(error)); + } + }) + .finally(() => { + if (!cancelled) { + setTreeCommitDiffLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [commitFilesBySha, interactive, selectedTreeCommitShas, sidebarMode, versionCompareActive]); const visibleSelectedPath = selectedPath && visibleFiles.some((file) => file.path === selectedPath) ? selectedPath @@ -368,20 +2328,103 @@ export function ReviewSurface({ ); }, [snapshot.files]); - useDocumentAppearance({ - codeFontFamily: snapshot.preferences.codeFontFamily, - codeFontSize: snapshot.preferences.codeFontSize, - theme: snapshot.preferences.theme, - }); + useEffect(() => { + const root = document.documentElement; + if (snapshot.preferences.theme === 'system') { + root.removeAttribute('data-theme'); + } else { + root.setAttribute('data-theme', snapshot.preferences.theme); + } + }, [snapshot.preferences.theme]); + + useEffect(() => { + const root = document.documentElement; + const codeFontFamily = snapshot.preferences.codeFontFamily.trim(); + const codeFontSize = normalizeCodeFontSizePreference(snapshot.preferences.codeFontSize); + + if (codeFontFamily) { + root.style.setProperty('--font-diff-mono', `${JSON.stringify(codeFontFamily)}, monospace`); + } + + root.style.setProperty('--font-diff-size', `${codeFontSize}px`); + root.style.setProperty('--font-diff-line-height', `${getCodeFontLineHeight(codeFontSize)}px`); + }, [snapshot.preferences.codeFontFamily, snapshot.preferences.codeFontSize]); + + const bumpItemVersion = useCallback((key: string) => { + setItemVersionByKey((current) => ({ + ...current, + [key]: (current[key] ?? 0) + 1, + })); + }, []); + const changeSidebarMode = useCallback( + (mode: MergeRequestReviewMode) => { + setUncontrolledSidebarMode(mode); + onModeChange?.(mode); + }, + [onModeChange], + ); + + const selectCommit = useCallback( + (sha: string) => { + if (versionCompareActive) { + interactive?.onExitVersionCompare?.(); + } + setActiveCommitSha(sha); + setSelectedTreeCommitShas(new Set([sha])); + setCommitDiffError(null); + setTreeCommitDiffError(null); + changeSidebarMode('tree'); + }, + [changeSidebarMode, versionCompareActive, interactive], + ); + + const createComment = useCallback( + (comment: Omit) => { + if (!canComment) { + return; + } + + const emptyExistingComment = reviewCommentsRef.current.find( + (candidate) => + candidate.body.length === 0 && getCommentKey(candidate) === getCommentKey(comment), + ); + if (emptyExistingComment) { + setFocusCommentId(emptyExistingComment.id); + setFocusCommentRequest((current) => current + 1); + return; + } + + const emptyDraft = reviewCommentsRef.current.find( + (candidate) => !candidate.isReadOnly && candidate.body.length === 0, + ); + if (emptyDraft) { + const id = crypto.randomUUID(); + setFocusCommentId(id); + setFocusCommentRequest((current) => current + 1); + setLocalReviewComments((current) => + current.map((candidate) => + candidate.id === emptyDraft.id + ? { + ...comment, + body: '', + id, + } + : candidate, + ), + ); + bumpItemVersion(emptyDraft.filePath); + bumpItemVersion(comment.filePath); + return; + } - const changeSidebarMode = useCallback( - (mode: ReviewMode) => { - setUncontrolledSidebarMode(mode); - onModeChange?.(mode); + const id = crypto.randomUUID(); + setFocusCommentId(id); + setFocusCommentRequest((current) => current + 1); + setLocalReviewComments((current) => [...current, { ...comment, body: '', id }]); + bumpItemVersion(comment.filePath); }, - [onModeChange], + [bumpItemVersion, canComment], ); - const activateGeneralComment = useCallback( (commentId: string) => { changeSidebarMode('comments'); @@ -390,6 +2433,14 @@ export function ReviewSurface({ }, [changeSidebarMode], ); + const activateReviewComment = useCallback( + (commentId: string) => { + changeSidebarMode('tree'); + setFocusCommentId(commentId); + setFocusCommentRequest((current) => current + 1); + }, + [changeSidebarMode], + ); const navigateGeneralComment = useCallback( (direction: 1 | -1) => { if (generalComments.length === 0) { @@ -413,6 +2464,31 @@ export function ReviewSurface({ }, [activateGeneralComment, focusedGeneralCommentId, generalComments], ); + const updateComment = useCallback((commentId: string, body: string) => { + const applyCommentBody = (comments: ReadonlyArray) => + comments.map((comment) => + comment.id === commentId && !comment.isReadOnly ? { ...comment, body } : comment, + ); + + reviewCommentsRef.current = applyCommentBody(reviewCommentsRef.current); + setLocalReviewComments(applyCommentBody); + }, []); + const updateActiveReviewCommentDraft = useCallback( + (comment: Pick | null) => { + activeReviewCommentDraftRef.current = comment; + setActiveReviewCommentDraftState((current) => { + if (comment == null) { + return current == null ? current : null; + } + + const body = comment.body.trim().length > 0 ? 'pending' : ''; + return current?.id === comment.id && current.body === body + ? current + : { body, id: comment.id }; + }); + }, + [], + ); const updateExistingReviewComment = useCallback( async (commentId: string, body: string) => { if (!updateReviewComment) { @@ -425,24 +2501,27 @@ export function ReviewSurface({ bumpItemVersion(comment.filePath); } }, - [bumpItemVersion, reviewCommentsRef, updateReviewComment], + [bumpItemVersion, updateReviewComment], ); const deleteComment = useCallback( (commentId: string) => { const comment = reviewCommentsRef.current.find((candidate) => candidate.id === commentId); + updateActiveReviewCommentDraft(null); if (comment?.isReadOnly && comment.canDelete && commenting?.onDeleteComment) { - updateActiveReviewCommentDraft(null); - setLocalReviewComments((current) => - current.filter((candidate) => candidate.id !== commentId), - ); void commenting.onDeleteComment(commentId).catch((error: unknown) => { window.alert(error instanceof Error ? error.message : String(error)); }); return; } - deleteLocalComment(commentId); + setFocusCommentId((current) => (current === commentId ? null : current)); + setLocalReviewComments((current) => + current.filter((candidate) => candidate.id !== commentId), + ); + if (comment) { + bumpItemVersion(comment.filePath); + } }, - [commenting, deleteLocalComment, reviewCommentsRef, updateActiveReviewCommentDraft], + [bumpItemVersion, commenting, updateActiveReviewCommentDraft], ); const submitComment = useCallback( (commentId: string) => { @@ -465,18 +2544,11 @@ export function ReviewSurface({ : candidate, ), ); - void submitReviewComment( - toPullRequestReviewComment(comment, { includeSectionId: commenting != null }), - ) - .then((submittedComment) => { - clearCommentFocus(commentId); + void submitReviewComment(toPullRequestReviewComment(comment)) + .then(() => { + setFocusCommentId((current) => (current === commentId ? null : current)); setLocalReviewComments((current) => - current.flatMap((candidate) => { - if (candidate.id !== commentId) { - return [candidate]; - } - return [toSubmittedReviewComment(submittedComment, candidate)]; - }), + current.filter((candidate) => candidate.id !== commentId), ); bumpItemVersion(comment.filePath); }) @@ -497,14 +2569,7 @@ export function ReviewSurface({ bumpItemVersion(comment.filePath); }); }, - [ - bumpItemVersion, - clearCommentFocus, - commenting, - reviewCommentsRef, - submitReviewComment, - updateActiveReviewCommentDraft, - ], + [bumpItemVersion, submitReviewComment, updateActiveReviewCommentDraft], ); const submitReview = useCallback( (event: PullRequestReviewEvent, body?: string) => { @@ -527,9 +2592,7 @@ export function ReviewSurface({ } const pendingIds = new Set(pendingComments.map((comment) => comment.id)); setPullRequestReviewSubmitting(event); - const formattedComments = pendingComments.map((comment) => - toPullRequestReviewComment(comment), - ); + const formattedComments = pendingComments.map(toPullRequestReviewComment); const submission = body ? interactive.onSubmitReview(event, formattedComments, body) : interactive.onSubmitReview(event, formattedComments); @@ -550,8 +2613,6 @@ export function ReviewSurface({ interactive, pullRequestReviewSubmitting, snapshot.repository.source, - activeReviewCommentDraftRef, - reviewCommentsRef, updateActiveReviewCommentDraft, ], ); @@ -611,38 +2672,94 @@ export function ReviewSurface({ } let cancelled = false; - void Promise.resolve(interactiveRef.current?.onGenerateWalkthrough()) + const options = walkthroughGenerationOptionsRef.current; + walkthroughGenerationOptionsRef.current = null; + void Promise.resolve(interactiveRef.current?.onGenerateWalkthrough(options ?? undefined)) .catch(() => {}) .finally(() => { if (cancelled) { return; } walkthroughRequestPendingRef.current = false; + walkthroughRequestForceRef.current = false; setWalkthroughRequestPending(false); + setWalkthroughRequestForce(false); }); return () => { cancelled = true; }; }, [walkthroughRequestId, walkthroughRequestPending]); - const startWalkthroughGeneration = useCallback(() => { - if ( - !interactive || - interactive.walkthroughStatus === 'generating' || - walkthroughRequestPendingRef.current - ) { - return; - } + const startWalkthroughGeneration = useCallback( + (options?: { + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + }) => { + if ( + !interactive || + interactive.walkthroughStatus === 'generating' || + walkthroughRequestPendingRef.current + ) { + return; + } - walkthroughRequestPendingRef.current = true; - setWalkthroughRequestPending(true); - setWalkthroughRequestId((current) => current + 1); - }, [interactive]); + walkthroughGenerationOptionsRef.current = options ?? null; + walkthroughRequestPendingRef.current = true; + walkthroughRequestForceRef.current = options?.force === true; + setWalkthroughRequestForce(options?.force === true); + setWalkthroughRequestPending(true); + setWalkthroughRequestId((current) => current + 1); + }, + [interactive], + ); useEffect(() => { - if (sidebarMode === 'walkthrough' && interactive?.walkthroughStatus === 'idle') { + if (sidebarMode !== 'walkthrough') { + return; + } + if (versionCompareActive) { + if (!versionCompareWalkthroughOptions) { + return; + } + const key = [ + versionCompareWalkthroughOptions.versionCompare.fromId, + versionCompareWalkthroughOptions.versionCompare.toId, + versionCompareWalkthroughOptions.versionCompare.walkthroughStructure, + ].join(':'); + // Auto-resolve whenever the version range/structure changes, including cache hits. + // Skip while a request is already in flight or actively generating. + if ( + lastAutoVersionWalkthroughKeyRef.current === key && + (interactive?.walkthroughStatus === 'ready' || + interactive?.walkthroughStatus === 'generating' || + walkthroughRequestPending) + ) { + return; + } + if (interactive?.walkthroughStatus === 'generating' || walkthroughRequestPending) { + return; + } + lastAutoVersionWalkthroughKeyRef.current = key; + startWalkthroughGeneration(versionCompareWalkthroughOptions); + return; + } + if (interactive?.walkthroughStatus === 'idle') { startWalkthroughGeneration(); } - }, [interactive?.walkthroughStatus, sidebarMode, startWalkthroughGeneration]); + }, [ + interactive?.walkthroughStatus, + sidebarMode, + startWalkthroughGeneration, + versionCompareActive, + versionCompareWalkthroughOptions, + walkthroughRequestPending, + ]); + useEffect(() => { if (sidebarMode !== 'comments' || generalComments.length === 0) { return; @@ -729,30 +2846,126 @@ export function ReviewSurface({ generalCommentEditSubmitting, updateGeneralDiscussion, ]); - const activateTreePath = useCallback( - (path: string) => { - setSelectedPath(path); - setTreeScrollTarget((current) => ({ - behavior: 'smooth', - path, - request: (current?.request ?? 0) + 1, - })); + const resizeSidebar = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + + const handle = event.currentTarget; + const shell = handle.parentElement; + if (!shell) { + return; + } + + const shellLeft = shell.getBoundingClientRect().left; + handle.setPointerCapture(event.pointerId); + handle.classList.add('dragging'); + document.body.style.cursor = 'col-resize'; + + const cleanup = () => { + handle.releasePointerCapture(event.pointerId); + handle.removeEventListener('pointermove', handleMove); + handle.removeEventListener('pointerup', handleEnd); + handle.removeEventListener('pointercancel', handleEnd); + handle.classList.remove('dragging'); + document.body.style.cursor = ''; + }; + + const handleMove = (moveEvent: PointerEvent) => { + setSidebarWidth(clampSidebarWidth(moveEvent.clientX - shellLeft)); + }; + + const handleEnd = () => { + cleanup(); + setSidebarWidth((width) => { + writeSharedSidebarWidth(width); + return width; + }); + }; + + handle.addEventListener('pointermove', handleMove); + handle.addEventListener('pointerup', handleEnd); + handle.addEventListener('pointercancel', handleEnd); + }, []); + + const toggleCollapsed = useCallback( + (file: ChangedFile, isCollapsed: boolean, reviewKey: string) => { + setCollapsed((current) => { + const next = new Set(current); + if (isCollapsed) { + next.delete(reviewKey); + } else { + next.add(reviewKey); + } + return next; + }); + setExpandedGenerated((current) => { + const next = new Set(current); + if (isCollapsed && isGeneratedWalkthroughFile(file)) { + next.add(reviewKey); + } else { + next.delete(reviewKey); + } + return next; + }); + bumpItemVersion(reviewKey); + }, + [bumpItemVersion], + ); + const toggleViewed = useCallback( + (_file: ChangedFile, isViewed: boolean, reviewIdentity: ReviewIdentity) => { + setViewed((current) => updateReviewIdentityViewed(current, reviewIdentity, isViewed)); + setCollapsed((current) => updateReviewIdentityCollapsed(current, reviewIdentity, isViewed)); + if (!isViewed) { + setExpandedGenerated((current) => { + const next = new Set(current); + next.delete(reviewIdentity.key); + return next; + }); + } + bumpItemVersion(reviewIdentity.key); }, - [setSelectedPath], + [bumpItemVersion], ); + const activateTreePath = useCallback((path: string) => { + setSelectedPath(path); + setTreeScrollTarget((current) => ({ + behavior: 'smooth', + path, + request: (current?.request ?? 0) + 1, + })); + }, []); const updateSelectedPathFromScroll = useCallback( (viewer: CodeViewInstance) => { - const nextPath = getSelectedPathFromScroll( - viewer, - visibleFiles, - snapshot.preferences.showWhitespace, - ); + if (visibleFiles.length === 0) { + return; + } + + const activationTop = viewer.getScrollTop() + DEFAULT_PADDING; + let nextPath = visibleFiles[0]?.path ?? null; + let nextDistance = Number.NEGATIVE_INFINITY; + + for (const file of visibleFiles) { + const section = getFirstVisibleSection(file, snapshot.preferences.showWhitespace); + const itemTop = section ? viewer.getTopForItem(getItemId(section)) : undefined; + if (itemTop == null) { + continue; + } + + const distance = itemTop - activationTop; + if (distance <= 0 && distance > nextDistance) { + nextDistance = distance; + nextPath = file.path; + } + } if (nextPath) { setSelectedPath((current) => (current === nextPath ? current : nextPath)); } }, - [setSelectedPath, snapshot.preferences.showWhitespace, visibleFiles], + [snapshot.preferences.showWhitespace, visibleFiles], ); const diffLineHeight = getCodeFontLineHeight( @@ -775,6 +2988,7 @@ export function ReviewSurface({ gitIdentity, hunkNavigation: null, initialMarkdownPreviewSectionIds, + isPullRequest: snapshot.repository.source.type === 'pull-request', isReadOnly: !canComment, itemVersionByKey, keymap, @@ -796,7 +3010,6 @@ export function ReviewSurface({ searchQuery: '', showWhitespace: snapshot.preferences.showWhitespace, source: snapshot.repository.source, - supportsReviewCommentActions: submitReviewComment != null, theme: snapshot.preferences.theme, viewed, wordWrap: snapshot.preferences.wordWrap, @@ -860,16 +3073,23 @@ export function ReviewSurface({ onActiveBlockChange: (blockId: string) => void, ) => { return ( - +
+ +
); }; @@ -885,6 +3105,36 @@ export function ReviewSurface({ ? (externalUrl ?? snapshot.repository.source.url) : null; const repositoryLinkUrl = repositoryUrl ?? sourceExternalUrl; + const gitLabProjectUrl = externalUrl?.split('/-/merge_requests/')[0]; + const versionCompareFrom = + versions.find((version) => version.id === versionCompareFromId) ?? versionCompare?.from; + const versionCompareTo = + versions.find((version) => version.id === versionCompareToId) ?? versionCompare?.to; + const diffScopeSummary = versionCompareActive ? ( + versionCompareFrom && versionCompareTo ? ( + <> + + {' → '} + + + ) : ( + Choose versions + ) + ) : null; + const selectVersionComparisonScope = () => { + if (versionCompareActive) { + return; + } + setActiveCommitSha(null); + setCommitDiffError(null); + setSelectedTreeCommitShas(new Set()); + setTreeCommitDiffError(null); + setVersionSectionExpanded(true); + interactive?.onOpenVersionCompare?.(); + }; const walkthroughStatus = walkthroughRequestPending && interactive?.walkthroughStatus !== 'ready' ? 'generating' @@ -901,18 +3151,73 @@ export function ReviewSurface({ previousWalkthroughStatusRef.current = walkthroughStatus; }, [walkthroughStatus]); const walkthroughReady = !interactive || walkthroughStatus === 'ready'; + const versionWalkthroughFilesMissing = + walkthroughReady && + Boolean(versionCompare) && + versionWalkthroughStructure === 'commit-by-commit' && + walkthroughFiles.length === 0; const walkthroughFailed = walkthroughStatus === 'failed'; + const walkthroughIdle = walkthroughStatus === 'idle'; + const walkthroughStructurePhrase = versionCompareActive + ? versionWalkthroughStructure === 'commit-by-commit' + ? 'commit-by-commit version' + : 'whole-diff version' + : interactive?.reviewStrategy?.mode === 'commit-by-commit' + ? 'commit-by-commit' + : 'whole-diff'; + const computingVersionChanges = Boolean(versionCompareLoading); + const walkthroughBusy = walkthroughRequestPending || walkthroughStatus === 'generating'; + // Non-forced in-flight requests are either a cache lookup or the brief handoff before + // the server flips to generating. Forced rebuilds / active generation use generate copy. + const loadingWalkthroughLookup = + walkthroughBusy && !walkthroughRequestForce && walkthroughStatus !== 'generating'; + const generatingWalkthrough = + walkthroughBusy && (walkthroughRequestForce || walkthroughStatus === 'generating'); const walkthroughStatusTitle = walkthroughFailed ? 'Walkthrough unavailable' - : 'Generating walkthrough…'; + : computingVersionChanges + ? 'Computing version changes…' + : walkthroughIdle && !walkthroughBusy + ? 'Walkthrough not generated' + : loadingWalkthroughLookup + ? `Loading ${walkthroughStructurePhrase} walkthrough…` + : `Generating ${walkthroughStructurePhrase} walkthrough…`; const walkthroughStatusDescription = walkthroughFailed ? (interactive?.walkthroughError ?? 'Fix the generation issue, then try again.') - : null; + : computingVersionChanges + ? 'Comparing the selected versions and preparing the review surface.' + : walkthroughIdle && !walkthroughBusy + ? versionCompareActive + ? 'Choose a walkthrough structure, then generate it for this version comparison.' + : 'Generate a walkthrough to review these changes.' + : (interactive?.walkthroughError ?? + (loadingWalkthroughLookup + ? `Looking up a cached ${walkthroughStructurePhrase} walkthrough for this scope.` + : `Building the ${walkthroughStructurePhrase} walkthrough.`)); + const walkthroughProgressLabel = computingVersionChanges + ? 'Computing version changes…' + : loadingWalkthroughLookup + ? `Loading ${walkthroughStructurePhrase} walkthrough…` + : generatingWalkthrough + ? `Generating ${walkthroughStructurePhrase} walkthrough…` + : undefined; const shellTheme = snapshot.preferences.theme === 'system' ? undefined : snapshot.preferences.theme; - const requestWalkthrough = () => { - startWalkthroughGeneration(); + const requestWalkthrough = (options?: { + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + }) => { + startWalkthroughGeneration(options); }; + const alternateReviewStructure = + interactive?.reviewStrategy?.mode === 'commit-by-commit' ? 'whole-diff' : 'commit-by-commit'; + const showCommentsTab = Boolean(commenting || interactive || generalCommentCount > 0); const reviewModes = [ { icon: , @@ -1056,43 +3361,649 @@ export function ReviewSurface({ value={fileSearchQuery} /> + {interactive && sidebarMode !== 'comments' ? ( +
+
+ {versionCompareActive ? ( + + ) : ( + + Diff scope + + )} +
+ + +
+
+ {versionSectionExpanded && versionCompareActive ? ( +
+ {versionHistoryLoading ? ( +
+ + Loading version history… +
+ ) : versions.length >= 2 && interactive?.onVersionCompareRangeChange ? ( +
+ + interactive.onVersionCompareRangeChange?.( + fromId, + versionCompareToId ?? versions[0]?.id ?? '', + ) + } + otherId={versionCompareToId} + value={versionCompareFromId ?? versions[1]?.id ?? versions[0]?.id ?? ''} + versions={versions} + /> + + interactive.onVersionCompareRangeChange?.( + versionCompareFromId ?? versions[1]?.id ?? '', + toId, + ) + } + otherId={versionCompareFromId} + value={versionCompareToId ?? versions[0]?.id ?? ''} + versions={versions} + /> +
+ ) : null} + {versionCompare?.analysis.baseMovement?.changed && + versionCompare.analysis.baseMovement ? ( +
+
+ Base changed{' '} + {' '} + →{' '} + +
+ {versionCompare.analysis.baseMovement.diffStat ? ( +
+ {formatBaseMovementCommitCount(versionCompare.analysis.baseMovement)} + {' · '} + {versionCompare.analysis.baseMovement.diffStat.filesChanged} files + {' · '} + + +{versionCompare.analysis.baseMovement.diffStat.additions} + {' '} + + −{versionCompare.analysis.baseMovement.diffStat.deletions} + + {' · '} + {formatBaseMovementRelationship( + versionCompare.analysis.baseMovement.relationship, + )} + {formatSignedBaseInterval( + versionCompare.analysis.baseMovement.commitTimestampDeltaMs, + ) + ? ` · ${formatSignedBaseInterval(versionCompare.analysis.baseMovement.commitTimestampDeltaMs)}` + : ''} +
+ ) : ( +
Details unavailable
+ )} + {(versionCompare.analysis.baseMovement.commits?.length ?? 0) > 0 ? ( +
+ + {versionCompare.analysis.baseMovement.relationship === 'backward' + ? 'Show previous base commits' + : 'Show new base commits'}{' '} + ({formatBaseMovementCommitCount(versionCompare.analysis.baseMovement)}) + +
+ {(versionCompare.analysis.baseMovement.commits ?? []).map((commit) => { + const isBackward = + versionCompare.analysis.baseMovement?.relationship === 'backward'; + return ( +
+ + {isBackward ? '−' : '+'} + + + {commit.subject} +
+ ); + })} +
+ {versionCompare.analysis.baseMovement.truncated ? ( + Commit list may be truncated by GitLab compare limits. + ) : null} +
+ ) : null} + {versionCompare.analysis.baseMovement.warning ? ( + {versionCompare.analysis.baseMovement.warning} + ) : null} + + Base branch changes are context only and are excluded from this review. + +
+ ) : null} + {versionCompareLoading ? ( +
+ + Computing changes between versions… +
+ ) : null} + {versionCompareError ? ( +
{versionCompareError}
+ ) : null} + {versionCommitEvolutionLoading ? ( +
+ + Analyzing commit evolution… +
+ ) : null} + {versionCommitEvolutionError ? ( +
+ Commit evolution could not be analyzed + {versionCommitEvolutionError} +
+ ) : null} + {versionCommitEvolution ? ( +
+ Commit stack +
+ {versionCommitEvolution.warnings?.map((warning) => ( +
+ {warning} +
+ ))} + {versionCommitEvolution.units.map((unit) => { + const commit = evolutionUnitCommit(unit); + if (!commit) { + return null; + } + const isUnchanged = + unit.kind === 'retained' || + unit.kind === 'rewritten-same-patch' || + unit.kind === 'absorbed-into-base'; + const symbol = + unit.kind === 'introduced' + ? '+' + : unit.kind === 'removed' + ? '−' + : unit.kind === 'revised' + ? '~' + : unit.kind === 'absorbed-into-base' + ? '↳' + : unit.kind === 'ambiguous' + ? '?' + : '·'; + const kindClass = + unit.kind === 'absorbed-into-base' + ? 'absorbed-into-base' + : isUnchanged + ? 'unchanged' + : unit.kind; + const walkthroughChapter = navigation.walkthroughView?.chapters.find( + (chapter) => chapter.commit?.sha === unit.id, + ); + const walkthroughStopIndex = walkthroughChapter?.stops[0]?.index; + const canNavigate = walkthroughStopIndex != null; + const rebaseDrivers = evolutionUnitRebaseDrivers(unit); + const driverTitle = + rebaseDrivers.length > 0 + ? `Likely rebase drivers: ${rebaseDrivers + .map((driver) => `${driver.shortSha} ${driver.subject}`) + .join('; ')}` + : null; + return ( +
+ + {rebaseDrivers.length > 0 ? ( +
+ + Rebase pressure + + {rebaseDrivers.map((driver) => ( +
+ + ~ + + + {driver.subject} +
+ ))} +
+ ) : null} +
+ ); + })} +
+
+ ) : null} + {selectedVersionUnits.length > 0 ? ( +
+ + Viewing {selectedVersionUnits.length}{' '} + {selectedVersionUnits.length === 1 ? 'commit change' : 'commit changes'} + + +
+ ) : null} + {versionCompare && !versionCompare.analysis.summary.empty ? ( +
+ Walkthrough structure + + + + {versionCommitEvolution?.recommendation.rationale ?? + 'Whole diff is available while commit evolution loads.'} + + +
+ ) : versionCompare && versionCommitEvolution?.summary.reviewable ? ( +
+ The final patch is equivalent, but the commit stack changed. +
+ ) : versionCompare ? ( +
+ These versions have no intentional MR changes. +
+ ) : null} +
+ ) : null} +
+ ) : null} {sidebarMode === 'tree' ? ( - + <> + {versionCompareActive ? ( + {}} + onToggleVersionUnit={toggleVersionUnit} + selectedVersionUnitIds={selectedVersionUnitIds} + versionCommitEvolution={versionCommitEvolution} + versionUnitError={versionUnitError} + versionUnitLoading={versionUnitLoading} + /> + ) : null} + {!versionCompareActive && commits.length > 0 ? ( + { + setSelectedTreeCommitShas(new Set()); + setTreeCommitDiffError(null); + setSelectedPath(snapshot.files[0]?.path ?? null); + }} + onSelectCommit={(sha) => { + if (!sha) { + return; + } + setSelectedTreeCommitShas((current) => { + const next = new Set(current); + if (next.has(sha)) { + next.delete(sha); + } else { + next.add(sha); + } + return next; + }); + setTreeCommitDiffError(null); + }} + selectedCommitShas={selectedTreeCommitShas} + /> + ) : null} + {treeCommitDiffLoading && selectedTreeCommitShas.size > 0 ? ( +
Loading selected commit changes…
+ ) : treeCommitDiffError ? ( +
{treeCommitDiffError}
+ ) : ( + + )} + + ) : sidebarMode === 'commits' ? ( +
+ {interactive?.reviewStrategy ? ( +
+ {interactive.reviewStrategy.mode === 'commit-by-commit' + ? 'Structured by commits' + : `Whole MR (${interactive.reviewStrategy.reason})`} +
+ ) : null} + {commits.map((commit) => { + const selected = commit.sha === activeCommitSha; + return ( + + ); + })} +
) : sidebarMode === 'comments' ? ( - + <> +
+ +
+ + + {overviewComments.length > 0 ? ( + + ) : !commentsLoading && aiReviews.length === 0 ? ( +
No overview comments.
+ ) : null} +
+ + + interactive?.onOpenVersionCompare?.({ commentId }) + } + /> + + ) : walkthroughReady ? ( - + <> + {!versionCompareActive && interactive?.reviewStrategy ? ( +
+ + {interactive.reviewStrategy.mode === 'commit-by-commit' + ? 'Structured by commits' + : `Whole MR (${interactive.reviewStrategy.reason})`} + + +
+ ) : null} + 0 ? versionCompareChangedPaths : undefined + } + commitEvolutionKinds={ + versionCommitEvolution + ? new Map(versionCommitEvolution.units.map((unit) => [unit.id, unit.kind])) + : undefined + } + files={visibleFiles} + navigation={navigation} + onRegenerateWalkthrough={ + versionCompareActive && versionCompareWalkthroughOptions + ? () => + requestWalkthrough({ + ...versionCompareWalkthroughOptions, + force: true, + }) + : interactive?.reviewStrategy + ? () => + requestWalkthrough({ + force: true, + reviewStructure: + interactive.reviewStrategy?.mode === 'commit-by-commit' + ? 'commit-by-commit' + : 'whole-diff', + }) + : () => requestWalkthrough({ force: true }) + } + showWhitespace={snapshot.preferences.showWhitespace} + walkthrough={sharedWalkthrough} + /> + ) : (
- {walkthroughFailed ? ( - {walkthroughStatusTitle} + {walkthroughFailed || + (walkthroughIdle && !walkthroughBusy && !computingVersionChanges) ? ( + <> + {walkthroughStatusTitle} + {walkthroughStatusDescription ? ( + {walkthroughStatusDescription} + ) : null} + ) : ( )} - {walkthroughStatusDescription ? {walkthroughStatusDescription} : null}
)} @@ -1110,8 +4021,11 @@ export function ReviewSurface({
{sidebarMode === 'comments' ? ( + ) : sidebarMode === 'commits' ? ( + commitDiffLoading && !activeCommitFiles ? ( +
Loading commit diff…
+ ) : commitDiffError ? ( +
+
+ Unable to load commit +

{commitDiffError}

+
+
+ ) : visibleFiles.length === 0 ? ( +
+
+ {activeCommit ? activeCommit.subject : 'Select a commit'} + + {activeCommit + ? 'This commit has no loadable text diffs.' + : 'Choose a commit from the sidebar.'} + +
+ +
+
+
+ ) : ( + <> +
+ {activeCommit ? activeCommit.subject : 'Commit'} + + {activeCommit ? ( + + ) : null} + + +
+ + Comments are on the full MR diff. + + + } + walkthroughNotes={emptyWalkthroughNotes} + /> + + ) ) : sidebarMode === 'tree' ? ( - visibleFiles.length === 0 ? ( + computingVersionChanges ? ( +
+ +
+ ) : versionCompareActive && + selectedVersionUnitIds.size > 0 && + versionUnitLoading && + selectedVersionUnitFiles.length === 0 ? ( +
Loading selected commit changes…
+ ) : versionCompareActive && + selectedVersionUnitIds.size > 0 && + versionUnitError && + selectedVersionUnitFiles.length === 0 ? ( +
+
+ Unable to load selected commit changes +

{versionUnitError}

+
+
+ ) : treeCommitDiffLoading && + selectedTreeCommitShas.size > 0 && + selectedTreeCommitFiles.length === 0 ? ( +
Loading selected commit changes…
+ ) : treeCommitDiffError ? ( +
+
+ Unable to load selected commit changes +

{treeCommitDiffError}

+
+
+ ) : visibleFiles.length === 0 ? (
- No matching files - {fileSearchQuery} + {fileSearchQuery ? 'No matching files' : 'No files in this diff'} + {fileSearchQuery ? {fileSearchQuery} : null}
) : ( @@ -1147,6 +4171,11 @@ export function ReviewSurface({ allowViewedToggle files={visibleFiles} forceExpandedPaths={emptyPaths} + key={ + versionCompareActive + ? `version:${selectedVersionUnits.map((unit) => unit.id).join(',') || 'all'}` + : `commits:${[...selectedTreeCommitShas].join(',') || 'all'}` + } onSelectPathFromScroll={updateSelectedPathFromScroll} scrollTarget={treeScrollTarget} selectedPath={visibleSelectedPath} @@ -1156,40 +4185,323 @@ export function ReviewSurface({ walkthroughNotes={emptyWalkthroughNotes} /> ) + ) : versionWalkthroughFilesMissing ? ( +
+
+ Commit walkthrough code is unavailable +

Regenerate this walkthrough to restore its authored commit-unit diff.

+ {versionCompareWalkthroughOptions ? ( + + ) : null} +
+
+ ) : walkthroughReady && + walkthroughCommitSha && + !commitFilesBySha[walkthroughCommitSha] && + commitDiffLoading ? ( +
Loading commit walkthrough code…
+ ) : walkthroughReady && + walkthroughCommitSha && + !commitFilesBySha[walkthroughCommitSha] && + commitDiffError ? ( +
+
+ Unable to load commit walkthrough code +

{commitDiffError}

+
+
) : walkthroughReady ? ( - + <> + {versionCompare && + versionWalkthroughStructure === 'whole-diff' && + versionCompare.analysis.baseMovement?.changed && + versionCompare.analysis.baseMovement ? ( +
+ Target base changed between versions + {versionCommitEvolution?.summary.absorbedIntoBase ? ( + + {versionCommitEvolution.summary.absorbedIntoBase}{' '} + {versionCommitEvolution.summary.absorbedIntoBase === 1 + ? 'earlier MR commit is' + : 'earlier MR commits are'}{' '} + now supplied by the target base. + + ) : ( + + Base movement is context only; this walkthrough covers the remaining MR changes. + + )} + {(versionCompare.analysis.baseMovement.commits?.length ?? 0) > 0 ? ( +
+ Show base commits +
+ {(versionCompare.analysis.baseMovement.commits ?? []).map((commit) => ( +
+ + {commit.subject} +
+ ))} +
+
+ ) : null} +
+ ) : null} + 0 ? versionCompareChangedPaths : undefined + } + files={walkthroughFiles} + navigation={navigation} + onActiveReviewTargetChange={noop} + onCommit={disabledCommit} + onUpdateCommitMessage={disabledCommitMessage} + renderDiffBlocks={renderWalkthroughDiffBlocks} + showWhitespace={snapshot.preferences.showWhitespace} + walkthrough={sharedWalkthrough} + /> + + ) : computingVersionChanges || walkthroughBusy ? ( +
+ +
) : walkthroughFailed ? (
{walkthroughStatusTitle}

{walkthroughStatusDescription}

- + {!versionCompareActive && interactive?.reviewStrategy ? ( + + ) : null}
- ) : ( -
- + ) : walkthroughIdle ? ( +
+
+ {walkthroughStatusTitle} +

{walkthroughStatusDescription}

+
+ +
+
- )} + ) : null}
); } + +export function SharedWalkthroughApp({ + commenting, + gitIdentity, + settingsBar, + snapshot, +}: { + commenting?: SharedWalkthroughCommenting; + gitIdentity?: GitIdentity | null; + settingsBar?: ReactNode; + snapshot: SharedWalkthroughSnapshot; +}) { + return ( + + ); +} + +export function MergeRequestReviewApp({ + aiReviews, + commentsError, + commentsLoading, + commits, + externalUrl, + gitIdentity, + initialMode, + onCancelAutoMerge, + onClosePullRequest, + onExitVersionCompare, + onGenerateWalkthrough, + onHome, + onLoadCommitDiff, + onLoadVersionCommitDiff, + onMergePullRequest, + onModeChange, + onOpenVersionCompare, + onResolveDiscussion, + onSubmitComment, + onSubmitGeneralComment, + onSubmitReview, + onUpdateComment, + onUpdateDescription, + onUpdateGeneralComment, + onUpdateTitle, + onUploadDescriptionAsset, + onVersionCompareRangeChange, + onVersionWalkthroughStructureChange, + preferences, + reviewStrategy, + selectedCommitSha, + settingsBar, + sourceDescriptionFooterAside, + state, + title, + versionCommitEvolution, + versionCommitEvolutionError, + versionCommitEvolutionLoading, + versionCompare, + versionCompareEnabled, + versionCompareError, + versionCompareFromId, + versionCompareLoading, + versionCompareToId, + versionHistoryLoading, + versions, + versionWalkthroughStructure, + walkthrough, + walkthroughError, + walkthroughStatus, +}: MergeRequestReviewAppProps) { + const placeholderWalkthrough = useMemo( + () => ({ + agent: 'codex', + chapters: [], + focus: 'Generate a walkthrough to review this merge request in narrative order.', + generatedAt: new Date(state.generatedAt).toISOString(), + kind: 'narrative', + repo: { + branch: state.branch, + root: state.root, + }, + source: state.source, + support: [], + title, + version: 4, + }), + [state.branch, state.generatedAt, state.root, state.source, title], + ); + const resolvedPreferences = useMemo( + () => ({ + ...defaultSharedPreferences, + ...preferences, + }), + [preferences], + ); + const snapshot = useMemo( + () => ({ + branch: state.branch, + codeQualityFindings: state.codeQualityFindings, + codiffVersion: 'web', + exportedAt: new Date(state.generatedAt).toISOString(), + files: state.files, + kind: 'codiff-walkthrough-share', + preferences: resolvedPreferences, + repository: { + generalComments: state.generalComments, + root: state.root, + source: state.source, + title, + }, + reviewComments: state.reviewComments, + version: 1, + walkthrough: walkthrough ?? placeholderWalkthrough, + }), + [placeholderWalkthrough, resolvedPreferences, state, title, walkthrough], + ); + + return ( + + ); +} diff --git a/core/__tests__/App.test.tsx b/core/__tests__/App.test.tsx index 7099236a..bd7e72d0 100644 --- a/core/__tests__/App.test.tsx +++ b/core/__tests__/App.test.tsx @@ -8,6 +8,7 @@ import { getSectionForFileDiff, getTotalDiffLineCount, getVisibleDiffSections, + formatTreeLineCount, fileHasVisibleDiff, loadSectionContents, shouldLoadDiffSectionContents, @@ -397,6 +398,16 @@ test('diff line counts include additions and deletions across sections', () => { }); }); +test('tree line counts keep additions and deletions separate', () => { + expect( + formatTreeLineCount({ + additions: 1234, + countable: true, + deletions: 56, + }), + ).toBe('+1.2k -56'); +}); + test('diff line counts omit binary summary rows', () => { const file = { fingerprint: 'binary-counts', diff --git a/core/app/components/CommitRefTooltip.tsx b/core/app/components/CommitRefTooltip.tsx new file mode 100644 index 00000000..f4c2b98c --- /dev/null +++ b/core/app/components/CommitRefTooltip.tsx @@ -0,0 +1,83 @@ +import { Tooltip } from '@base-ui/react/tooltip'; +import { ExternalLink } from 'lucide-react'; + +export type CommitRefSummary = { + additions?: number; + authoredAt?: number | string | null; + authorName?: string; + deletions?: number; + sha: string; + shortSha: string; + subject?: string; + webUrl?: string; +}; + +export function CommitRefTooltip({ + className, + commit, + focusable = true, + linkTrigger = true, +}: { + className?: string; + commit: CommitRefSummary; + focusable?: boolean; + linkTrigger?: boolean; +}) { + const triggerClassName = ['git-commit-ref-trigger', className].filter(Boolean).join(' '); + const trigger = + linkTrigger && commit.webUrl ? ( + + ) : ( + + ); + const authoredAt = commit.authoredAt ? new Date(commit.authoredAt) : null; + const authoredLabel = + authoredAt && Number.isFinite(authoredAt.getTime()) ? authoredAt.toLocaleString() : null; + + return ( + + + {commit.shortSha} + + + + + {commit.subject || 'Commit'} + {commit.sha} + {commit.authorName || authoredLabel ? ( + + {[commit.authorName, authoredLabel].filter(Boolean).join(' · ')} + + ) : null} + {commit.additions != null || commit.deletions != null ? ( + + {commit.additions != null ? ( + +{commit.additions} + ) : null} + {commit.deletions != null ? ( + −{commit.deletions} + ) : null} + + ) : null} + {commit.webUrl ? ( + + View commit in GitLab + + + ) : null} + + + + + ); +} diff --git a/core/app/components/CommitScopePanel.tsx b/core/app/components/CommitScopePanel.tsx new file mode 100644 index 00000000..b0244f8d --- /dev/null +++ b/core/app/components/CommitScopePanel.tsx @@ -0,0 +1,157 @@ +import { ChevronDown } from 'lucide-react'; +import { useState } from 'react'; +import { evolutionUnitCommit } from '../../lib/review-history.ts'; +import type { + MergeRequestCommitListEntry, + MergeRequestVersionCommitEvolution, + MergeRequestVersionCommitEvolutionUnit, +} from '../../SharedWalkthroughApp.tsx'; +import { CommitRefTooltip } from './CommitRefTooltip.tsx'; + +export type CommitScopePanelProps = { + commits: ReadonlyArray; + mode: 'merge-request' | 'version-compare'; + onClear: () => void; + onSelectCommit: (sha: string | null) => void; + onToggleVersionUnit?: (unit: MergeRequestVersionCommitEvolutionUnit) => void; + selectedCommitShas?: ReadonlySet; + selectedVersionUnitIds?: ReadonlySet; + versionCommitEvolution?: MergeRequestVersionCommitEvolution | null; + versionUnitError?: string | null; + versionUnitLoading?: boolean; +}; + +export function CommitScopePanel({ + commits, + mode, + onClear, + onSelectCommit, + onToggleVersionUnit, + selectedCommitShas = new Set(), + selectedVersionUnitIds = new Set(), + versionCommitEvolution, + versionUnitError, + versionUnitLoading = false, +}: CommitScopePanelProps) { + const [expanded, setExpanded] = useState(true); + const selectedUnits = + versionCommitEvolution?.units.filter((unit) => selectedVersionUnitIds.has(unit.id)) ?? []; + const summary = + mode === 'merge-request' + ? selectedCommitShas.size > 0 + ? `${selectedCommitShas.size} selected commit${selectedCommitShas.size === 1 ? '' : 's'}` + : 'All merge request changes' + : selectedUnits.length > 0 + ? `${selectedUnits.length} selected commit${selectedUnits.length === 1 ? '' : 's'}` + : 'All version changes'; + + return ( +
+
+ + +
+ {expanded ? ( +
+ {mode === 'merge-request' ? ( + <> + {commits.map((commit) => ( + + ))} + + ) : ( + versionCommitEvolution?.units + .filter((unit) => unit.reviewable) + .map((unit) => { + const commit = evolutionUnitCommit(unit); + if (!commit || !onToggleVersionUnit) { + return null; + } + return ( + + ); + }) + )} + {versionUnitLoading ? ( +
Loading selected commit changes…
+ ) : null} + {versionUnitError ? ( +
{versionUnitError}
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/core/app/components/ReviewCodeView.tsx b/core/app/components/ReviewCodeView.tsx index 0bc31b41..79d16244 100644 --- a/core/app/components/ReviewCodeView.tsx +++ b/core/app/components/ReviewCodeView.tsx @@ -113,7 +113,6 @@ import type { ReviewSource, } from '../../types.ts'; import { Avatar } from './Avatar.tsx'; -import { Button } from './Button.tsx'; import { RepositoryMarkdownEditor, type MarkdownDocumentEditorHandle, @@ -143,16 +142,6 @@ const preloadMarkdownEditor = () => { }; const MarkdownEditor = lazy(loadMarkdownEditor); -const isEditableWorkingTreeSection = ( - sourceType: ReviewSource['type'], - file: ChangedFile, - section: DiffSection, -) => - (sourceType === 'working-tree' || sourceType === 'branch-working-tree') && - file.status !== 'deleted' && - file.sections.at(-1)?.id === section.id && - (section.kind === 'staged' || section.kind === 'unstaged'); - function CopyFilePathButton({ path }: { path: string }) { const [copied, markCopied] = useCopiedState(1600); @@ -285,7 +274,7 @@ function CodeViewHeader({
{statusLabel[file.status]}
{canCreateFileComment ? ( - + ) : null} {canRenderMarkdown ? ( - + ) : null} {canLoadSection && !readOnly ? ( + ) : null} {!readOnly || allowViewedToggle ? ( + + + {mode === 'history' ? ( ) : mode === 'walkthrough' && narrativeWalkthrough ? ( ) : ( - +
+ +
)} {showFooter ? (
@@ -193,14 +408,14 @@ export function Sidebar({ ) : null} {showCommitButton ? ( - + ) : null}
) : null} @@ -208,6 +423,96 @@ export function Sidebar({ ); } +const escapeCSSString = (value: string) => + value + .replaceAll('\\', String.raw`\\`) + .replaceAll('\n', String.raw`\a `) + .replaceAll('\r', String.raw`\d `) + .replaceAll('\f', String.raw`\c `) + .replaceAll('"', String.raw`\"`); + +const getReloadDeltaGitStatusCSS = (paths: ReadonlySet) => + [...paths] + .map( + (path) => ` + [data-item-path="${escapeCSSString(path)}"][data-item-git-status] > [data-item-section='git'] { + color: var(--sidebar-ref); + } + `, + ) + .join('\n'); + +const getViewedRowCSS = (files: ReadonlyArray, viewed: Record) => + getViewedRowCSSFromSelectors( + files + .filter((file) => viewed[file.path] === file.fingerprint) + .map((file) => `[data-item-path="${escapeCSSString(file.path)}"]`), + ); + +const getViewedRowCSSFromSelectors = (selectors: ReadonlyArray) => { + if (selectors.length === 0) { + return ''; + } + + const rowContent = selectors + .flatMap((selector) => [ + `${selector} > [data-item-section='icon']`, + `${selector} > [data-item-section='icon'] > :where(:not([data-icon-name='file-tree-icon-chevron']))`, + `${selector} > [data-item-section='content']`, + `${selector} > [data-item-section='decoration']`, + `${selector} > [data-item-section='git']`, + ]) + .join(',\n'); + + return ` + ${rowContent} { + color: var(--muted); + } + `; +}; + +const reloadDeltaGitStatusStyleAttribute = 'data-codiff-reload-delta-git-status'; +const viewedRowStyleAttribute = 'data-codiff-viewed-rows'; + +const useTreeShadowStyle = ( + treeHostRef: RefObject, + styleAttribute: string, + css: string, +) => { + useEffect(() => { + // Tree unsafeCSS is constructor-time; keep dynamic row styling in a shadow style tag. + if (syncTreeShadowStyle(treeHostRef.current, styleAttribute, css)) { + return; + } + + const frame = window.requestAnimationFrame(() => { + syncTreeShadowStyle(treeHostRef.current, styleAttribute, css); + }); + return () => window.cancelAnimationFrame(frame); + }, [css, styleAttribute, treeHostRef]); +}; + +const syncTreeShadowStyle = (treeHost: HTMLElement | null, styleAttribute: string, css: string) => { + const shadowRoot = treeHost?.querySelector('file-tree-container')?.shadowRoot; + if (!shadowRoot) { + return false; + } + + const existingStyle = shadowRoot.querySelector(`style[${styleAttribute}]`); + if (css.length === 0) { + existingStyle?.remove(); + return true; + } + + const style = existingStyle ?? document.createElement('style'); + style.setAttribute(styleAttribute, ''); + style.textContent = css; + if (!existingStyle) { + shadowRoot.append(style); + } + return true; +}; + const shortDate = (timestamp: number) => { const seconds = Math.floor((Date.now() - timestamp) / 1000); if (seconds < 60) { @@ -254,6 +559,20 @@ function HistorySidebar({ searchQuery: string; }) { const currentSourceKey = getSourceKey(currentSource); + const getGitLabCommitUrl = (ref: string) => { + if ( + currentSource.type !== 'pull-request' || + currentSource.provider !== 'gitlab' || + !currentSource.projectPath + ) { + return undefined; + } + try { + return `${new URL(currentSource.url).origin}/${currentSource.projectPath}/-/commit/${encodeURIComponent(ref)}`; + } catch { + return undefined; + } + }; const normalizedQuery = searchQuery.trim().toLowerCase(); const listRef = useRef(null); const rows = useMemo(() => { @@ -328,28 +647,6 @@ function HistorySidebar({ subject: 'Uncommitted changes', } : null, - !normalizedQuery - ? { - author: null, - committedAt: null, - gravatarUrl: undefined, - key: getSourceKey({ - baseRef: branchSource.baseRef, - headRef: branchSource.headRef, - ref: branchSource.ref, - type: 'branch-working-tree', - }), - kind: 'entry' as const, - ref: 'branch+', - source: { - baseRef: branchSource.baseRef, - headRef: branchSource.headRef, - ref: branchSource.ref, - type: 'branch-working-tree', - } satisfies ReviewSource, - subject: `All changes vs ${branchSource.ref}`, - } - : null, !normalizedQuery ? { author: null, @@ -359,7 +656,7 @@ function HistorySidebar({ kind: 'entry' as const, ref: 'branch', source: branchSource satisfies ReviewSource, - subject: `Committed only vs ${branchSource.ref}`, + subject: `Branch diff vs ${branchSource.ref}`, } : null, localRows.length > 0 @@ -424,10 +721,21 @@ function HistorySidebar({ > {row.source.type === 'commit' - ? getShortRef(row.source.ref) - : row.source.type === 'pull-request' || - row.source.type === 'branch-diff' || - row.source.type === 'branch-working-tree' + ? ( + + ) + : row.source.type === 'pull-request' || row.source.type === 'branch-diff' ? row.ref : 'local'} diff --git a/core/app/components/walkthrough/NarrativeSidebar.tsx b/core/app/components/walkthrough/NarrativeSidebar.tsx index 8630857f..83b50af0 100644 --- a/core/app/components/walkthrough/NarrativeSidebar.tsx +++ b/core/app/components/walkthrough/NarrativeSidebar.tsx @@ -1,7 +1,4 @@ -import { CheckIcon as Check } from '@phosphor-icons/react/Check'; -import { GitBranchIcon as GitBranch } from '@phosphor-icons/react/GitBranch'; -import { PathIcon as Path } from '@phosphor-icons/react/Path'; -import { ShareNetworkIcon as ShareNetwork } from '@phosphor-icons/react/ShareNetwork'; +import { getAgentLabel } from '../../../lib/app-constants.ts'; import { renderInlineMarkdown } from '../../../lib/markdown.tsx'; import { buildCommitModel, @@ -13,9 +10,13 @@ import { type WalkthroughStopView, } from '../../../lib/narrative-walkthrough.ts'; import type { ChangedFile, NarrativeWalkthrough } from '../../../types.ts'; +import { CommitRefTooltip } from '../CommitRefTooltip.tsx'; +import { ArrowsClockwise, Check, GitBranch, Path, ShareNetwork } from './icons.tsx'; import { ChapterIcon } from './parts.tsx'; import type { NarrativeNavigation } from './useNarrativeNavigation.ts'; +const agentLabel = (agentId: NarrativeWalkthrough['agent']) => getAgentLabel(agentId); + function TocFileRows({ files, }: { @@ -88,11 +89,13 @@ function TocStop({ } function SupportingFilesStop({ + changedPaths, files, navigation, showWhitespace, walkthroughView, }: { + changedPaths?: ReadonlySet; files: ReadonlyArray; navigation: NarrativeNavigation; showWhitespace: boolean; @@ -102,7 +105,7 @@ function SupportingFilesStop({ files, walkthroughView, showWhitespace, - ); + ).filter((file) => !changedPaths?.has(file.path)); if (walkthroughView.support.length === 0 && uncoveredFiles.length === 0) { return null; } @@ -147,18 +150,111 @@ function SupportingFilesStop({ ); } +function ChangedFilesStop({ + changedPaths, + files, + navigation, + showWhitespace, + walkthroughView, +}: { + changedPaths?: ReadonlySet; + files: ReadonlyArray; + navigation: NarrativeNavigation; + showWhitespace: boolean; + walkthroughView: WalkthroughView; +}) { + const changedFiles = getUncoveredWalkthroughFileLineItems( + files, + walkthroughView, + showWhitespace, + ).filter((file) => changedPaths?.has(file.path)); + if (changedFiles.length === 0) { + return null; + } + const fileRows = formatWalkthroughFileLineRows(changedFiles); + return ( +
+
+ + + + Changed +
+
+ +
+
+ ); +} + +const commitEvolutionKindClass = (kind: string | undefined) => { + switch (kind) { + case 'added': + case 'introduced': + return 'evolution-added'; + case 'removed': + return 'evolution-removed'; + case 'likely-revised': + case 'revised': + return 'evolution-revised'; + case 'absorbed-into-base': + return 'evolution-absorbed'; + case 'ambiguous': + return 'evolution-ambiguous'; + default: + return 'evolution-unchanged'; + } +}; + +const commitEvolutionSymbol = (kind: string | undefined) => { + switch (kind) { + case 'added': + case 'introduced': + return '+'; + case 'removed': + return '−'; + case 'likely-revised': + case 'revised': + return '~'; + case 'absorbed-into-base': + return '↳'; + case 'ambiguous': + return '?'; + default: + return '·'; + } +}; + export function NarrativeSidebar({ allowCommit = true, + changedPaths, + commitEvolutionKinds, files, navigation, + onRegenerateWalkthrough, onShareWalkthrough, shareWalkthroughDisabled = false, showWhitespace, walkthrough, }: { allowCommit?: boolean; + changedPaths?: ReadonlySet; + commitEvolutionKinds?: ReadonlyMap; files: ReadonlyArray; navigation: NarrativeNavigation; + onRegenerateWalkthrough?: () => void; onShareWalkthrough?: () => void; shareWalkthroughDisabled?: boolean; showWhitespace: boolean; @@ -183,13 +279,75 @@ export function NarrativeSidebar({ return (
- Review focus +
+ Review focus + {onRegenerateWalkthrough ? ( + + ) : null} +

{renderInlineMarkdown(walkthrough.focus)}

{walkthroughView.chapters.map((chapter) => ( -
+
+ {chapter.commit ? ( +
`${driver.shortSha} ${driver.subject}`, + ), + chapter.commit.revisionCause, + ] + .filter(Boolean) + .join(' · ')} + > + {commitEvolutionKinds ? ( + + {commitEvolutionSymbol(commitEvolutionKinds.get(chapter.commit.sha))} + + ) : null} + + {chapter.commit.subject} + {(chapter.commit.rebaseDrivers?.length ?? 0) > 0 ? ( + `${driver.shortSha} ${driver.subject}`) + .join('; ')}`} + className="wt-commit-rebase-badge" + tabIndex={0} + > + Revised due to rebase + + ) : chapter.commit.revisionCause ? ( + + Revised due to rebase + + ) : null} +
+ ) : null}
@@ -210,6 +368,14 @@ export function NarrativeSidebar({
))} + ); } + +export { agentLabel }; diff --git a/core/app/components/walkthrough/NarrativeWalkthroughView.tsx b/core/app/components/walkthrough/NarrativeWalkthroughView.tsx index ac072a37..8568d8a7 100644 --- a/core/app/components/walkthrough/NarrativeWalkthroughView.tsx +++ b/core/app/components/walkthrough/NarrativeWalkthroughView.tsx @@ -1,11 +1,3 @@ -import { ArrowLeftIcon as ArrowLeft } from '@phosphor-icons/react/ArrowLeft'; -import { ArrowRightIcon as ArrowRight } from '@phosphor-icons/react/ArrowRight'; -import { CaretLeftIcon as CaretLeft } from '@phosphor-icons/react/CaretLeft'; -import { CaretRightIcon as CaretRight } from '@phosphor-icons/react/CaretRight'; -import { CheckIcon as Check } from '@phosphor-icons/react/Check'; -import { GitBranchIcon as GitBranch } from '@phosphor-icons/react/GitBranch'; -import { PathIcon as Path } from '@phosphor-icons/react/Path'; -import { ShareNetworkIcon as ShareNetwork } from '@phosphor-icons/react/ShareNetwork'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { ReviewIdentity } from '../../../lib/app-types.ts'; import type { ReviewScrollBehavior } from '../../../lib/app-types.ts'; @@ -24,6 +16,7 @@ import { type WalkthroughStopView, } from '../../../lib/narrative-walkthrough.ts'; import type { ChangedFile, NarrativeWalkthrough, WalkthroughHunkGroup } from '../../../types.ts'; +import { CommitRefTooltip } from '../CommitRefTooltip.tsx'; import type { ReviewDiffBlock } from '../ReviewCodeView.tsx'; import { CommitView, @@ -31,6 +24,17 @@ import { type CommitMessageHandler, type CommitOutputSubscriber, } from './CommitView.tsx'; +import { + ArrowLeft, + ArrowRight, + ArrowsClockwise, + CaretLeft, + CaretRight, + Check, + GitBranch, + Path, + ShareNetwork, +} from './icons.tsx'; import { ChapterIcon, ImportancePill, Narration } from './parts.tsx'; import type { NarrativeNavigation } from './useNarrativeNavigation.ts'; @@ -153,6 +157,29 @@ function StopHeader({ current, stop }: { current: boolean; stop: WalkthroughStop
+ {stop.commentReferences?.length ? ( +
+ {stop.commentReferences.map((comment) => ( +
+
+ {comment.url ? ( + Review comment by {comment.authorName} + ) : ( + Review comment by {comment.authorName} + )} + + {comment.status === 'resolved-by-change' + ? 'Addressed in this version' + : comment.status === 'outdated' + ? 'Code changed since comment' + : 'Related comment'} + +
+
{comment.body}
+
+ ))} +
+ ) : null}
); } @@ -169,6 +196,90 @@ function SupportHeader({ current }: { current: boolean }) { ); } +function CommitBoundary({ + commit, +}: { + commit: NonNullable; +}) { + const rebaseDrivers = commit.rebaseDrivers ?? []; + return ( +
+
+ Commit + + {commit.subject} +
+ {rebaseDrivers.length > 0 ? ( +
+ Revised due to rebase +
+ {rebaseDrivers.map((driver) => ( +
+ + {driver.subject} + {driver.overlappingPaths?.length ? ( + Overlaps {driver.overlappingPaths.join(', ')} + ) : null} +
+ ))} +
+
+ ) : commit.revisionCause ? ( +
+ Revised due to rebase + {commit.revisionCause} +
+ ) : null} +
+ ); +} + +function ChangedHeader({ + current, + onRegenerate, + regenerateDisabled = false, +}: { + current: boolean; + onRegenerate?: () => void; + regenerateDisabled?: boolean; +}) { + return ( +
+
+

Changed

+ {onRegenerate ? ( + + ) : null} +
+ +
+ ); +} + const createWalkthroughBlocks = ( files: ReadonlyArray, walkthroughView: WalkthroughView, @@ -178,36 +289,45 @@ const createWalkthroughBlocks = ( const firstBlockIdByStop: Array = []; const stopIndexByBlockId = new Map(); - for (const stop of walkthroughView.sequence) { - const focusedRuns = getFocusedRunDiffs(stop, files); - if (focusedRuns.length === 0) { - const blockId = `walkthrough:${stop.id}:missing`; - firstBlockIdByStop[stop.index] = blockId; - stopIndexByBlockId.set(blockId, stop.index); + for (const chapter of walkthroughView.chapters) { + if (chapter.commit) { blocks.push({ - header: , - headerSelected: stop.index === currentIndex, - id: blockId, + header: , + headerSelected: false, + id: `walkthrough:commit:${chapter.commit.sha}`, }); - continue; } + for (const stop of chapter.stops) { + const header = ; + const focusedRuns = getFocusedRunDiffs(stop, files); + if (focusedRuns.length === 0) { + const blockId = `walkthrough:${stop.id}:missing`; + firstBlockIdByStop[stop.index] = blockId; + stopIndexByBlockId.set(blockId, stop.index); + blocks.push({ + header, + headerSelected: stop.index === currentIndex, + id: blockId, + }); + continue; + } - firstBlockIdByStop[stop.index] = `walkthrough:${stop.id}:0`; + firstBlockIdByStop[stop.index] = `walkthrough:${stop.id}:0`; - focusedRuns.forEach(({ file, note, reviewIdentity }, runIndex) => { - const blockId = `walkthrough:${stop.id}:${runIndex}`; - stopIndexByBlockId.set(blockId, stop.index); - blocks.push({ - file, - header: - runIndex === 0 ? : null, - headerSelected: stop.index === currentIndex, - id: blockId, - itemIdPrefix: blockId, - note, - reviewIdentity, + focusedRuns.forEach(({ file, note, reviewIdentity }, runIndex) => { + const blockId = `walkthrough:${stop.id}:${runIndex}`; + stopIndexByBlockId.set(blockId, stop.index); + blocks.push({ + file, + header: runIndex === 0 ? header : null, + headerSelected: stop.index === currentIndex, + id: blockId, + itemIdPrefix: blockId, + note, + reviewIdentity, + }); }); - }); + } } return { blocks, firstBlockIdByStop, stopIndexByBlockId }; @@ -231,6 +351,9 @@ const createSupportBlocks = ( selected: boolean, walkthroughView: WalkthroughView, showWhitespace: boolean, + changedPaths?: ReadonlySet, + onRegenerateWalkthrough?: () => void, + regenerateDisabled?: boolean, ): ReadonlyArray => { const blocks: Array = []; for (const group of walkthroughView.supportByReason) { @@ -252,7 +375,13 @@ const createSupportBlocks = ( } const uncoveredFiles = getUncoveredWalkthroughFiles(files, walkthroughView, showWhitespace); - for (const file of uncoveredFiles) { + // Changes that arrived after the walkthrough was generated (e.g. via an + // in-place refresh) get their own "Changed" section; other uncovered hunks + // stay under "Support" as before. + const changedFiles = uncoveredFiles.filter((file) => changedPaths?.has(file.path)); + const uncoveredSupportFiles = uncoveredFiles.filter((file) => !changedPaths?.has(file.path)); + + for (const file of uncoveredSupportFiles) { const blockId = `walkthrough:uncovered:${file.path}`; const isFirstBlock = blocks.length === 0; blocks.push({ @@ -269,6 +398,29 @@ const createSupportBlocks = ( }); } + changedFiles.forEach((file, fileIndex) => { + const blockId = `walkthrough:changed:${file.path}`; + blocks.push({ + file, + header: + fileIndex === 0 ? ( + + ) : null, + headerSelected: selected, + id: blockId, + itemIdPrefix: blockId, + note: 'Changed after the walkthrough was generated.', + reviewIdentity: { + fingerprint: file.fingerprint, + key: blockId, + }, + }); + }); + return blocks; }; @@ -395,6 +547,17 @@ function Arc({
+ {chapter.commit ? ( + + ) : null} {chapter.title}
@@ -497,26 +660,32 @@ function Arc({ export function NarrativeWalkthroughView({ allowCommit = true, + changedPaths, files, navigation, onActiveReviewTargetChange, onCommit, onCommitOutput, + onRegenerateWalkthrough, onShareWalkthrough, onUpdateCommitMessage, + regenerateDisabled, renderDiffBlocks, shareWalkthroughDisabled, showWhitespace, walkthrough, }: { allowCommit?: boolean; + changedPaths?: ReadonlySet; files: ReadonlyArray; navigation: NarrativeNavigation; onActiveReviewTargetChange: (target: WalkthroughReviewTarget | null) => void; onCommit: CommitHandler; onCommitOutput?: CommitOutputSubscriber; + onRegenerateWalkthrough?: () => void; onShareWalkthrough?: () => void; onUpdateCommitMessage: CommitMessageHandler; + regenerateDisabled?: boolean; renderDiffBlocks: RenderWalkthroughDiffBlocks; shareWalkthroughDisabled?: boolean; showWhitespace: boolean; @@ -534,9 +703,25 @@ export function NarrativeWalkthroughView({ const supportBlocks = useMemo( () => walkthroughView - ? createSupportBlocks(files, navigation.mode === 'support', walkthroughView, showWhitespace) + ? createSupportBlocks( + files, + navigation.mode === 'support', + walkthroughView, + showWhitespace, + changedPaths, + onRegenerateWalkthrough, + regenerateDisabled, + ) : [], - [files, navigation.mode, showWhitespace, walkthroughView], + [ + changedPaths, + files, + navigation.mode, + onRegenerateWalkthrough, + regenerateDisabled, + showWhitespace, + walkthroughView, + ], ); const supportAvailable = supportBlocks.length > 0; const firstSupportBlockId = supportBlocks[0]?.id ?? null; diff --git a/core/app/components/walkthrough/WalkthroughProgress.tsx b/core/app/components/walkthrough/WalkthroughProgress.tsx index a116272b..03f042b2 100644 --- a/core/app/components/walkthrough/WalkthroughProgress.tsx +++ b/core/app/components/walkthrough/WalkthroughProgress.tsx @@ -16,10 +16,14 @@ export const nextWalkthroughResponseLabelIndex = (current: number) => const TIMER_THRESHOLD_SECONDS = 3; export function WalkthroughProgress({ + detail, + label: labelOverride, phase, responseLabelIndex, stageRevision, }: { + detail?: string | null; + label?: string; phase: WalkthroughProgressPhase | null; responseLabelIndex: number; stageRevision: number; @@ -40,21 +44,25 @@ export function WalkthroughProgress({ const elapsedSeconds = timerState.stageRevision === stageRevision ? timerState.elapsedSeconds : 0; const showTimer = elapsedSeconds >= TIMER_THRESHOLD_SECONDS; const label = - phase === 'agent-generation' + labelOverride ?? + (phase === 'agent-generation' ? 'Analyzing changes…' : phase === 'response-received' ? walkthroughResponseLabels[Math.abs(responseLabelIndex) % walkthroughResponseLabels.length] - : 'Generating walkthrough…'; + : 'Generating walkthrough…'); return ( - {label} - + {label}{detail ? ` · ${detail}` : ''} + + + ); } diff --git a/core/app/components/walkthrough/icons.tsx b/core/app/components/walkthrough/icons.tsx new file mode 100644 index 00000000..9de9e249 --- /dev/null +++ b/core/app/components/walkthrough/icons.tsx @@ -0,0 +1,21 @@ +import { ArrowLeftIcon as ArrowLeft } from '@phosphor-icons/react/ArrowLeft'; +import { ArrowRightIcon as ArrowRight } from '@phosphor-icons/react/ArrowRight'; +import { ArrowsClockwiseIcon as ArrowsClockwise } from '@phosphor-icons/react/ArrowsClockwise'; +import { CaretLeftIcon as CaretLeft } from '@phosphor-icons/react/CaretLeft'; +import { CaretRightIcon as CaretRight } from '@phosphor-icons/react/CaretRight'; +import { CheckIcon as Check } from '@phosphor-icons/react/Check'; +import { GitBranchIcon as GitBranch } from '@phosphor-icons/react/GitBranch'; +import { PathIcon as Path } from '@phosphor-icons/react/Path'; +import { ShareNetworkIcon as ShareNetwork } from '@phosphor-icons/react/ShareNetwork'; + +export { + ArrowLeft, + ArrowRight, + ArrowsClockwise, + CaretLeft, + CaretRight, + Check, + GitBranch, + Path, + ShareNetwork, +}; diff --git a/core/index.ts b/core/index.ts index f3066731..9577a60e 100644 --- a/core/index.ts +++ b/core/index.ts @@ -107,3 +107,24 @@ export type { WalkthroughShareManifestV1, WalkthroughHunk, } from './types.ts'; +export { + formatVersionElapsedDuration, + MergeRequestReviewApp, + ReadOnlyGeneralCommentCard, + ReviewSurface, + type MergeRequestCommitListEntry, + type MergeRequestReviewAppProps, + type MergeRequestReviewMode, + type MergeRequestReviewStrategySummary, + type MergeRequestVersionBaseMovement, + type MergeRequestVersionBaseMovementCommit, + type MergeRequestVersionCommitEvolution, + type MergeRequestVersionCommitEvolutionUnit, + type MergeRequestVersionCommitSummary, + type MergeRequestVersionCompareCommentAssociation, + type MergeRequestVersionCompareSummary, + type MergeRequestVersionCompareView, + type MergeRequestVersionOption, + type MergeRequestVersionRebaseDriverCommit, + type MergeRequestWalkthroughStatus, +} from './SharedWalkthroughApp.tsx'; diff --git a/core/lib/diff.ts b/core/lib/diff.ts index 841011a4..61084a62 100644 --- a/core/lib/diff.ts +++ b/core/lib/diff.ts @@ -12,7 +12,8 @@ export const getItemId = (section: DiffSection) => `diff:${section.id}`; export const isMarkdownFilePath = (path: string) => /\.md$/i.test(path); -const isImageFilePath = (path: string) => /\.(?:apng|avif|bmp|gif|ico|jpe?g|png|webp)$/i.test(path); +export const isImageFilePath = (path: string) => + /\.(?:apng|avif|bmp|gif|ico|jpe?g|png|webp)$/i.test(path); export const canRenderImagePreview = (path: string, section: DiffSection) => isImageFilePath(path) && @@ -52,7 +53,7 @@ const fileDiffSectionLookup = new WeakMap< export const getSectionForFileDiff = (fileDiff: FileDiffMetadata) => fileDiffSectionLookup.get(fileDiff); -const getLoadedSectionContents = (file: ChangedFile, section: DiffSection) => +export const getLoadedSectionContents = (file: ChangedFile, section: DiffSection) => loadedSectionContents.get(getLoadedContentsKey(file, section)); export const loadSectionContents = ( @@ -218,7 +219,7 @@ export const getTotalDiffLineCount = (lineCounts: Iterable): Diff export const formatLineCountNumber = (value: number) => value.toLocaleString('en-US'); -const formatCompactLineCountNumber = (value: number) => { +export const formatCompactLineCountNumber = (value: number) => { if (value < 1000) { return String(value); } @@ -237,7 +238,7 @@ const formatCompactLineCountNumber = (value: number) => { export const formatTreeLineCount = ({ additions, deletions }: DiffLineCount) => `+${formatCompactLineCountNumber(additions)} -${formatCompactLineCountNumber(deletions)}`; -const pluralizeLine = (count: number) => (count === 1 ? 'line' : 'lines'); +export const pluralizeLine = (count: number) => (count === 1 ? 'line' : 'lines'); export const getDiffLineCountTitle = ({ additions, deletions }: DiffLineCount) => `${formatLineCountNumber(additions)} added ${pluralizeLine( diff --git a/core/lib/narrative-walkthrough.ts b/core/lib/narrative-walkthrough.ts index 000c29e1..1d5639bf 100644 --- a/core/lib/narrative-walkthrough.ts +++ b/core/lib/narrative-walkthrough.ts @@ -17,7 +17,7 @@ import { isSyntheticWalkthroughHunk, } from './narrative-walkthrough-diff.js'; -type NarrativeLineCount = { +export type NarrativeLineCount = { added: number; deleted: number; }; @@ -29,12 +29,12 @@ export type WalkthroughStopView = WalkthroughStop & { }; /** A chapter with indexed stops. */ -type WalkthroughChapterView = Omit & { +export type WalkthroughChapterView = Omit & { stops: ReadonlyArray; }; /** Support grouped by reason, preserving first-seen order. */ -type WalkthroughSupportReason = { +export type WalkthroughSupportReason = { files: ReadonlyArray; reason: string; }; @@ -338,19 +338,21 @@ export const resolveWalkthroughHunkFile = ( hunk: WalkthroughHunk, files: ReadonlyArray, ): ResolvedWalkthroughHunkFile | null => { - const file = files.find((candidate) => candidate.path === hunk.path); - if (!file) { + const candidates = files.filter((candidate) => candidate.path === hunk.path); + if (candidates.length === 0) { return null; } - - const section = hunk.anchor.sectionId - ? file.sections.find((candidate) => candidate.id === hunk.anchor.sectionId) - : undefined; - if (!section) { + const sectionId = hunk.anchor.sectionId; + if (!sectionId) { return null; } - - return { file, section }; + for (const file of candidates) { + const section = file.sections.find((candidate) => candidate.id === sectionId); + if (section) { + return { file, section }; + } + } + return null; }; const walkthroughHunkRunKey = (item: WalkthroughHunkGroup, hunks: ReadonlyArray) => diff --git a/core/react.ts b/core/react.ts index ea6e4a3a..c581101e 100644 --- a/core/react.ts +++ b/core/react.ts @@ -8,11 +8,46 @@ export { PlanReviewSurface, type PlanReviewCommenting, } from './SharedPlanApp.tsx'; +export { CommitScopePanel } from './app/components/CommitScopePanel.tsx'; export { + formatVersionElapsedDuration, + MergeRequestReviewApp, ReadOnlyGeneralCommentCard, ReviewSurface, + type MergeRequestCommitListEntry, + type MergeRequestReviewAppProps, + type MergeRequestReviewMode, + type MergeRequestReviewStrategySummary, + type MergeRequestVersionBaseMovement, + type MergeRequestVersionBaseMovementCommit, + type MergeRequestVersionCommitEvolution, + type MergeRequestVersionCommitEvolutionUnit, + type MergeRequestVersionCommitSummary, + type MergeRequestVersionCompareCommentAssociation, + type MergeRequestVersionCompareSummary, + type MergeRequestVersionCompareView, + type MergeRequestVersionOption, + type MergeRequestVersionRebaseDriverCommit, + type MergeRequestWalkthroughStatus, type ReviewCommenting, type ReviewMode, type ReviewSurfaceProps, type ReviewWalkthroughStatus, } from './SharedWalkthroughApp.tsx'; +export type { + DiffComparison, + DiffComparisonAnalysis, + DiffComparisonView, + DiffRange, + ReviewCommitEvolution, + ReviewCommitListEntry, + ReviewEvolutionUnit, + ReviewPlan, + ReviewStrategySummary, + ReviewStructureRecommendation, + ReviewUnit, + ReviewVersionOption, + RevisionLabel, + RevisionRef, + WalkthroughGenerationInput, +} from './types.ts'; diff --git a/core/walkthrough.css b/core/walkthrough.css index 5142685f..bcc10586 100644 --- a/core/walkthrough.css +++ b/core/walkthrough.css @@ -8,12 +8,36 @@ border-bottom: 1px solid var(--sidebar-border); padding: 0 8px 8px; } +.wt-focus-header { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; + min-width: 0; +} .wt-focus-label { color: var(--text); font: 700 10px/1 var(--font-sans); letter-spacing: 0.06em; text-transform: uppercase; } +.wt-regen-button { + background: transparent; + border: 1px solid rgb(127 127 127 / 0.22); + border-radius: 999px; + color: var(--muted); + cursor: pointer; + flex: none; + font: 9px/1 var(--font-mono); + letter-spacing: 0.04em; + padding: 4px 7px; + text-transform: uppercase; + white-space: nowrap; +} +.wt-regen-button:hover { + background: rgb(127 127 127 / 0.12); + color: var(--sidebar-text); +} .wt-focus p { color: var(--sidebar-text); font: 12px/1.5 var(--font-sans); @@ -38,6 +62,43 @@ display: flex; flex-direction: column; } +.wt-toc-chapter.commit-boundary { + border-top: 1px solid var(--file-border); + margin-top: 8px; + padding-top: 8px; +} +.wt-toc-chapter.commit-boundary:first-child { + margin-top: 0; +} +.wt-commit-boundary-label { + align-items: center; + color: var(--muted); + display: grid; + font: 10px/1.25 var(--font-mono); + gap: 6px; + grid-template-columns: auto auto minmax(0, 1fr); + max-width: 100%; + min-width: 0; + overflow: hidden; + padding: 0 2px 7px; +} +.wt-commit-boundary-label code { + color: var(--sidebar-text); +} +.wt-commit-boundary-label span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wt-commit-evolution-indicator { + font-weight: 700; + flex-shrink: 0; +} +.wt-commit-evolution-indicator.evolution-added { color: var(--diff-addition); } +.wt-commit-evolution-indicator.evolution-removed { color: var(--diff-deletion); } +.wt-commit-evolution-indicator.evolution-revised { color: #d9962f; } +.wt-commit-evolution-indicator.evolution-ambiguous { color: var(--muted); } +.wt-commit-evolution-indicator.evolution-unchanged { color: var(--muted); } .wt-toc-chapter-head { align-items: center; display: grid; @@ -329,6 +390,46 @@ gap: 12px; margin: 0 0 12px; } +.wt-comment-references { + display: grid; + gap: 8px; + margin-top: 12px; +} +.wt-comment-reference { + background: color-mix(in srgb, var(--tree-selection-focus) 7%, transparent); + border-left: 3px solid var(--tree-selection-focus); + border-radius: 4px; + padding: 8px 10px; +} +.wt-comment-reference.resolved-by-change { border-left-color: var(--addition); } +.wt-comment-reference > div { align-items: baseline; display: flex; gap: 8px; justify-content: space-between; } +.wt-comment-reference a, +.wt-comment-reference strong { color: var(--text); font: 600 12px/1.35 var(--font-sans); } +.wt-comment-reference span { color: var(--muted); font: 11px/1.35 var(--font-sans); } +.wt-comment-reference blockquote { + color: var(--muted); + font: 12px/1.45 var(--font-sans); + margin: 6px 0 0; + white-space: pre-wrap; +} +.wt-regenerate { + align-items: center; + background: color-mix(in srgb, var(--tree-selection-focus) 13%, transparent); + border: 0; + border-radius: 14px; + color: var(--tree-selection-focus); + corner-shape: squircle; + cursor: pointer; + display: inline-flex; + flex: none; + font: 600 12px/1 var(--font-sans); + gap: 6px; + padding: 7px 12px; +} +.wt-regenerate:disabled { + cursor: default; + opacity: 0.6; +} /* ---- Variation C — hybrid arc timeline ------------------------------------ */ .wt-hybrid { display: flex; @@ -453,6 +554,14 @@ .wt-arc-chapter-label.muted { color: color-mix(in srgb, var(--muted) 85%, transparent); } +.wt-arc-commit-ref { + background: color-mix(in srgb, var(--tree-selection-focus) 14%, transparent); + border-radius: 4px; + color: var(--tree-selection-focus); + font: 9px/1.3 var(--font-mono); + padding: 1px 4px; + text-transform: none; +} .wt-arc-chapter-label .icon { font-size: 13px; } @@ -561,6 +670,37 @@ .wt-stop-block-header.current { box-shadow: inset 3px 0 0 var(--tree-selection-focus); } +.wt-main-commit-section { + margin: 0 0 20px; +} +.wt-main-commit-boundary { + max-width: 100%; + min-width: 0; + overflow: hidden; + align-items: baseline; + background: color-mix(in srgb, var(--tree-selection-focus) 8%, var(--app-bg)); + border-block: 1px solid color-mix(in srgb, var(--tree-selection-focus) 30%, var(--file-border)); + display: flex; + gap: 8px; + padding: 10px 24px; +} +.wt-main-commit-boundary > span { + color: var(--muted); + font: 700 10px/1 var(--font-sans); + text-transform: uppercase; +} +.wt-main-commit-boundary code { + color: var(--tree-selection-focus); + font: 11px/1.2 var(--font-mono); +} +.wt-main-commit-boundary strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); + font: 600 12px/1.3 var(--font-sans); +} /* The whole order as one continuous scroll: each stop is a block (narration + diff) stacked top-to-bottom, separated by a rule, with the scroll-focused stop @@ -1284,7 +1424,7 @@ display: flex; gap: 8px; } -.codiff-button.wt-commit-btn { +.codiff-open-button.wt-commit-btn { background: var(--viewed); border-color: color-mix(in srgb, var(--viewed) 46%, transparent); box-shadow: @@ -1294,7 +1434,7 @@ 0 10px 30px -16px color-mix(in srgb, var(--viewed) 90%, transparent); color: #fff; } -.codiff-button.wt-commit-btn:hover:not(:disabled) { +.codiff-open-button.wt-commit-btn:hover:not(:disabled) { background: color-mix(in srgb, var(--viewed) 92%, #fff); box-shadow: 0 0 0 1px rgb(255 255 255 / 0.2) inset, @@ -1303,10 +1443,10 @@ 0 10px 30px -16px color-mix(in srgb, var(--viewed) 90%, transparent); transform: scale(1.05); } -.codiff-button.wt-commit-btn:active:not(:disabled) { +.codiff-open-button.wt-commit-btn:active:not(:disabled) { transform: scale(0.95); } -.codiff-button.wt-commit-btn:disabled { +.codiff-open-button.wt-commit-btn:disabled { background: var(--hover-wash); border-color: rgb(127 127 127 / 0.18); box-shadow: none; @@ -1316,3 +1456,115 @@ color: #fff; font: inherit; } + +.wt-commit-boundary-label { + align-items: center; + display: grid; + gap: 6px; + grid-template-columns: auto auto minmax(0, 1fr); + max-width: 100%; + min-width: 0; + overflow: hidden; +} +.wt-commit-boundary-label code { + flex: none; + white-space: nowrap; +} +.wt-commit-boundary-subject { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wt-commit-rebase-badge { + background: color-mix(in srgb, #d9962f 14%, transparent); + border: 1px solid color-mix(in srgb, #d9962f 35%, transparent); + border-radius: 4px; + color: #d9962f; + font: 10px/1.35 var(--font-sans); + grid-column: 1 / -1; + min-width: 0; + padding: 2px 5px; + width: fit-content; +} +.wt-commit-rebase-badge:focus-visible { + outline: 2px solid var(--tree-selection-focus); + outline-offset: 2px; +} +.wt-main-rebase-context { + border-bottom: 1px solid color-mix(in srgb, #d9962f 30%, var(--file-border)); + display: grid; + gap: 7px; + padding: 10px 24px 12px; +} +.wt-main-rebase-context > strong { + color: #d9962f; + font: 600 11px/1.35 var(--font-sans); +} +.wt-main-rebase-context.legacy > span { + color: var(--muted); + font: 11px/1.45 var(--font-sans); +} +.wt-main-rebase-drivers { + display: grid; + gap: 5px; +} +.wt-main-rebase-drivers > a, +.wt-main-rebase-drivers > div { + color: inherit; + display: grid; + gap: 2px 7px; + grid-template-columns: auto minmax(0, 1fr); + text-decoration: none; +} +.wt-main-rebase-drivers code { + color: var(--tree-selection-focus); + font: 10px/1.4 var(--font-mono); +} +.wt-main-rebase-drivers span { + font: 11px/1.4 var(--font-sans); + min-width: 0; + overflow-wrap: anywhere; +} +.wt-main-rebase-drivers small { + color: var(--muted); + font: 10px/1.4 var(--font-sans); + grid-column: 2; +} +.wt-version-base-context { + border-bottom: 1px solid var(--file-border); + display: grid; + gap: 5px; + padding: 12px 24px; +} +.wt-version-base-context > strong { + font: 600 12px/1.4 var(--font-sans); +} +.wt-version-base-context > span, +.wt-version-base-context summary { + color: var(--muted); + font: 11px/1.45 var(--font-sans); +} +.wt-version-base-context summary { + cursor: pointer; +} +.wt-version-base-context details > div { + display: grid; + gap: 4px; + margin-top: 6px; +} +.wt-version-base-context details > div > div { + color: inherit; + display: grid; + gap: 7px; + grid-template-columns: auto minmax(0, 1fr); +} +.wt-version-base-context .git-commit-ref-trigger { + color: var(--tree-selection-focus); + font: 10px/1.4 var(--font-mono); +} +.wt-version-base-context details > div > div > span { + font: 11px/1.4 var(--font-sans); + overflow-wrap: anywhere; +} From 885758f8f2d1ddd1579f14913abbeccc2a80f1a1 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:31 -0500 Subject: [PATCH 02/10] Use provider-neutral review comment coordinates Remove shared walkthrough section identifiers from provider-facing comment payloads and recover the rendered diff section from file, side, and line coordinates. Update Core and Codiff Web callers so persisted comments use one provider-neutral contract without losing their in-app placement. --- core/__tests__/review-comments.test.ts | 44 +------- core/lib/review-comments.ts | 135 +++++++++++++++-------- core/types.ts | 1 - service/react.test.ts | 1 - web/src/sharing/WalkthroughPage.test.tsx | 50 +++++++++ web/src/sharing/WalkthroughPage.tsx | 4 +- 6 files changed, 147 insertions(+), 88 deletions(-) diff --git a/core/__tests__/review-comments.test.ts b/core/__tests__/review-comments.test.ts index dcdfb5af..fce91aeb 100644 --- a/core/__tests__/review-comments.test.ts +++ b/core/__tests__/review-comments.test.ts @@ -5,8 +5,6 @@ import { getPendingPullRequestReviewComments, getReviewCommentsFromState, getVisibleReviewComments, - mergeReviewComments, - toSubmittedReviewComment, toPullRequestReviewComment, } from '../lib/review-comments.ts'; import type { RepositoryState } from '../types.ts'; @@ -74,7 +72,7 @@ test('getReviewCommentsFromState carries the outdated flag through to review com expect(comments.find((comment) => comment.id === 'github:2')?.isOutdated).toBeUndefined(); }); -test('getReviewCommentsFromState hydrates shared comments on their exact working-tree section', () => { +test('getReviewCommentsFromState derives the working-tree section from line coordinates', () => { const state = createPullRequestState(); state.source = { type: 'working-tree' }; state.files = [ @@ -85,13 +83,15 @@ test('getReviewCommentsFromState hydrates shared comments on their exact working binary: false, id: 'src/a.ts:staged', kind: 'staged', - patch: '', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new', }, { binary: false, id: 'src/a.ts:unstaged', kind: 'unstaged', - patch: '', + patch: + 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -5 +5 @@\n-old\n+new', }, ], }, @@ -103,7 +103,6 @@ test('getReviewCommentsFromState hydrates shared comments on their exact working filePath: 'src/a.ts', id: 'shared:1', lineNumber: 5, - sectionId: 'src/a.ts:unstaged', side: 'additions', }, ]; @@ -309,39 +308,8 @@ test('serializes file-level thread replies without inventing line metadata', () }); }); -test('serializes section identity only for shared walkthrough comments', () => { +test('omits UI-only section identity from review comment payloads', () => { const comment = createReviewComment({ body: 'Persist this comment.' }); expect(toPullRequestReviewComment(comment)).not.toHaveProperty('sectionId'); - expect(toPullRequestReviewComment(comment, { includeSectionId: true })).toMatchObject({ - sectionId: 'src/a.ts:pull-request:1', - }); -}); - -test('keeps a submitted shared comment visible until the matching snapshot comment arrives', () => { - const draft = createReviewComment({ - id: 'draft-comment', - remoteSubmit: { status: 'submitting' }, - }); - const submitted = toSubmittedReviewComment( - { - author: { login: 'ada', name: 'Ada Lovelace' }, - body: draft.body, - canDelete: true, - canEdit: true, - filePath: draft.filePath, - id: 'persisted-comment', - lineNumber: draft.lineNumber, - sectionId: draft.sectionId, - side: draft.side, - submittedAt: '2026-07-16T12:00:00.000Z', - threadId: 'persisted-thread', - }, - draft, - ); - - expect(mergeReviewComments([], [submitted])).toEqual([submitted]); - - const snapshotComment = { ...submitted, body: 'Canonical server comment.' }; - expect(mergeReviewComments([snapshotComment], [submitted])).toEqual([snapshotComment]); }); diff --git a/core/lib/review-comments.ts b/core/lib/review-comments.ts index e2b24a60..85365916 100644 --- a/core/lib/review-comments.ts +++ b/core/lib/review-comments.ts @@ -1,7 +1,7 @@ +import type { CodeViewLineSelection } from '@pierre/diffs'; import type { ChangedFile, DiffSection, - PullRequestExistingReviewComment, PullRequestReviewComment, RepositoryState, } from '../types.ts'; @@ -31,6 +31,18 @@ export const isLineReviewComment = ( ): comment is ReviewComment & { lineNumber: number; side: 'additions' | 'deletions' } => !isFileReviewComment(comment); +export const getReviewCommentLineSelection = (comment: ReviewComment): CodeViewLineSelection => ({ + id: `diff:${comment.sectionId}`, + range: { + end: comment.lineNumber ?? 1, + ...(comment.startSide != null && comment.startSide !== comment.side + ? { endSide: comment.side } + : {}), + side: comment.startSide ?? comment.side ?? 'additions', + start: comment.startLineNumber ?? comment.lineNumber ?? 1, + }, +}); + type ReviewPatchRow = { additionLineNumber?: number; deletionLineNumber?: number; @@ -67,9 +79,10 @@ export function updateStickyHeaderState(viewer: CodeViewInstance) { } } -const getReviewSideLabel = (side: ReviewComment['side']) => (side === 'additions' ? 'New' : 'Old'); +export const getReviewSideLabel = (side: ReviewComment['side']) => + side === 'additions' ? 'New' : 'Old'; -const getReviewCommentStartSide = (comment: Pick) => +export const getReviewCommentStartSide = (comment: Pick) => comment.startSide ?? comment.side; export const getReviewCommentLineLabel = ( @@ -116,53 +129,15 @@ export const getReviewCommentRangeProps = ( : {}; }; -export const toPullRequestReviewComment = ( - comment: ReviewComment, - { includeSectionId = false }: { includeSectionId?: boolean } = {}, -): PullRequestReviewComment => ({ +export const toPullRequestReviewComment = (comment: ReviewComment): PullRequestReviewComment => ({ body: comment.body, filePath: comment.filePath, ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), - ...(includeSectionId ? { sectionId: comment.sectionId } : {}), ...(comment.side ? { side: comment.side } : {}), ...getReviewCommentRangeProps(comment), ...(comment.threadId ? { threadId: comment.threadId } : {}), }); -export const toSubmittedReviewComment = ( - comment: PullRequestExistingReviewComment, - draft: ReviewComment, -): ReviewComment => ({ - ...(comment.anchor ? { anchor: comment.anchor } : {}), - author: comment.author, - body: comment.body, - ...(comment.canDelete ? { canDelete: true } : {}), - ...(comment.canEdit ? { canEdit: true } : {}), - ...(comment.canReplyThread === false ? { canReplyThread: false } : {}), - ...(comment.canResolveThread ? { canResolveThread: true } : {}), - filePath: comment.filePath, - id: comment.id, - ...(comment.isOutdated ? { isOutdated: true } : {}), - isReadOnly: true, - ...(comment.isThreadResolved ? { isThreadResolved: true } : {}), - ...(comment.lineNumber != null ? { lineNumber: comment.lineNumber } : {}), - sectionId: comment.sectionId ?? draft.sectionId, - ...(comment.side ? { side: comment.side } : {}), - ...(comment.startLineNumber != null ? { startLineNumber: comment.startLineNumber } : {}), - ...(comment.startSide ? { startSide: comment.startSide } : {}), - ...(comment.submittedAt ? { submittedAt: comment.submittedAt } : {}), - ...(comment.threadId ? { threadId: comment.threadId } : {}), - ...(comment.url ? { url: comment.url } : {}), -}); - -export const mergeReviewComments = ( - snapshotComments: ReadonlyArray, - localComments: ReadonlyArray, -): ReadonlyArray => { - const snapshotIds = new Set(snapshotComments.map((comment) => comment.id)); - return [...snapshotComments, ...localComments.filter((comment) => !snapshotIds.has(comment.id))]; -}; - const isPendingPullRequestReviewComment = (comment: ReviewComment) => !comment.isReadOnly && !comment.threadId && @@ -246,7 +221,7 @@ const trimReviewPatchLineTerminator = (line: string) => const getReviewPatchText = (lines: ReadonlyArray, index: number) => trimReviewPatchLineTerminator(lines[index] ?? ''); -const getReviewCommentPatchContext = ( +export const getReviewCommentPatchContext = ( file: ChangedFile, section: DiffSection, comment: ReviewComment, @@ -337,6 +312,77 @@ const getReviewCommentPatchContext = ( return section.summary?.reason || section.patch.trim() || 'No patch context available.'; }; +export const getReviewCommentSection = ( + file: ChangedFile, + comment: Pick, + showWhitespace: boolean, +) => { + if (isFileReviewComment(comment)) { + return file.sections[0]; + } + const side = comment.side ?? 'additions'; + const line = comment.lineNumber ?? 1; + const startLine = comment.startLineNumber ?? line; + const startSide = comment.startSide ?? side; + return ( + file.sections.find((section) => { + const parsed = parseSectionDiffWithOptions(file, section, showWhitespace); + return parsed.hunks.some((hunk) => { + let oldLine = hunk.deletionStart; + let newLine = hunk.additionStart; + let hasStart = false; + let hasEnd = false; + for (const content of hunk.hunkContent) { + if (content.type === 'context') { + if ( + (startSide === 'additions' && + startLine >= newLine && + startLine < newLine + content.lines) || + (startSide === 'deletions' && + startLine >= oldLine && + startLine < oldLine + content.lines) + ) { + hasStart = true; + } + if ( + (side === 'additions' && line >= newLine && line < newLine + content.lines) || + (side === 'deletions' && line >= oldLine && line < oldLine + content.lines) + ) { + hasEnd = true; + } + oldLine += content.lines; + newLine += content.lines; + continue; + } + if (side === 'deletions' && line >= oldLine && line < oldLine + content.deletions) { + hasEnd = true; + } + if ( + startSide === 'deletions' && + startLine >= oldLine && + startLine < oldLine + content.deletions + ) { + hasStart = true; + } + if (side === 'additions' && line >= newLine && line < newLine + content.additions) { + hasEnd = true; + } + if ( + startSide === 'additions' && + startLine >= newLine && + startLine < newLine + content.additions + ) { + hasStart = true; + } + oldLine += content.deletions; + newLine += content.additions; + } + return hasStart && hasEnd; + }); + }) ?? file.sections[0] + ); +}; + export const buildReviewCommentsMarkdown = ( files: ReadonlyArray, comments: ReadonlyArray, @@ -383,8 +429,7 @@ export const buildReviewCommentsMarkdown = ( export const getReviewCommentsFromState = (state: RepositoryState): ReadonlyArray => (state.reviewComments ?? []).flatMap((comment) => { const file = state.files.find((candidate) => candidate.path === comment.filePath); - const section = - file?.sections.find((candidate) => candidate.id === comment.sectionId) ?? file?.sections[0]; + const section = file ? getReviewCommentSection(file, comment, false) : undefined; return section ? [ { diff --git a/core/types.ts b/core/types.ts index 161ccd1c..4c9103bc 100644 --- a/core/types.ts +++ b/core/types.ts @@ -1107,7 +1107,6 @@ export type PullRequestReviewComment = { body: string; filePath: string; lineNumber?: number; - sectionId?: string; side?: 'additions' | 'deletions'; startLineNumber?: number; startSide?: 'additions' | 'deletions'; diff --git a/service/react.test.ts b/service/react.test.ts index d28f0c45..a84116e8 100644 --- a/service/react.test.ts +++ b/service/react.test.ts @@ -5,7 +5,6 @@ const comment = { body: 'Keep this visible while it saves.', filePath: 'src/review.ts', lineNumber: 12, - sectionId: 'src/review.ts:unstaged', side: 'additions', } as const; diff --git a/web/src/sharing/WalkthroughPage.test.tsx b/web/src/sharing/WalkthroughPage.test.tsx index 1ddc1029..15783bbb 100644 --- a/web/src/sharing/WalkthroughPage.test.tsx +++ b/web/src/sharing/WalkthroughPage.test.tsx @@ -179,3 +179,53 @@ test('optimistically adds walkthrough-level comments and replies', async () => { view: {}, }); }); + +test('persists diff comments using provider-neutral line coordinates', async () => { + await act(async () => { + root.render( + + + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const commenting = rendered.props?.commenting as { + onSubmitComment(comment: { + body: string; + filePath: string; + lineNumber: number; + side: 'additions'; + }): Promise; + }; + await commenting.onSubmitComment({ + body: 'Review this line.', + filePath: 'src/review.ts', + lineNumber: 12, + side: 'additions', + }); + + expect(fate.client.mutations.shareComment.createThread).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + body: 'Review this line.', + shareId: 'walkthrough-id', + shareType: 'walkthrough', + target: { + filePath: 'src/review.ts', + kind: 'walkthrough-diff', + lineNumber: 12, + side: 'additions', + }, + }, + optimistic: expect.objectContaining({ + filePath: 'src/review.ts', + kind: 'walkthrough-diff', + lineNumber: 12, + sectionId: null, + side: 'additions', + }), + view: {}, + }), + ); +}); diff --git a/web/src/sharing/WalkthroughPage.tsx b/web/src/sharing/WalkthroughPage.tsx index 3ad5e9b9..b8c18800 100644 --- a/web/src/sharing/WalkthroughPage.tsx +++ b/web/src/sharing/WalkthroughPage.tsx @@ -81,7 +81,6 @@ const reviewComments = ( ? { anchor: 'file' as const } : { lineNumber: thread.lineNumber!, - ...(thread.sectionId ? { sectionId: thread.sectionId } : {}), side: thread.side!, ...(thread.startLineNumber ? { startLineNumber: thread.startLineNumber } : {}), ...(thread.startSide ? { startSide: thread.startSide } : {}), @@ -140,7 +139,6 @@ const commentTarget = (comment: PullRequestReviewComment) => { filePath: comment.filePath, kind: 'walkthrough-diff' as const, lineNumber: comment.lineNumber, - ...(comment.sectionId ? { sectionId: comment.sectionId } : {}), side: comment.side, ...(comment.startLineNumber ? { startLineNumber: comment.startLineNumber } : {}), ...(comment.startSide ? { startSide: comment.startSide } : {}), @@ -288,7 +286,7 @@ const Viewer = ({ walkthrough: walkthroughRef }: { walkthrough: ViewRef<'Walkthr ], planId: null, resolvedAt: null, - sectionId: fileComment ? null : (target.sectionId ?? null), + sectionId: null, side: fileComment ? null : target.side, startLineNumber: fileComment ? null : (target.startLineNumber ?? null), startSide: fileComment ? null : (target.startSide ?? null), From 2b267978b88f45ffd3a9dd174bd94d4e71552e87 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 21:50:17 -0500 Subject: [PATCH 03/10] Explain commit evolution in shared review UI Present introduced, removed, revised, retained, ambiguous, and absorbed commits with consistent labels in tooltips, comparison lists, and walkthrough chapter boundaries. Carry the classification through walkthrough authoring so reviewers can understand why a logical change moved or was rewritten without reading provider-specific metadata. --- core/App.css | 37 +++- core/SharedWalkthroughApp.tsx | 42 ++-- core/__tests__/walkthrough-authoring.test.ts | 23 +++ core/app/components/CommitRefTooltip.tsx | 37 +++- core/app/components/CommitScopePanel.tsx | 5 + .../walkthrough/NarrativeSidebar.tsx | 117 ++++++----- .../walkthrough/NarrativeWalkthroughView.tsx | 193 +++++++++--------- core/index.ts | 4 + core/lib/walkthrough-authoring.ts | 106 ++++++++-- core/types.ts | 24 ++- core/walkthrough-authoring.ts | 2 + core/walkthrough.css | 32 ++- 12 files changed, 403 insertions(+), 219 deletions(-) diff --git a/core/App.css b/core/App.css index 57ea8dd4..871addd9 100644 --- a/core/App.css +++ b/core/App.css @@ -1480,20 +1480,37 @@ html[data-codiff-platform='darwin'] .sidebar { .version-commit-unit > span:nth-child(3) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .version-commit-unit.unchanged { opacity: 0.5; } .version-commit-unit.unchanged:not(:disabled):hover { opacity: 0.8; } -.version-commit-kind { font-weight: 700; } -.version-commit-kind.added { color: var(--diff-addition); } -.version-commit-kind.removed { color: var(--diff-deletion); } -.version-commit-kind.likely-revised, -.version-commit-kind.revised { +.version-commit-kind-pill { + align-items: center; + background: color-mix(in srgb, currentColor 12%, transparent); + border: 1px solid color-mix(in srgb, currentColor 32%, transparent); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + font: 700 9px/1 var(--font-sans); + justify-self: start; + letter-spacing: 0.01em; + padding: 3px 5px; + white-space: nowrap; +} + +.version-commit-kind-pill.added, +.version-commit-kind-pill.introduced { + color: var(--diff-addition); +} +.version-commit-kind-pill.removed { + color: var(--diff-deletion); +} +.version-commit-kind-pill.likely-revised, +.version-commit-kind-pill.revised { color: #d9962f; } -.version-commit-kind.introduced { - color: #3f9a5a; +.version-commit-kind-pill.absorbed-into-base, +.version-commit-kind-pill.ambiguous, +.version-commit-kind-pill.unchanged { + color: var(--muted); } -.version-commit-kind.absorbed-into-base { color: var(--muted); } -.version-commit-kind.ambiguous { color: var(--muted); } -.version-commit-kind.unchanged { color: var(--muted); } .version-commit-unit-block { display: grid; gap: 1px; } .version-commit-rebase-drivers { display: grid; diff --git a/core/SharedWalkthroughApp.tsx b/core/SharedWalkthroughApp.tsx index 987b0040..94545c8a 100644 --- a/core/SharedWalkthroughApp.tsx +++ b/core/SharedWalkthroughApp.tsx @@ -23,7 +23,10 @@ import { } from 'react'; import { Avatar } from './app/components/Avatar.tsx'; import { Button } from './app/components/Button.tsx'; -import { CommitRefTooltip } from './app/components/CommitRefTooltip.tsx'; +import { + CommitRefTooltip, + versionCommitKindLabel, +} from './app/components/CommitRefTooltip.tsx'; import { CommitScopePanel } from './app/components/CommitScopePanel.tsx'; import { isTerminalPullRequestMergeState, @@ -3513,10 +3516,9 @@ export function ReviewSurface({ key={commit.sha} > - {isBackward ? '−' : '+'} + {isBackward ? 'Removed' : 'Added'} {commit.subject} @@ -3576,18 +3578,12 @@ export function ReviewSurface({ unit.kind === 'retained' || unit.kind === 'rewritten-same-patch' || unit.kind === 'absorbed-into-base'; - const symbol = - unit.kind === 'introduced' - ? '+' - : unit.kind === 'removed' - ? '−' - : unit.kind === 'revised' - ? '~' - : unit.kind === 'absorbed-into-base' - ? '↳' - : unit.kind === 'ambiguous' - ? '?' - : '·'; + const versionCommitKind = + unit.kind !== 'commit' && + 'after' in unit && + unit.after?.sha === commit.sha + ? unit.kind + : undefined; const kindClass = unit.kind === 'absorbed-into-base' ? 'absorbed-into-base' @@ -3639,7 +3635,11 @@ export function ReviewSurface({ } type="button" > - {symbol} + + {unit.kind === 'commit' + ? 'Commit' + : versionCommitKindLabel(unit.kind)} + - - ~ + + Rebase driver { expect(composed.title).toContain('v1'); }); +test('scopes version-comparison Review focus to changes since the earlier version', () => { + const overviewPrompt = buildVersionCommitOverviewPrompt({ + entries: [ + { + context: { + after: { shortSha: 'bbbbbbb', subject: 'Later' }, + evolutionKind: 'added', + kind: 'version-commit', + range: { fromLabel: 'v1', toLabel: 'v2' }, + unitId: 'unit-1', + }, + state, + walkthrough: null, + }, + ], + range: { fromLabel: 'v1', toLabel: 'v2' }, + }); + expect(overviewPrompt).toContain('strictly the changes since v1, through v2'); + expect(overviewPrompt).toContain('Do not summarize the merge request as a whole'); + expect(overviewPrompt).toContain('behavior already present in v1 as newly introduced'); +}); + test('exposes prompt digest sizing and patch budgets', () => { const { digest, patchBudgets, size } = buildWalkthroughPromptInput(state); expect(size.hunkCount).toBe(2); diff --git a/core/app/components/CommitRefTooltip.tsx b/core/app/components/CommitRefTooltip.tsx index f4c2b98c..1dcf7827 100644 --- a/core/app/components/CommitRefTooltip.tsx +++ b/core/app/components/CommitRefTooltip.tsx @@ -1,5 +1,6 @@ import { Tooltip } from '@base-ui/react/tooltip'; import { ExternalLink } from 'lucide-react'; +import type { VersionCommitKind } from '../../types.ts'; export type CommitRefSummary = { additions?: number; @@ -9,9 +10,28 @@ export type CommitRefSummary = { sha: string; shortSha: string; subject?: string; + /** Present only for commits from the later side of a version comparison. */ + versionCommitKind?: VersionCommitKind; webUrl?: string; }; - +export const versionCommitKindLabel = (kind: VersionCommitKind) => { + switch (kind) { + case 'introduced': + return 'Added'; + case 'removed': + return 'Removed'; + case 'revised': + return 'Revised'; + case 'retained': + return 'Unchanged'; + case 'rewritten-same-patch': + return 'Same patch'; + case 'absorbed-into-base': + return 'In target base'; + case 'ambiguous': + return 'Unclassified'; + } +}; export function CommitRefTooltip({ className, commit, @@ -26,12 +46,7 @@ export function CommitRefTooltip({ const triggerClassName = ['git-commit-ref-trigger', className].filter(Boolean).join(' '); const trigger = linkTrigger && commit.webUrl ? ( - + ) : ( ); @@ -49,6 +64,14 @@ export function CommitRefTooltip({ {commit.subject || 'Commit'} {commit.sha} + {commit.versionCommitKind ? ( + + {versionCommitKindLabel(commit.versionCommitKind)} + + ) : null} {commit.authorName || authoredLabel ? ( {[commit.authorName, authoredLabel].filter(Boolean).join(' · ')} diff --git a/core/app/components/CommitScopePanel.tsx b/core/app/components/CommitScopePanel.tsx index b0244f8d..80913391 100644 --- a/core/app/components/CommitScopePanel.tsx +++ b/core/app/components/CommitScopePanel.tsx @@ -113,6 +113,10 @@ export function CommitScopePanel({ if (!commit || !onToggleVersionUnit) { return null; } + const versionCommitKind = + unit.kind !== 'commit' && 'after' in unit && unit.after?.sha === commit.sha + ? unit.kind + : undefined; return (
diff --git a/core/app/components/walkthrough/NarrativeWalkthroughView.tsx b/core/app/components/walkthrough/NarrativeWalkthroughView.tsx index 8568d8a7..b774817a 100644 --- a/core/app/components/walkthrough/NarrativeWalkthroughView.tsx +++ b/core/app/components/walkthrough/NarrativeWalkthroughView.tsx @@ -163,7 +163,9 @@ function StopHeader({ current, stop }: { current: boolean; stop: WalkthroughStop
{comment.url ? ( - Review comment by {comment.authorName} + + Review comment by {comment.authorName} + ) : ( Review comment by {comment.authorName} )} @@ -868,110 +870,109 @@ export function NarrativeWalkthroughView({ onTouchStartCapture={navigation.releaseStopScrollLock} onWheelCapture={navigation.releaseStopScrollLock} > + {navigation.mode === 'commit' ? ( + + ) : walkthroughView.sequence.length > 0 ? ( + renderDiffBlocks(reviewBlocks, reviewBlockScrollTarget, handleActiveBlockChange) + ) : ( + renderDiffBlocks(reviewBlocks, reviewBlockScrollTarget, handleActiveBlockChange) + )} - {navigation.mode === 'commit' ? ( - - ) : walkthroughView.sequence.length > 0 ? ( - renderDiffBlocks(reviewBlocks, reviewBlockScrollTarget, handleActiveBlockChange) - ) : ( - renderDiffBlocks(reviewBlocks, reviewBlockScrollTarget, handleActiveBlockChange) - )} - - {navigation.mode === 'commit' ? null : completionAction ? ( - - ) : navigation.mode === 'stop' && next ? ( - - ) : navigation.mode === 'stop' && supportAvailable ? ( - + ) : navigation.mode === 'stop' && next ? ( + + ) : navigation.mode === 'stop' && supportAvailable ? ( + - ) : navigation.mode === 'stop' && committable ? ( - + ) : navigation.mode === 'stop' && committable ? ( + - ) : navigation.mode === 'support' && committable ? ( - + ) : navigation.mode === 'support' && committable ? ( + - ) : navigation.mode === 'support' ? ( - + ) : navigation.mode === 'support' ? ( + + ) : null}
diff --git a/core/index.ts b/core/index.ts index 9577a60e..98cb2b9a 100644 --- a/core/index.ts +++ b/core/index.ts @@ -46,6 +46,7 @@ export { type GenerateReviewWalkthroughResult, type ReviewWalkthroughModelResult, type ReviewWalkthroughRunModel, + type ReviewWalkthroughRunOverviewModel, } from './lib/generate-review-walkthrough.ts'; export { parsePlanShareManifest, @@ -94,6 +95,7 @@ export type { ReviewStructureRecommendation, ReviewUnit, ReviewVersionOption, + VersionCommitKind, RepositoryState, ReviewSource, RevisionLabel, @@ -103,6 +105,8 @@ export type { SharedPlanSnapshot, SharedWalkthroughSnapshot, WalkthroughCommentReference, + WalkthroughGenerationProgress, + WalkthroughGenerationUnitProgress, WalkthroughGenerationInput, WalkthroughShareManifestV1, WalkthroughHunk, diff --git a/core/lib/walkthrough-authoring.ts b/core/lib/walkthrough-authoring.ts index aac60902..9afcb360 100644 --- a/core/lib/walkthrough-authoring.ts +++ b/core/lib/walkthrough-authoring.ts @@ -26,19 +26,19 @@ import { string, union, } from 'valibot'; -import { - getSectionWalkthroughHunks, - isGeneratedWalkthroughPath, -} from './narrative-walkthrough-diff.js'; import type { ChangedFile, DiffSection, NarrativeWalkthrough, RepositoryState, + VersionCommitKind, WalkthroughCommentReference, WalkthroughHunk, } from '../types.ts'; - +import { + getSectionWalkthroughHunks, + isGeneratedWalkthroughPath, +} from './narrative-walkthrough-diff.js'; export const maxProseChars = 4000; export const maxPatchExcerpt = 2500; @@ -277,7 +277,11 @@ const compactNullableGroup = (group: { title?: string | null; }) => ({ ...(group.changeType - ? { changeType: group.changeType as NonNullable } + ? { + changeType: group.changeType as NonNullable< + WalkthroughDraft['chapters'][number]['stops'][number]['changeType'] + >, + } : {}), ...(group.commitNote ? { commitNote: group.commitNote } : {}), hunkIds: [...group.hunkIds], @@ -368,7 +372,6 @@ export type WalkthroughReviewStrategy = reason: string; }; - type IndexedHunk = WalkthroughHunk & { sectionId: string; sectionKind: 'pull-request'; @@ -477,7 +480,11 @@ export const normalizeWalkthroughDraft = ( const used = new Set(); const itemIds = new Set(); - const resolveGroup = (group: WalkthroughDraft["chapters"][number]["stops"][number] | NonNullable[number]) => { + const resolveGroup = ( + group: + | WalkthroughDraft['chapters'][number]['stops'][number] + | NonNullable[number], + ) => { if (itemIds.has(group.id)) { return null; } @@ -500,8 +507,12 @@ export const normalizeWalkthroughDraft = ( return { ...counts, ...(group.changeType - ? { changeType: group.changeType as NonNullable } - : {}), + ? { + changeType: group.changeType as NonNullable< + WalkthroughDraft['chapters'][number]['stops'][number]['changeType'] + >, + } + : {}), ...(group.commitNote ? { commitNote: clean(group.commitNote) } : {}), hunkIds, hunks, @@ -1034,24 +1045,21 @@ Repository digest: ${JSON.stringify(digest)}`; }; - - /** @deprecated Prefer normalizeWalkthroughDraft. */ export const normalizeAuthoredWalkthrough = normalizeWalkthroughDraft; export type UnitWalkthroughEntry = { context: | { - kind: 'mr-commit'; commit: { sha: string; shortSha: string; subject: string; webUrl?: string; }; + kind: 'mr-commit'; } | { - kind: 'version-commit'; after?: { sha: string; shortSha: string; @@ -1065,6 +1073,8 @@ export type UnitWalkthroughEntry = { webUrl?: string; }; commentReferences?: ReadonlyArray; + evolutionKind?: 'likely-revised' | 'added' | 'removed' | 'ambiguous'; + kind: 'version-commit'; range: { fromLabel: string; toLabel: string }; rebaseDrivers?: ReadonlyArray<{ authoredAt?: string; @@ -1080,17 +1090,71 @@ export type UnitWalkthroughEntry = { state: RepositoryState; walkthrough: NarrativeWalkthrough | null; }; +const versionCommitOverviewKindLabel = ( + kind: Extract['evolutionKind'], +) => { + switch (kind) { + case 'added': + return 'added'; + case 'removed': + return 'removed'; + case 'likely-revised': + return 'revised'; + default: + return 'unclassified'; + } +}; + +const toVersionCommitKind = ( + kind: NonNullable< + Extract['evolutionKind'] + >, +): VersionCommitKind => + kind === 'added' ? 'introduced' : kind === 'likely-revised' ? 'revised' : kind; + +/** Build the focused cross-commit summary that appears above a unit walkthrough. */ +export const buildVersionCommitOverviewPrompt = ({ + entries, + range, +}: { + entries: ReadonlyArray; + range: { fromLabel: string; toLabel: string }; +}) => { + const commits = entries.flatMap((entry) => { + if (entry.context.kind !== 'version-commit') { + return []; + } + const { after, before, evolutionKind } = entry.context; + return [ + { + earlierCommit: before ? `${before.shortSha}: ${before.subject}` : null, + kind: versionCommitOverviewKindLabel(evolutionKind), + laterCommit: after ? `${after.shortSha}: ${after.subject}` : null, + unitFocus: entry.walkthrough?.focus ?? null, + }, + ]; + }); + return `Write the Review focus for a commit-by-commit version comparison from ${range.fromLabel} to ${range.toLabel}. + +> Scope is strictly the changes since ${range.fromLabel}, through ${range.toLabel}. Frame every statement as a change that occurred after the earlier selected version. Do not summarize the merge request as a whole, describe behavior already present in ${range.fromLabel} as newly introduced, or infer changes outside this version window. +> The reviewer needs a concise, evidence-based overview of the various changed commits before following the detailed walkthrough. Synthesize the supplied unit summaries; do not merely count commits or repeat their subjects. Mention each commit's kind (added, removed, revised, or unclassified) when it clarifies what changed. Do not describe pure rebase noise as new behavior. Use 2-4 short sentences, no heading or list. + +> Commit context (ordered): +${JSON.stringify(commits)}`; +}; /** * Deterministically compose completed unit walkthroughs into one narrative. */ export const composeUnitWalkthroughs = ({ agent, entries, + focus, state, }: { agent: NarrativeWalkthrough['agent']; entries: ReadonlyArray; + focus?: string; state: RepositoryState; }): NarrativeWalkthrough => { const chapters = entries.flatMap((entry) => { @@ -1113,6 +1177,11 @@ export const composeUnitWalkthroughs = ({ shortSha: summary.shortSha, subject: summary.subject, webUrl: summary.webUrl, + ...(context.kind === 'version-commit' && + context.evolutionKind && + context.after?.sha === summary.sha + ? { versionCommitKind: toVersionCommitKind(context.evolutionKind) } + : {}), ...(rebaseDrivers.length > 0 ? { rebaseDrivers: rebaseDrivers.map((driver) => ({ @@ -1139,9 +1208,12 @@ export const composeUnitWalkthroughs = ({ agent, chapters, commitFiles: entries.flatMap((entry) => entry.state.files), - focus: isVersionComparison - ? `Review ${entries.length} changed commit units in stack order.` - : `Review ${entries.length} commits in topological order, from oldest to newest.`, + focus: clean( + focus ?? '', + isVersionComparison + ? `Review ${entries.length} changed commit units in stack order.` + : `Review ${entries.length} commits in topological order, from oldest to newest.`, + ), generatedAt: new Date().toISOString(), kind: 'narrative', repo: { branch: state.branch, root: state.root }, diff --git a/core/types.ts b/core/types.ts index 4c9103bc..4929401d 100644 --- a/core/types.ts +++ b/core/types.ts @@ -237,10 +237,27 @@ export type CodiffFeatureFlags = { walkthroughSharing: boolean; }; +export type WalkthroughGenerationUnitProgress = { + detail?: string; + id: string; + label: string; + status: 'failed' | 'generating' | 'pending' | 'ready'; +}; + +/** Host/agent progress for an in-flight walkthrough generation. */ +export type WalkthroughGenerationProgress = { + completed?: number; + phase: 'combining' | 'generating' | 'generating-units' | 'preparing'; + summary: string; + total?: number; + units?: ReadonlyArray; +}; + export type WalkthroughProgressPhase = 'agent-generation' | 'response-received'; export type WalkthroughProgressEvent = { - phase: WalkthroughProgressPhase; + generation?: WalkthroughGenerationProgress; + phase?: WalkthroughProgressPhase; }; export type CodiffMarkdownDocument = { @@ -564,6 +581,9 @@ export type ReviewEvolutionMarkerUnit = { export type ReviewEvolutionUnit = ReviewUnit | ReviewEvolutionMarkerUnit; +/** Classification of a commit while comparing two review versions. */ +export type VersionCommitKind = Exclude; + export type ReviewEvolutionSummary = { absorbedIntoBase: number; added: number; @@ -885,6 +905,8 @@ export type WalkthroughChapter = { sha: string; shortSha: string; subject: string; + /** Classification when this boundary is a commit from the later comparison version. */ + versionCommitKind?: VersionCommitKind; webUrl?: string; }; icon: WalkthroughIcon; diff --git a/core/walkthrough-authoring.ts b/core/walkthrough-authoring.ts index b18b36cb..63c9ded2 100644 --- a/core/walkthrough-authoring.ts +++ b/core/walkthrough-authoring.ts @@ -1,6 +1,7 @@ export { attachVersionCommentReferences, buildWalkthroughPrompt, + buildVersionCommitOverviewPrompt, buildWalkthroughPromptInput, combineCommitWalkthroughs, composeUnitWalkthroughs, @@ -34,4 +35,5 @@ export { type GenerateReviewWalkthroughResult, type ReviewWalkthroughModelResult, type ReviewWalkthroughRunModel, + type ReviewWalkthroughRunOverviewModel, } from './lib/generate-review-walkthrough.ts'; diff --git a/core/walkthrough.css b/core/walkthrough.css index bcc10586..273530a7 100644 --- a/core/walkthrough.css +++ b/core/walkthrough.css @@ -91,14 +91,8 @@ white-space: nowrap; } .wt-commit-evolution-indicator { - font-weight: 700; flex-shrink: 0; } -.wt-commit-evolution-indicator.evolution-added { color: var(--diff-addition); } -.wt-commit-evolution-indicator.evolution-removed { color: var(--diff-deletion); } -.wt-commit-evolution-indicator.evolution-revised { color: #d9962f; } -.wt-commit-evolution-indicator.evolution-ambiguous { color: var(--muted); } -.wt-commit-evolution-indicator.evolution-unchanged { color: var(--muted); } .wt-toc-chapter-head { align-items: center; display: grid; @@ -401,11 +395,24 @@ border-radius: 4px; padding: 8px 10px; } -.wt-comment-reference.resolved-by-change { border-left-color: var(--addition); } -.wt-comment-reference > div { align-items: baseline; display: flex; gap: 8px; justify-content: space-between; } +.wt-comment-reference.resolved-by-change { + border-left-color: var(--addition); +} +.wt-comment-reference > div { + align-items: baseline; + display: flex; + gap: 8px; + justify-content: space-between; +} .wt-comment-reference a, -.wt-comment-reference strong { color: var(--text); font: 600 12px/1.35 var(--font-sans); } -.wt-comment-reference span { color: var(--muted); font: 11px/1.35 var(--font-sans); } +.wt-comment-reference strong { + color: var(--text); + font: 600 12px/1.35 var(--font-sans); +} +.wt-comment-reference span { + color: var(--muted); + font: 11px/1.35 var(--font-sans); +} .wt-comment-reference blockquote { color: var(--muted); font: 12px/1.45 var(--font-sans); @@ -433,12 +440,17 @@ /* ---- Variation C — hybrid arc timeline ------------------------------------ */ .wt-hybrid { display: flex; + flex: 1; flex-direction: column; height: 100%; min-height: 0; min-width: 0; width: 100%; } + +.codiff-web-review > .wt-arc { + order: -1; +} .wt-arc { align-items: center; background: var(--app-bg); From 0bf5597df9ffe501d39029fb9f1fc7e28cdab4b4 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 21:50:17 -0500 Subject: [PATCH 04/10] Show detailed walkthrough generation progress Expose structured generation progress to the shared review surface and render current phases plus per-unit status while a walkthrough is prepared, generated, and combined. Keep loading and generation messaging host-neutral so Codiff Web can provide useful feedback without coupling the UI to a specific agent runtime. --- core/App.css | 95 ++++++- core/SharedWalkthroughApp.tsx | 24 +- core/__tests__/WalkthroughProgress.test.tsx | 43 ++++ .../generate-review-walkthrough.test.ts | 44 +++- .../walkthrough/WalkthroughProgress.tsx | 65 ++++- core/lib/generate-review-walkthrough.ts | 232 +++++++++++++----- 6 files changed, 415 insertions(+), 88 deletions(-) diff --git a/core/App.css b/core/App.css index 871addd9..113690e7 100644 --- a/core/App.css +++ b/core/App.css @@ -2288,16 +2288,79 @@ html[data-codiff-platform='darwin'] .sidebar { width: 100%; } +.walkthrough-progress-copy { + display: grid; + gap: 6px; + min-width: 0; + text-align: left; + width: 100%; +} + +.walkthrough-progress-heading { + align-items: baseline; + display: inline-flex; + gap: 0.5ch; + justify-content: center; + max-width: 100%; + min-width: 0; +} + .walkthrough-progress-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.walkthrough-progress-units { + display: grid; + gap: 3px; + list-style: none; + margin: 0; + max-height: 12rem; + overflow: auto; + padding: 0; +} + +.walkthrough-progress-unit { + align-items: baseline; + color: var(--muted, inherit); + display: grid; + font-size: 12px; + font-weight: 400; + gap: 8px; + grid-template-columns: 5.5rem minmax(0, 1fr); + opacity: 0.9; +} + +.walkthrough-progress-unit.is-generating { + color: inherit; + opacity: 1; +} + +.walkthrough-progress-unit.is-ready { + opacity: 0.75; +} + +.walkthrough-progress-unit.is-failed { + color: var(--danger, #a33a3a); +} + +.walkthrough-progress-unit-status { + font-variant-numeric: tabular-nums; + opacity: 0.8; + text-transform: lowercase; +} + +.walkthrough-progress-unit-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .walkthrough-progress-timer { flex: 0 0 2ch; font-variant-numeric: tabular-nums; - margin-left: 0.5ch; text-align: left; visibility: hidden; width: 2ch; @@ -2312,9 +2375,35 @@ html[data-codiff-platform='darwin'] .sidebar { } .walkthrough-generating-main { - display: grid; - gap: 10px; + align-items: center; + box-sizing: border-box; + display: flex; + flex: 1; + height: 100%; + justify-content: center; + min-height: 0; + min-width: 0; + padding: 24px; + width: 100%; +} + +.walkthrough-generating-main .walkthrough-progress, +.walkthrough-generating-main .walkthrough-progress-copy { + width: auto; +} + +.walkthrough-generating-main .walkthrough-progress-copy { justify-items: center; + text-align: center; +} + +.walkthrough-generating-main .walkthrough-progress-label { + white-space: normal; +} + +.walkthrough-generating-main .walkthrough-progress-units { + justify-items: start; + text-align: left; } .walkthrough-generating-main > span { diff --git a/core/SharedWalkthroughApp.tsx b/core/SharedWalkthroughApp.tsx index 94545c8a..c33712d7 100644 --- a/core/SharedWalkthroughApp.tsx +++ b/core/SharedWalkthroughApp.tsx @@ -123,6 +123,7 @@ import type { ReviewVersionOption, RepositoryState, SharedWalkthroughSnapshot, + WalkthroughGenerationProgress, WalkthroughCommitMessageResult, WalkthroughCommitResult, } from './types.ts'; @@ -272,6 +273,7 @@ export type MergeRequestReviewAppProps = { versionWalkthroughStructure?: 'commit-by-commit' | 'whole-diff'; walkthrough: NarrativeWalkthrough | null; walkthroughError?: string | null; + walkthroughProgress?: WalkthroughGenerationProgress | null; walkthroughStatus: MergeRequestWalkthroughStatus; }; @@ -1377,6 +1379,7 @@ export type ReviewSurfaceProps = { onVersionCompareRangeChange?: (fromId: string, toId: string) => void; reviewStrategy?: MergeRequestReviewStrategySummary | null; walkthroughError?: string | null; + walkthroughProgress?: WalkthroughGenerationProgress | null; walkthroughStatus: MergeRequestWalkthroughStatus; }; onDeleteShare?: () => Promise | void; @@ -3176,6 +3179,7 @@ export function ReviewSurface({ walkthroughBusy && !walkthroughRequestForce && walkthroughStatus !== 'generating'; const generatingWalkthrough = walkthroughBusy && (walkthroughRequestForce || walkthroughStatus === 'generating'); + const walkthroughGenerationProgress = interactive?.walkthroughProgress ?? null; const walkthroughStatusTitle = walkthroughFailed ? 'Walkthrough unavailable' : computingVersionChanges @@ -3183,8 +3187,8 @@ export function ReviewSurface({ : walkthroughIdle && !walkthroughBusy ? 'Walkthrough not generated' : loadingWalkthroughLookup - ? `Loading ${walkthroughStructurePhrase} walkthrough…` - : `Generating ${walkthroughStructurePhrase} walkthrough…`; + ? 'Loading walkthrough…' + : 'Generating walkthrough…'; const walkthroughStatusDescription = walkthroughFailed ? (interactive?.walkthroughError ?? 'Fix the generation issue, then try again.') : computingVersionChanges @@ -3193,16 +3197,17 @@ export function ReviewSurface({ ? versionCompareActive ? 'Choose a walkthrough structure, then generate it for this version comparison.' : 'Generate a walkthrough to review these changes.' - : (interactive?.walkthroughError ?? + : (walkthroughGenerationProgress?.summary ?? + interactive?.walkthroughError ?? (loadingWalkthroughLookup - ? `Looking up a cached ${walkthroughStructurePhrase} walkthrough for this scope.` - : `Building the ${walkthroughStructurePhrase} walkthrough.`)); + ? `Looking up a cached ${walkthroughStructurePhrase} walkthrough.` + : null)); const walkthroughProgressLabel = computingVersionChanges ? 'Computing version changes…' : loadingWalkthroughLookup - ? `Loading ${walkthroughStructurePhrase} walkthrough…` + ? 'Loading walkthrough…' : generatingWalkthrough - ? `Generating ${walkthroughStructurePhrase} walkthrough…` + ? 'Generating walkthrough…' : undefined; const shellTheme = snapshot.preferences.theme === 'system' ? undefined : snapshot.preferences.theme; @@ -3998,6 +4003,7 @@ export function ReviewSurface({ detail={walkthroughStatusDescription} label={walkthroughProgressLabel} phase={null} + progress={walkthroughGenerationProgress} responseLabelIndex={0} stageRevision={walkthroughProgressRevision} /> @@ -4126,6 +4132,7 @@ export function ReviewSurface({ detail="Comparing the selected versions and preparing the review surface." label="Computing version changes…" phase={null} + progress={walkthroughGenerationProgress} responseLabelIndex={0} stageRevision={walkthroughProgressRevision} /> @@ -4275,6 +4282,7 @@ export function ReviewSurface({ detail={walkthroughStatusDescription} label={walkthroughProgressLabel ?? walkthroughStatusTitle} phase={null} + progress={walkthroughGenerationProgress} responseLabelIndex={0} stageRevision={walkthroughProgressRevision} /> @@ -4399,6 +4407,7 @@ export function MergeRequestReviewApp({ versionWalkthroughStructure, walkthrough, walkthroughError, + walkthroughProgress, walkthroughStatus, }: MergeRequestReviewAppProps) { const placeholderWalkthrough = useMemo( @@ -4479,6 +4488,7 @@ export function MergeRequestReviewApp({ onVersionCompareRangeChange, reviewStrategy, walkthroughError, + walkthroughProgress, walkthroughStatus, }} onModeChange={onModeChange} diff --git a/core/__tests__/WalkthroughProgress.test.tsx b/core/__tests__/WalkthroughProgress.test.tsx index a7ba4e1a..3a89f450 100644 --- a/core/__tests__/WalkthroughProgress.test.tsx +++ b/core/__tests__/WalkthroughProgress.test.tsx @@ -94,3 +94,46 @@ test('reserves timer space, reveals 3s without shifting, and resets for each sta container.remove(); } }); + +test('renders a detailed commit-unit preparation status', async () => { + const container = document.createElement('div'); + document.body.append(container); + let root: Root | null = createRoot(container); + + try { + await act(async () => { + root?.render( + , + ); + }); + + expect(container.textContent).toContain('1/2'); + expect(container.textContent).toContain('Preparing aaaaaaa Add review focus.'); + expect(container.textContent).toContain('done'); + expect(container.textContent).toContain('generating'); + expect(container.textContent).toContain('bbbbbbb Render commit pills'); + } finally { + await act(async () => root?.unmount()); + root = null; + container.remove(); + } +}); diff --git a/core/__tests__/generate-review-walkthrough.test.ts b/core/__tests__/generate-review-walkthrough.test.ts index c38098cd..1c72068a 100644 --- a/core/__tests__/generate-review-walkthrough.test.ts +++ b/core/__tests__/generate-review-walkthrough.test.ts @@ -1,6 +1,10 @@ import { expect, test, vi } from 'vite-plus/test'; import { generateReviewWalkthrough } from '../lib/generate-review-walkthrough.ts'; -import type { RepositoryState, ReviewCommitEvolution } from '../types.ts'; +import type { + RepositoryState, + ReviewCommitEvolution, + WalkthroughGenerationProgress, +} from '../types.ts'; import { createChangedFile } from './helpers/fixtures.ts'; const baseState = { @@ -109,16 +113,31 @@ test('generateReviewWalkthrough units path fans out and composes', async () => { ], } satisfies ReviewCommitEvolution; - const runModel = vi.fn(async () => ({ draft })); + let releaseModels: () => void; + const modelsReady = new Promise((resolve) => { + releaseModels = resolve; + }); + const runModel = vi.fn(async () => { + await modelsReady; + return { draft }; + }); + const progressUpdates: Array = []; + const overviewPrompts: Array = []; + const runOverviewModel = vi.fn(async ({ prompt }: { prompt: string }) => { + overviewPrompts.push(prompt); + return { focus: 'The added commits establish the feature in two ordered steps.' }; + }); const unitState = { ...baseState, files: [createChangedFile('src/unit.ts')], } satisfies RepositoryState; - const result = await generateReviewWalkthrough({ + const generation = generateReviewWalkthrough({ agent: 'codex', evolution, + onProgress: (progress) => progressUpdates.push(progress), runModel, + runOverviewModel, states: { byUnitId: { 'introduced:a': unitState, @@ -128,15 +147,34 @@ test('generateReviewWalkthrough units path fans out and composes', async () => { }, structure: 'units', }); + expect(runModel).toHaveBeenCalledTimes(2); + releaseModels!(); + const result = await generation; expect(result.status).toBe('ready'); if (result.status !== 'ready') { return; } expect(runModel).toHaveBeenCalledTimes(2); + expect(runOverviewModel).toHaveBeenCalledTimes(1); + expect(overviewPrompts).toEqual([expect.stringContaining('"kind":"added"')]); + expect(result.walkthrough.focus).toBe( + 'The added commits establish the feature in two ordered steps.', + ); + expect(result.walkthrough.chapters[0]?.commit?.versionCommitKind).toBe('introduced'); expect(result.plan.structure).toBe('units'); expect(result.walkthrough.chapters.length).toBeGreaterThan(0); expect(result.walkthrough.title).toContain('Commit-by-commit'); + expect(progressUpdates).toContainEqual( + expect.objectContaining({ + phase: 'generating-units', + total: 2, + units: expect.arrayContaining([expect.objectContaining({ status: 'generating' })]), + }), + ); + expect(progressUpdates).toContainEqual( + expect.objectContaining({ phase: 'combining', summary: 'Composing commit walkthroughs.' }), + ); }); test('generateReviewWalkthrough fails clearly without whole state', async () => { diff --git a/core/app/components/walkthrough/WalkthroughProgress.tsx b/core/app/components/walkthrough/WalkthroughProgress.tsx index 03f042b2..37b41154 100644 --- a/core/app/components/walkthrough/WalkthroughProgress.tsx +++ b/core/app/components/walkthrough/WalkthroughProgress.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import type { WalkthroughProgressPhase } from '../../../types.ts'; +import type { WalkthroughGenerationProgress, WalkthroughProgressPhase } from '../../../types.ts'; export const walkthroughResponseLabels = [ 'Building walkthrough…', @@ -15,16 +15,33 @@ export const nextWalkthroughResponseLabelIndex = (current: number) => const TIMER_THRESHOLD_SECONDS = 3; +const unitStatusLabel = ( + status: NonNullable[number]['status'], +) => { + switch (status) { + case 'ready': + return 'done'; + case 'generating': + return 'generating'; + case 'failed': + return 'failed'; + default: + return 'pending'; + } +}; + export function WalkthroughProgress({ detail, label: labelOverride, phase, + progress, responseLabelIndex, stageRevision, }: { detail?: string | null; label?: string; phase: WalkthroughProgressPhase | null; + progress?: WalkthroughGenerationProgress | null; responseLabelIndex: number; stageRevision: number; }) { @@ -50,19 +67,47 @@ export function WalkthroughProgress({ : phase === 'response-received' ? walkthroughResponseLabels[Math.abs(responseLabelIndex) % walkthroughResponseLabels.length] : 'Generating walkthrough…'); + const summary = progress?.summary ?? detail ?? null; + const units = progress?.units ?? []; + const counts = + progress?.total != null + ? `${progress.completed ?? units.filter((unit) => unit.status === 'ready').length}/${progress.total}` + : null; return ( - - {label}{detail ? ` · ${detail}` : ''} + + + + {label} + {counts ? ` · ${counts}` : ''} + {summary ? ` · ${summary}` : ''} + + + + {units.length > 0 ? ( +
    + {units.map((unit) => ( +
  • + + {unitStatusLabel(unit.status)} + + {unit.label} +
  • + ))} +
+ ) : null}
-
); } diff --git a/core/lib/generate-review-walkthrough.ts b/core/lib/generate-review-walkthrough.ts index 1cbc47d3..15dcb1ca 100644 --- a/core/lib/generate-review-walkthrough.ts +++ b/core/lib/generate-review-walkthrough.ts @@ -4,9 +4,12 @@ import type { ReviewCommitEvolution, ReviewPlan, ReviewUnit, + WalkthroughGenerationProgress, + WalkthroughGenerationUnitProgress, } from '../types.ts'; import { resolveReviewPlan, reviewableUnits } from './review-history.ts'; import { + buildVersionCommitOverviewPrompt, buildWalkthroughPrompt, composeUnitWalkthroughs, normalizeWalkthroughDraft, @@ -25,6 +28,10 @@ export type ReviewWalkthroughRunModel = (input: { state: RepositoryState; }) => Promise; +export type ReviewWalkthroughRunOverviewModel = (input: { + agent: NarrativeWalkthrough['agent']; + prompt: string; +}) => Promise<{ focus: string }>; export type GenerateReviewWalkthroughInput = { agent: NarrativeWalkthrough['agent']; /** @@ -32,6 +39,8 @@ export type GenerateReviewWalkthroughInput = { * Hosts typically pass the projected Core evolution for the active compare. */ evolution?: ReviewCommitEvolution | null; + /** Receives unit-level status updates during commit-by-commit generation. */ + onProgress?: (progress: WalkthroughGenerationProgress) => void; /** * Optional explicit plan. When omitted, resolved from evolution recommendation * (units if recommended and unit states exist; otherwise whole-diff). @@ -41,6 +50,8 @@ export type GenerateReviewWalkthroughInput = { promptOptions?: WalkthroughPromptOptions; /** Host-provided model runner (local agent CLI, Think, etc.). */ runModel: ReviewWalkthroughRunModel; + /** Optional second model call that synthesizes the cross-commit review focus. */ + runOverviewModel?: ReviewWalkthroughRunOverviewModel; /** * Whole-diff state and/or per-unit states. * - whole-diff plan: provide `whole` @@ -52,6 +63,8 @@ export type GenerateReviewWalkthroughInput = { }; /** Force whole-diff even when a units plan is available. */ structure?: 'auto' | 'commit-by-commit' | 'whole-diff' | 'units'; + /** Maximum number of model-backed commit units to generate concurrently. */ + unitConcurrency?: number; }; export type GenerateReviewWalkthroughResult = @@ -127,6 +140,33 @@ const toPromptOptionsForUnit = ( }; }; +const unitLabel = (unit: ReviewUnit) => { + const commit = unitAfter(unit) ?? unitBefore(unit); + return commit ? `${commit.shortSha} ${commit.subject}` : unit.id; +}; + +const mapWithConcurrency = async ( + items: ReadonlyArray, + limit: number, + worker: (item: T, index: number) => Promise, +): Promise> => { + const results = new Array(items.length); + let nextIndex = 0; + const runWorker = async () => { + while (true) { + const index = nextIndex++; + if (index >= items.length) { + return; + } + results[index] = await worker(items[index]!, index); + } + }; + await Promise.all( + Array.from({ length: Math.min(Math.max(1, limit), items.length) }, () => runWorker()), + ); + return results; +}; + /** * Host-agnostic walkthrough generation orchestrator. * @@ -211,73 +251,120 @@ export async function generateReviewWalkthrough( const entries: Array = []; const failures: Array = []; + const progressUnits: Array = units.map((unit) => ({ + id: unit.id, + label: unitLabel(unit), + status: 'pending' as const, + })); + const reportUnitProgress = ( + summary: string, + phase: WalkthroughGenerationProgress['phase'] = 'generating-units', + ) => { + input.onProgress?.({ + completed: progressUnits.filter((unit) => unit.status === 'ready' || unit.status === 'failed') + .length, + phase, + summary, + total: units.length, + units: progressUnits.map((unit) => ({ ...unit })), + }); + }; + reportUnitProgress(`Generating ${units.length} commit walkthroughs.`); - for (const unit of units) { - const unitState = input.states.byUnitId?.[unit.id]; - if (!unitState || unitState.files.length === 0) { - failures.push(`${unit.id}: missing unit diff state`); - continue; - } - try { - const prompt = buildWalkthroughPrompt( - unitState, - toPromptOptionsForUnit(unit, range, input.promptOptions), - ); - const { draft } = await input.runModel({ - agent: input.agent, - prompt, - state: unitState, - }); - const walkthrough = normalizeWalkthroughDraft(draft, unitState, input.agent); - const after = unitAfter(unit); - const before = unitBefore(unit); - const context: UnitWalkthroughEntry['context'] = { - kind: 'version-commit', - range, - unitId: unit.id, - ...(after - ? { - after: { - sha: after.sha, - shortSha: after.shortSha, - subject: after.subject, - ...(after.webUrl ? { webUrl: after.webUrl } : {}), - }, - } - : {}), - ...(before - ? { - before: { - sha: before.sha, - shortSha: before.shortSha, - subject: before.subject, - ...(before.webUrl ? { webUrl: before.webUrl } : {}), - }, - } - : {}), - ...('rebaseDrivers' in unit && unit.rebaseDrivers - ? { - rebaseDrivers: unit.rebaseDrivers.map((driver) => ({ - ...(driver.authoredAt ? { authoredAt: driver.authoredAt } : {}), - ...(driver.authorName ? { authorName: driver.authorName } : {}), - ...(driver.overlappingPaths - ? { overlappingPaths: [...driver.overlappingPaths] } - : {}), - ...(driver.sha ? { sha: driver.sha } : {}), - shortSha: driver.shortSha, - subject: driver.subject, - ...(driver.webUrl ? { webUrl: driver.webUrl } : {}), - })), - } - : {}), + const outcomes = await mapWithConcurrency( + units, + input.unitConcurrency ?? 3, + async (unit, index): Promise<{ entry?: UnitWalkthroughEntry; failure?: string }> => { + const unitState = input.states.byUnitId?.[unit.id]; + if (!unitState || unitState.files.length === 0) { + const failure = `${unit.id}: missing unit diff state`; + progressUnits[index] = { ...progressUnits[index]!, detail: failure, status: 'failed' }; + reportUnitProgress(`Skipping ${unitLabel(unit)} because its diff is unavailable.`); + return { failure }; + } + progressUnits[index] = { + ...progressUnits[index]!, + detail: 'Generating walkthrough…', + status: 'generating', }; - entries.push({ - context, - state: unitState, - walkthrough, - }); - } catch (error: unknown) { - failures.push(`${unit.id}: ${error instanceof Error ? error.message : String(error)}`); + reportUnitProgress(`Generating ${unitLabel(unit)}.`); + try { + const prompt = buildWalkthroughPrompt( + unitState, + toPromptOptionsForUnit(unit, range, input.promptOptions), + ); + const { draft } = await input.runModel({ + agent: input.agent, + prompt, + state: unitState, + }); + const walkthrough = normalizeWalkthroughDraft(draft, unitState, input.agent); + const after = unitAfter(unit); + const before = unitBefore(unit); + const context: UnitWalkthroughEntry['context'] = { + evolutionKind: toEvolutionKind(unit.kind), + kind: 'version-commit', + range, + unitId: unit.id, + ...(after + ? { + after: { + sha: after.sha, + shortSha: after.shortSha, + subject: after.subject, + ...(after.webUrl ? { webUrl: after.webUrl } : {}), + }, + } + : {}), + ...(before + ? { + before: { + sha: before.sha, + shortSha: before.shortSha, + subject: before.subject, + ...(before.webUrl ? { webUrl: before.webUrl } : {}), + }, + } + : {}), + ...('rebaseDrivers' in unit && unit.rebaseDrivers + ? { + rebaseDrivers: unit.rebaseDrivers.map((driver) => ({ + ...(driver.authoredAt ? { authoredAt: driver.authoredAt } : {}), + ...(driver.authorName ? { authorName: driver.authorName } : {}), + ...(driver.overlappingPaths + ? { overlappingPaths: [...driver.overlappingPaths] } + : {}), + ...(driver.sha ? { sha: driver.sha } : {}), + shortSha: driver.shortSha, + subject: driver.subject, + ...(driver.webUrl ? { webUrl: driver.webUrl } : {}), + })), + } + : {}), + }; + progressUnits[index] = { ...progressUnits[index]!, status: 'ready' }; + reportUnitProgress(`Finished ${unitLabel(unit)}.`); + return { + entry: { + context, + state: unitState, + walkthrough, + }, + }; + } catch (error: unknown) { + const failure = `${unit.id}: ${error instanceof Error ? error.message : String(error)}`; + progressUnits[index] = { ...progressUnits[index]!, detail: failure, status: 'failed' }; + reportUnitProgress(`Could not generate ${unitLabel(unit)}.`); + return { failure }; + } + }, + ); + for (const outcome of outcomes) { + if (outcome.entry) { + entries.push(outcome.entry); + } + if (outcome.failure) { + failures.push(outcome.failure); } } @@ -291,9 +378,24 @@ export async function generateReviewWalkthrough( }; } + let focus: string | undefined; + reportUnitProgress('Composing commit walkthroughs.', 'combining'); + if (input.runOverviewModel) { + try { + const overview = await input.runOverviewModel({ + agent: input.agent, + prompt: buildVersionCommitOverviewPrompt({ entries, range }), + }); + focus = overview.focus.trim() || undefined; + } catch { + // The detailed unit walkthrough remains useful if a best-effort overview fails. + } + } + const walkthrough = composeUnitWalkthroughs({ agent: input.agent, entries, + focus, state: wholeState, }); From 8572a47680db4f0012bb4d1074aaa58b247a0326 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 18:05:17 -0500 Subject: [PATCH 05/10] Add the shared GitHub review-history adapter Introduce the `@nkzw/codiff-github` workspace package with transport contracts, force-push timeline loading, historical comparison tests, and package build configuration. The package is kept separate from Electron wiring so its provider behavior can be reviewed before local Codiff adopts it. --- github/__tests__/history.test.ts | 317 +++++++++++++ github/package.json | 36 ++ github/src/history.ts | 738 +++++++++++++++++++++++++++++++ github/src/index.ts | 2 + github/src/transport.ts | 109 +++++ github/tsconfig.build.json | 13 + github/vite.config.ts | 8 + package.json | 2 +- pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 1 + vite.config.ts | 11 +- 11 files changed, 1237 insertions(+), 6 deletions(-) create mode 100644 github/__tests__/history.test.ts create mode 100644 github/package.json create mode 100644 github/src/history.ts create mode 100644 github/src/index.ts create mode 100644 github/src/transport.ts create mode 100644 github/tsconfig.build.json create mode 100644 github/vite.config.ts diff --git a/github/__tests__/history.test.ts b/github/__tests__/history.test.ts new file mode 100644 index 00000000..dec044e7 --- /dev/null +++ b/github/__tests__/history.test.ts @@ -0,0 +1,317 @@ +import { + createCommitPatchSignature, + matchVersionCommitStacks, + projectCommitEvolution, +} from '@nkzw/codiff-core'; +import type { ChangedFile } from '@nkzw/codiff-core/types'; +import { expect, test } from 'vite-plus/test'; +import { + buildBaseMovement, + listGitHubReviewVersions, + normalizeForcePushEvent, + type GitHubCommitLike, + type GitHubHistoryGit, +} from '../src/history.ts'; +import { createFakeGitHubTransport } from '../src/transport.ts'; + +test('normalizeForcePushEvent accepts head_ref_force_pushed timeline payloads', () => { + const event = normalizeForcePushEvent({ + after: 'b'.repeat(40), + before: 'a'.repeat(40), + created_at: '2026-01-02T00:00:00.000Z', + event: 'head_ref_force_pushed', + }); + expect(event).toEqual({ + after: 'b'.repeat(40), + before: 'a'.repeat(40), + createdAt: '2026-01-02T00:00:00.000Z', + }); +}); + +test('normalizeForcePushEvent ignores non-force-push timeline noise', () => { + expect( + normalizeForcePushEvent({ + created_at: '2026-01-02T00:00:00.000Z', + event: 'commented', + }), + ).toBeNull(); +}); + +test('listGitHubReviewVersions builds head timeline labels without GitLab version numbers', async () => { + const before = 'a'.repeat(40); + const after = 'b'.repeat(40); + const current = 'c'.repeat(40); + const base = '0'.repeat(40); + const transport = createFakeGitHubTransport([ + { + path: `/repos/nkzw-tech/codiff/issues/12/timeline`, + response: [ + { + actor: { login: 'ada' }, + after, + before, + created_at: '2026-01-02T00:00:00.000Z', + event: 'head_ref_force_pushed', + }, + ], + }, + { + path: `/repos/nkzw-tech/codiff/pulls/12`, + response: { + base: { sha: base }, + head: { sha: current }, + }, + }, + ]); + + const { versions, warning } = await listGitHubReviewVersions({ + pull: { + number: 12, + owner: 'nkzw-tech', + repo: 'codiff', + }, + transport, + }); + + expect(warning).toBeNull(); + expect(versions.map((version) => version.id)).toEqual([before, after, current]); + const labels = versions.map((version) => version.range.head.label.text); + expect(labels.some((label) => label.startsWith('Head ·'))).toBe(true); + expect(labels.some((label) => label.startsWith('Force-push ·'))).toBe(true); + expect(labels.at(-1)).toBe('Current head'); + expect(labels.every((label) => !/^v\d+/.test(label))).toBe(true); +}); + +const commit = ( + shaChar: string, + subject: string, + parent: string, + authoredAt = '2026-01-01T00:00:00.000Z', +): GitHubCommitLike => { + const sha = shaChar.repeat(40); + return { + authoredAt, + authorName: 'Ada', + parentIds: [parent], + sha, + shortSha: sha.slice(0, 7), + subject, + }; +}; + +const patchFile = (filePath: string, body: string): ChangedFile => ({ + fingerprint: filePath, + path: filePath, + sections: [ + { + binary: false, + id: filePath, + kind: 'commit', + patch: body, + }, + ], + status: 'modified', +}); + +test('buildBaseMovement classifies forward base advances with commitsBetween + diffStat', async () => { + const oldBase = '1'.repeat(40); + const mid = commit('2', 'base: mid', oldBase, '2026-01-01T01:00:00.000Z'); + const newBase = commit('3', 'base: tip', mid.sha, '2026-01-01T02:00:00.000Z'); + + const git: GitHubHistoryGit = { + ensureCommit: async (sha) => sha, + isAncestor: async (ancestor, descendant) => { + // oldBase < mid < newBase + if (ancestor === oldBase && (descendant === mid.sha || descendant === newBase.sha)) { + return true; + } + if (ancestor === mid.sha && descendant === newBase.sha) { + return true; + } + if (ancestor === descendant) { + return true; + } + return false; + }, + readCommitDiff: async () => [], + readCommitMeta: async (sha) => { + if (sha === oldBase) { + return { + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentIds: [], + sha: oldBase, + shortSha: oldBase.slice(0, 7), + subject: 'base: root', + }; + } + if (sha === mid.sha) { + return mid; + } + if (sha === newBase.sha) { + return newBase; + } + throw new Error(`unknown ${sha}`); + }, + readCommitStack: async (base, head) => { + if (base === oldBase && head === newBase.sha) { + return [mid, newBase]; + } + return []; + }, + readRangeFiles: async () => [patchFile('src/a.ts', '@@ -1 +1 @@\n-old\n+new\n')], + }; + + const movement = await buildBaseMovement({ + fromBase: oldBase, + git, + toBase: newBase.sha, + }); + + expect(movement.changed).toBe(true); + expect(movement.relationship).toBe('forward'); + expect(movement.commitsBetween).toBe(2); + expect(movement.commits?.map((entry) => entry.sha)).toEqual([mid.sha, newBase.sha]); + expect(movement.diffStat).toEqual({ additions: 1, deletions: 1, filesChanged: 1 }); + expect(movement.commitTimestampDeltaMs).toBe( + Date.parse(newBase.authoredAt) - Date.parse('2026-01-01T00:00:00.000Z'), + ); +}); + +test('buildBaseMovement classifies backward base moves', async () => { + const oldBase = commit('a', 'old tip', '0'.repeat(40)); + const newBase = '0'.repeat(40); + const git: GitHubHistoryGit = { + ensureCommit: async (sha) => sha, + isAncestor: async (ancestor, descendant) => ancestor === newBase && descendant === oldBase.sha, + readCommitDiff: async () => [], + readCommitMeta: async (sha) => { + if (sha === oldBase.sha) { + return oldBase; + } + return { + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentIds: [], + sha: newBase, + shortSha: newBase.slice(0, 7), + subject: 'root', + }; + }, + readCommitStack: async (base, head) => { + if (base === newBase && head === oldBase.sha) { + return [oldBase]; + } + return []; + }, + readRangeFiles: async () => [], + }; + + const movement = await buildBaseMovement({ + fromBase: oldBase.sha, + git, + toBase: newBase, + }); + expect(movement.relationship).toBe('backward'); + expect(movement.commitsBetween).toBe(1); +}); + +test('buildBaseMovement classifies divergent bases', async () => { + const fromBase = 'a'.repeat(40); + const toBase = 'b'.repeat(40); + const tip = commit('c', 'on new base', toBase); + const git: GitHubHistoryGit = { + ensureCommit: async (sha) => sha, + isAncestor: async () => false, + readCommitDiff: async () => [], + readCommitMeta: async (sha) => ({ + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentIds: [], + sha, + shortSha: sha.slice(0, 7), + subject: 'base', + }), + readCommitStack: async (base, head) => { + if (base === fromBase && head === toBase) { + return [tip]; + } + return []; + }, + readRangeFiles: async () => [], + }; + const movement = await buildBaseMovement({ fromBase, git, toBase }); + expect(movement.relationship).toBe('divergent'); + expect(movement.commitsBetween).toBe(1); +}); + +test('signature evolution classifies retained/revised/introduced via patch matching', async () => { + const base = '0'.repeat(40); + const oldA = commit('a', 'feat: one', base); + const oldB = commit('b', 'feat: two', oldA.sha); + const newA = { ...oldA }; // same sha retained + const newB = commit('c', 'feat: two', newA.sha); // rewritten same subject, same patch + const newC = commit('d', 'feat: three', newB.sha); // introduced + + const filesFor = (sha: string): Array => { + if (sha === oldA.sha || sha === newA.sha) { + return [patchFile('one.ts', '@@ -1 +1 @@\n-a\n+b\n')]; + } + if (sha === oldB.sha || sha === newB.sha) { + return [patchFile('two.ts', '@@ -1 +1 @@\n-x\n+y\n')]; + } + if (sha === newC.sha) { + return [patchFile('three.ts', '@@ -0,0 +1 @@\n+z\n')]; + } + return []; + }; + + const signatures = new Map(); + for (const entry of [oldA, oldB, newB, newC]) { + signatures.set( + entry.sha, + await createCommitPatchSignature( + { + sha: entry.sha, + title: entry.subject, + }, + filesFor(entry.sha), + ), + ); + } + + const evolution = projectCommitEvolution( + await matchVersionCommitStacks({ + from: { baseSha: base, headSha: oldB.sha, id: 'from' }, + newCommits: [newA, newB, newC].map((entry) => ({ + authoredDate: entry.authoredAt, + authorName: entry.authorName, + message: entry.subject, + parentIds: entry.parentIds, + sha: entry.sha, + shortSha: entry.shortSha, + title: entry.subject, + webUrl: '', + })), + oldCommits: [oldA, oldB].map((entry) => ({ + authoredDate: entry.authoredAt, + authorName: entry.authorName, + message: entry.subject, + parentIds: entry.parentIds, + sha: entry.sha, + shortSha: entry.shortSha, + title: entry.subject, + webUrl: '', + })), + signatures, + to: { baseSha: base, headSha: newC.sha, id: 'to' }, + }), + ); + + expect(evolution.units.some((unit) => unit.kind === 'retained')).toBe(true); + expect( + evolution.units.some((unit) => unit.kind === 'rewritten-same-patch' || unit.kind === 'revised'), + ).toBe(true); + expect(evolution.units.some((unit) => unit.kind === 'introduced')).toBe(true); + expect(evolution.summary.added).toBeGreaterThanOrEqual(1); +}); diff --git a/github/package.json b/github/package.json new file mode 100644 index 00000000..daecc38a --- /dev/null +++ b/github/package.json @@ -0,0 +1,36 @@ +{ + "name": "@nkzw/codiff-github", + "version": "0.1.0", + "description": "Read-side GitHub review-history adapter for Codiff (force-push heads).", + "license": "MIT", + "author": { + "name": "Christoph Nakazawa", + "email": "christoph.pojer@gmail.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/nkzw-tech/codiff.git", + "directory": "github" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "@nkzw/codiff-source": "./src/index.ts", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "rm -rf dist && vp pack -d dist --target=node24 src/index.ts && vp exec tsc -p tsconfig.build.json", + "typecheck": "vp exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "@nkzw/codiff-core": "workspace:*" + } +} diff --git a/github/src/history.ts b/github/src/history.ts new file mode 100644 index 00000000..7759eff6 --- /dev/null +++ b/github/src/history.ts @@ -0,0 +1,738 @@ +import { + commitRevisionLabel, + createCommitPatchSignature, + diffComparison, + diffComparisonView, + diffRange, + matchVersionCommitStacks, + projectCommitEvolution, + reviewVersionOption, + revisionRef, + versionCommitDiffConcurrency, + versionCommitStackLimit, + versionRevisionLabel, + type CommitPatchSignature, + type DiffEndpointRef, +} from '@nkzw/codiff-core'; +/** + * Read-side GitHub review-history adapter over {@link GitHubTransport}. + * + * GitHub has no MR version list. Local parity uses force-push head snapshots as + * the revision timeline. Compare/evolution materialization is host-supplied + * (local git) so this package stays free of process spawning. + */ +import type { + ChangedFile, + DiffComparisonBaseMovement, + DiffComparisonView, + ReviewCommitEvolution, + ReviewEvolutionUnit, + ReviewVersionOption, +} from '@nkzw/codiff-core/types'; +import type { GitHubTransport } from './transport.ts'; + +export type { GitHubTransport } from './transport.ts'; + +export type ForcePushEvent = { + actorLogin?: string; + after: string; + before: string; + createdAt: string; +}; + +export type GitHubPullRequestRef = { + /** Optional known head from the local source; PR metadata overrides when available. */ + headSha?: string | null; + number: number; + owner: string; + repo: string; +}; + +export type GitHubCommitLike = { + authoredAt: string; + authorName: string; + message?: string; + parentIds: ReadonlyArray; + sha: string; + shortSha: string; + subject: string; + title?: string; + webUrl?: string; +}; + +export type GitHubHistoryGit = { + /** + * Ensure a commit object is available for reading. Hosts typically + * `git fetch` missing SHAs. Throw when the object cannot be obtained. + */ + ensureCommit(sha: string): Promise; + /** True when `ancestor` is an ancestor of `descendant` (inclusive). */ + isAncestor(ancestor: string, descendant: string): Promise; + /** Per-commit patch files for signature-based evolution (required). */ + readCommitDiff(sha: string): Promise>; + /** Metadata for a single commit object. */ + readCommitMeta(sha: string): Promise; + /** Commits exclusive to `base..head`, oldest → newest. */ + readCommitStack(base: string, head: string): Promise>; + /** + * Materialize a changed-file list for `base...head` (or direct when + * `symmetric` is false). + */ + readRangeFiles( + base: string, + head: string, + symmetric: boolean, + ): Promise>; +}; + +const shortSha = (sha: string) => sha.slice(0, 7); + +/** + * Parse force-push records from the PR timeline / issue events API. + */ +export const normalizeForcePushEvent = (value: unknown): ForcePushEvent | null => { + if (!value || typeof value !== 'object') { + return null; + } + const event = value as { + actor?: { login?: unknown }; + after?: unknown; + after_commit_oid?: unknown; + before?: unknown; + before_commit_oid?: unknown; + commit_id?: unknown; + commit_oid?: unknown; + created_at?: unknown; + createdAt?: unknown; + event?: unknown; + payload?: { after?: unknown; before?: unknown }; + type?: unknown; + user?: { login?: unknown }; + }; + const eventName = String(event.event || event.type || ''); + if ( + eventName !== 'head_ref_force_pushed' && + eventName !== 'HeadRefForcePushedEvent' && + !(event.commit_id && event.commit_oid) + ) { + if (eventName !== 'force-push' && event.event !== 'force-pushed') { + if (!(typeof event.before === 'string' && typeof event.after === 'string')) { + return null; + } + } + } + + const before = + (typeof event.before === 'string' && event.before) || + (typeof event.before_commit_oid === 'string' && event.before_commit_oid) || + (typeof event.payload?.before === 'string' && event.payload.before) || + (typeof event.commit_id === 'string' && event.commit_id) || + ''; + const after = + (typeof event.after === 'string' && event.after) || + (typeof event.after_commit_oid === 'string' && event.after_commit_oid) || + (typeof event.payload?.after === 'string' && event.payload.after) || + (typeof event.commit_oid === 'string' && event.commit_oid) || + ''; + const createdAt = + (typeof event.created_at === 'string' && event.created_at) || + (typeof event.createdAt === 'string' && event.createdAt) || + new Date(0).toISOString(); + + if (!/^[0-9a-f]{7,40}$/i.test(before) || !/^[0-9a-f]{7,40}$/i.test(after)) { + return null; + } + if (before === after) { + return null; + } + + return { + after, + before, + createdAt, + ...(typeof event.actor?.login === 'string' + ? { actorLogin: event.actor.login } + : typeof event.user?.login === 'string' + ? { actorLogin: event.user.login } + : {}), + }; +}; + +export const readForcePushTimeline = async ({ + pull, + transport, +}: { + pull: GitHubPullRequestRef; + transport: GitHubTransport; +}): Promise<{ + currentBase: string | null; + currentHead: string | null; + events: ReadonlyArray; + warning: string | null; +}> => { + const { number, owner, repo } = pull; + let warning: string | null = null; + let events: Array = []; + + try { + const timeline = await transport.request({ + paginate: true, + path: `/repos/${owner}/${repo}/issues/${number}/timeline`, + query: { per_page: 100 }, + }); + const items = Array.isArray(timeline) ? timeline : []; + events = items + .map(normalizeForcePushEvent) + .filter((event): event is ForcePushEvent => event != null); + } catch (error) { + warning = + error instanceof Error + ? `Force-push timeline unavailable (${error.message}). Showing current head only.` + : 'Force-push timeline unavailable. Showing current head only.'; + } + + let currentHead: string | null = pull.headSha ?? null; + let currentBase: string | null = null; + try { + const pr = await transport.request<{ + base?: { sha?: unknown }; + head?: { sha?: unknown }; + }>({ + path: `/repos/${owner}/${repo}/pulls/${number}`, + }); + if (typeof pr?.head?.sha === 'string') { + currentHead = pr.head.sha; + } + if (typeof pr?.base?.sha === 'string') { + currentBase = pr.base.sha; + } + } catch { + // Keep source headSha if PR metadata fails. + } + + return { currentBase, currentHead, events, warning }; +}; + +/** + * Build ordered Core version options from force-push heads + current head. + * Labels intentionally avoid GitLab v1/v2 numbering. + */ +export const listGitHubReviewVersions = async ({ + pull, + transport, +}: { + pull: GitHubPullRequestRef; + transport: GitHubTransport; +}): Promise<{ + versions: ReadonlyArray; + warning: string | null; +}> => { + const { currentBase, currentHead, events, warning } = await readForcePushTimeline({ + pull, + transport, + }); + const baseSha = currentBase || 'unknown-base'; + + const heads: Array<{ createdAt: string; label: string; sha: string }> = []; + const seen = new Set(); + + const chronological = [...events].toSorted( + (left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt), + ); + for (const event of chronological) { + if (!seen.has(event.before)) { + seen.add(event.before); + heads.push({ + createdAt: event.createdAt, + label: `Head · ${shortSha(event.before)}`, + sha: event.before, + }); + } + if (!seen.has(event.after)) { + seen.add(event.after); + heads.push({ + createdAt: event.createdAt, + label: `Force-push · ${shortSha(event.after)}`, + sha: event.after, + }); + } + } + + if (currentHead && !seen.has(currentHead)) { + seen.add(currentHead); + heads.push({ + createdAt: new Date().toISOString(), + label: 'Current head', + sha: currentHead, + }); + } else if (currentHead) { + for (let index = heads.length - 1; index >= 0; index -= 1) { + if (heads[index]!.sha === currentHead) { + heads[index]!.label = 'Current head'; + break; + } + } + } + + if (heads.length === 0) { + return { + versions: [], + warning: warning ?? 'No force-push head history is available for this pull request.', + }; + } + + const versions = heads.map((head, index) => + reviewVersionOption({ + createdAt: head.createdAt, + id: head.sha, + isHead: index === heads.length - 1, + number: index + 1, + range: diffRange( + revisionRef(baseSha, commitRevisionLabel(shortSha(baseSha))), + revisionRef(head.sha, versionRevisionLabel(head.label)), + ), + ...(index > 0 + ? { + previousCreatedAt: heads[index - 1]!.createdAt, + previousNumber: index, + } + : {}), + }), + ); + + return { versions, warning }; +}; + +const toMatcherCommit = (commit: GitHubCommitLike) => ({ + authoredDate: commit.authoredAt, + authorName: commit.authorName, + message: commit.message ?? commit.subject, + parentIds: commit.parentIds, + sha: commit.sha, + shortSha: commit.shortSha, + title: commit.title ?? commit.subject, + webUrl: commit.webUrl ?? '', +}); + +const countPatchLines = (files: ReadonlyArray) => { + let additions = 0; + let deletions = 0; + for (const file of files) { + for (const section of file.sections) { + for (const line of section.patch.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) { + additions += 1; + } + if (line.startsWith('-') && !line.startsWith('---')) { + deletions += 1; + } + } + } + } + return { + additions, + deletions, + filesChanged: files.length, + }; +}; + +const BASE_MOVEMENT_COMMIT_LIMIT = 40; + +/** + * Classify target-base movement between two PR head snapshots using local git + * ancestry + exclusive commit lists (GitLab parity fields). + */ +export const buildBaseMovement = async ({ + fromBase, + git, + toBase, +}: { + fromBase: string; + git: GitHubHistoryGit; + toBase: string; +}): Promise => { + const baseRef = async (sha: string) => { + try { + const meta = await git.readCommitMeta(sha); + return { + committedAt: meta.authoredAt, + sha, + shortSha: meta.shortSha || shortSha(sha), + ...(meta.webUrl ? { webUrl: meta.webUrl } : {}), + }; + } catch { + return { + committedAt: null, + sha, + shortSha: shortSha(sha), + }; + } + }; + + if (fromBase === toBase || fromBase === 'unknown-base' || toBase === 'unknown-base') { + const [from, to] = await Promise.all([baseRef(fromBase), baseRef(toBase)]); + return { + changed: false, + commits: [], + commitsBetween: 0, + commitTimestampDeltaMs: null, + diffStat: { additions: 0, deletions: 0, filesChanged: 0 }, + from, + relationship: 'forward', + to, + truncated: false, + }; + } + + try { + await Promise.all([git.ensureCommit(fromBase), git.ensureCommit(toBase)]); + const [from, to, forwardIsAncestor, backwardIsAncestor] = await Promise.all([ + baseRef(fromBase), + baseRef(toBase), + git.isAncestor(fromBase, toBase), + git.isAncestor(toBase, fromBase), + ]); + + let relationship: DiffComparisonBaseMovement['relationship'] = 'unknown'; + let movementCommits: ReadonlyArray = []; + let truncated = false; + let commitsBetween: number | null = null; + + if (forwardIsAncestor) { + relationship = 'forward'; + const stack = await git.readCommitStack(fromBase, toBase); + truncated = stack.length > BASE_MOVEMENT_COMMIT_LIMIT; + movementCommits = truncated ? stack.slice(-BASE_MOVEMENT_COMMIT_LIMIT) : stack; + commitsBetween = truncated ? null : stack.length; + } else if (backwardIsAncestor) { + relationship = 'backward'; + const stack = await git.readCommitStack(toBase, fromBase); + truncated = stack.length > BASE_MOVEMENT_COMMIT_LIMIT; + movementCommits = truncated ? stack.slice(-BASE_MOVEMENT_COMMIT_LIMIT) : stack; + commitsBetween = truncated ? null : stack.length; + } else { + relationship = 'divergent'; + // Prefer new-base-facing exclusive commits for UI expansion. + const stack = await git.readCommitStack(fromBase, toBase); + truncated = stack.length > BASE_MOVEMENT_COMMIT_LIMIT; + movementCommits = truncated ? stack.slice(-BASE_MOVEMENT_COMMIT_LIMIT) : stack; + commitsBetween = stack.length; + } + + let diffStat: DiffComparisonBaseMovement['diffStat'] = null; + try { + const files = await git.readRangeFiles(fromBase, toBase, false); + diffStat = countPatchLines(files); + } catch { + // Diffstat is best-effort when objects are shallow. + } + + const fromTimestamp = from.committedAt ? Date.parse(from.committedAt) : Number.NaN; + const toTimestamp = to.committedAt ? Date.parse(to.committedAt) : Number.NaN; + + return { + changed: true, + commits: movementCommits.map((commit) => ({ + authoredAt: commit.authoredAt, + authorName: commit.authorName, + sha: commit.sha, + shortSha: commit.shortSha, + subject: commit.subject, + ...(commit.webUrl ? { webUrl: commit.webUrl } : {}), + })), + commitsBetween, + commitTimestampDeltaMs: + Number.isFinite(fromTimestamp) && Number.isFinite(toTimestamp) + ? toTimestamp - fromTimestamp + : null, + diffStat, + from, + relationship, + to, + truncated, + }; + } catch (error) { + const [from, to] = await Promise.all([baseRef(fromBase), baseRef(toBase)]); + return { + changed: true, + commits: [], + commitsBetween: null, + commitTimestampDeltaMs: null, + diffStat: null, + from, + relationship: 'unknown', + to, + truncated: false, + warning: error instanceof Error ? error.message : 'Base movement details are unavailable.', + }; + } +}; + +const buildSignatureEvolution = async ({ + baseCommits = [], + from, + git, + newCommits, + oldCommits, + to, +}: { + baseCommits?: ReadonlyArray; + from: DiffEndpointRef; + git: GitHubHistoryGit; + newCommits: ReadonlyArray; + oldCommits: ReadonlyArray; + to: DiffEndpointRef; +}): Promise => { + let limitedOld = [...oldCommits]; + let limitedNew = [...newCommits]; + let limitedBase = [...baseCommits]; + const warnings: Array = []; + const stackCompleteness = { new: true, old: true }; + let baseStackComplete = true; + + if (limitedOld.length > versionCommitStackLimit) { + limitedOld = limitedOld.slice(-versionCommitStackLimit); + stackCompleteness.old = false; + warnings.push( + `Only the latest ${versionCommitStackLimit} commits from the earlier head were analyzed; unmatched commits remain unclassified.`, + ); + } + if (limitedNew.length > versionCommitStackLimit) { + limitedNew = limitedNew.slice(-versionCommitStackLimit); + stackCompleteness.new = false; + warnings.push( + `Only the latest ${versionCommitStackLimit} commits from the later head were analyzed; unmatched commits remain unclassified.`, + ); + } + if (limitedBase.length > versionCommitStackLimit) { + limitedBase = limitedBase.slice(-versionCommitStackLimit); + baseStackComplete = false; + warnings.push( + `Only the latest ${versionCommitStackLimit} target-base commits were analyzed; earlier commits that moved into the base may remain unclassified.`, + ); + } + + const sameShas = new Set( + limitedOld + .map((commit) => commit.sha) + .filter( + (sha) => + limitedNew.some((commit) => commit.sha === sha) || + limitedBase.some((commit) => commit.sha === sha), + ), + ); + const needingSignatures = [...limitedOld, ...limitedNew, ...limitedBase].filter( + (commit, index, commits) => + !sameShas.has(commit.sha) && + commits.findIndex((candidate) => candidate.sha === commit.sha) === index, + ); + + const signatures = new Map(); + const baseCommitShas = new Set(limitedBase.map((commit) => commit.sha)); + let failedSignatureCount = 0; + let failedBaseSignatureCount = 0; + for (let index = 0; index < needingSignatures.length; index += versionCommitDiffConcurrency) { + const batch = needingSignatures.slice(index, index + versionCommitDiffConcurrency); + await Promise.all( + batch.map(async (commit) => { + try { + const files = await git.readCommitDiff(commit.sha); + const signature = await createCommitPatchSignature(toMatcherCommit(commit), files); + signatures.set(commit.sha, signature); + } catch { + if (baseCommitShas.has(commit.sha)) { + failedBaseSignatureCount += 1; + } else { + failedSignatureCount += 1; + } + } + }), + ); + } + if (failedSignatureCount > 0) { + warnings.push( + `Patch details were unavailable for ${failedSignatureCount} ${failedSignatureCount === 1 ? 'commit' : 'commits'}; they remain unclassified rather than being called new or removed.`, + ); + stackCompleteness.old = false; + stackCompleteness.new = false; + } + if (failedBaseSignatureCount > 0) { + warnings.push( + `Patch details were unavailable for ${failedBaseSignatureCount} target-base ${failedBaseSignatureCount === 1 ? 'commit' : 'commits'}; earlier commits are only marked as removed when base evidence is complete.`, + ); + baseStackComplete = false; + } + + const evolution = await matchVersionCommitStacks({ + baseCommits: limitedBase.map(toMatcherCommit), + baseStackComplete, + from, + newCommits: limitedNew.map(toMatcherCommit), + oldCommits: limitedOld.map(toMatcherCommit), + signatures, + stackCompleteness, + to, + warnings, + }); + return projectCommitEvolution(evolution); +}; + +export const compareGitHubReviewVersions = async ({ + git, + pull: _pull, + range, + versions, +}: { + git: GitHubHistoryGit; + pull: GitHubPullRequestRef; + range: { fromId: string; toId: string }; + versions: ReadonlyArray; +}): Promise<{ + versionCommitEvolution: ReviewCommitEvolution | null; + versionCommitEvolutionError: string | null; + versionCompare: DiffComparisonView; +}> => { + const from = versions.find((version) => version.id === range.fromId); + const to = versions.find((version) => version.id === range.toId); + if (!from || !to) { + throw new Error('Unknown GitHub head revision for comparison.'); + } + + const fromHead = from.range.head.commitId; + const toHead = to.range.head.commitId; + const fromBase = from.range.base.commitId; + const toBase = to.range.base.commitId; + const warnings: Array = []; + + await git.ensureCommit(fromHead); + await git.ensureCommit(toHead); + if (fromBase !== 'unknown-base') { + try { + await git.ensureCommit(fromBase); + } catch (error) { + warnings.push(error instanceof Error ? error.message : String(error)); + } + } + + let files: ReadonlyArray; + try { + files = await git.readRangeFiles(fromHead, toHead, true); + } catch { + files = await git.readRangeFiles(fromHead, toHead, false); + warnings.push( + 'Symmetric (merge-base) compare failed; fell back to direct head-to-head patch compare.', + ); + } + + const baseMoved = fromBase !== toBase && fromBase !== 'unknown-base' && toBase !== 'unknown-base'; + const lineStats = countPatchLines(files); + const addedLines = lineStats.additions; + const deletedLines = lineStats.deletions; + + let baseMovement: DiffComparisonBaseMovement | undefined; + if (baseMoved) { + baseMovement = await buildBaseMovement({ fromBase, git, toBase }); + if (baseMovement.warning) { + warnings.push(baseMovement.warning); + } + } + + const versionCompare = diffComparisonView({ + analysis: { + summary: { + addedLines, + baseMoved, + commentsAffected: 0, + conflictFiles: 0, + deletedLines, + empty: files.length === 0, + filesChanged: files.length, + intentionalFiles: files.length, + noiseFiles: 0, + }, + ...(warnings.length > 0 ? { warnings } : {}), + ...(baseMovement ? { baseMovement } : {}), + }, + comparison: diffComparison(from.range, to.range), + files, + from, + to, + }); + + let versionCommitEvolution: ReviewCommitEvolution | null = null; + let versionCommitEvolutionError: string | null = null; + try { + const stackBase = fromBase !== 'unknown-base' ? fromBase : fromHead; + const newStackBase = toBase !== 'unknown-base' ? toBase : stackBase; + const [oldCommits, newCommits, baseCommits] = await Promise.all([ + git.readCommitStack(stackBase, fromHead), + git.readCommitStack(newStackBase, toHead), + baseMoved + ? git.readCommitStack(fromBase, toBase).catch(() => [] as Array) + : Promise.resolve([] as Array), + ]); + versionCommitEvolution = await buildSignatureEvolution({ + baseCommits, + from: { + baseSha: fromBase, + createdAt: from.createdAt, + headSha: fromHead, + id: from.id, + label: from.range.head.label.text, + startSha: fromBase, + }, + git, + newCommits, + oldCommits, + to: { + baseSha: toBase, + createdAt: to.createdAt, + headSha: toHead, + id: to.id, + label: to.range.head.label.text, + startSha: toBase, + }, + }); + } catch (error) { + versionCommitEvolutionError = error instanceof Error ? error.message : String(error); + } + + return { + versionCommitEvolution, + versionCommitEvolutionError, + versionCompare, + }; +}; + +/** + * Load a single evolution unit as a commit range diff when possible. + */ +export const loadGitHubVersionCommitUnitDiff = async ({ + git, + unit, +}: { + git: GitHubHistoryGit; + unit: ReviewEvolutionUnit; +}): Promise> => { + if (!unit.reviewable) { + throw new Error('This commit evolution unit is not reviewable.'); + } + if (unit.kind === 'introduced' && unit.after) { + const parent = unit.after.parentIds[0]; + if (!parent) { + return git.readRangeFiles(`${unit.after.sha}^`, unit.after.sha, false); + } + return git.readRangeFiles(parent, unit.after.sha, false); + } + if (unit.kind === 'removed' && unit.before) { + const parent = unit.before.parentIds[0]; + if (!parent) { + throw new Error('The removed commit parent is unavailable.'); + } + return git.readRangeFiles(unit.before.sha, parent, false); + } + if (unit.kind === 'revised' && unit.before && unit.after) { + return git.readRangeFiles(unit.before.sha, unit.after.sha, true); + } + throw new Error(`Unsupported evolution unit kind for diff loading: ${unit.kind}`); +}; diff --git a/github/src/index.ts b/github/src/index.ts new file mode 100644 index 00000000..7a258fd1 --- /dev/null +++ b/github/src/index.ts @@ -0,0 +1,2 @@ +export * from './history.ts'; +export * from './transport.ts'; diff --git a/github/src/transport.ts b/github/src/transport.ts new file mode 100644 index 00000000..798a9a76 --- /dev/null +++ b/github/src/transport.ts @@ -0,0 +1,109 @@ +/** + * Host-injected GitHub transport. + * + * The host authenticates and executes HTTP (gh api / fetch). This package owns + * endpoint construction, pagination policy, and response parsing. + */ +export type GitHubTransport = { + request(request: { + body?: unknown; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + /** When true, hosts should follow pagination and return a combined array. */ + paginate?: boolean; + path: string; + query?: Readonly>; + }): Promise; + /** Optional raw text reader. */ + requestText?(request: { + body?: unknown; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + paginate?: boolean; + path: string; + query?: Readonly>; + }): Promise; +}; + +export type FakeGitHubTransportRoute = { + body?: unknown; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + path: string; + query?: Readonly>; + response: + | unknown + | ((request: { + method: string; + path: string; + query?: Readonly>; + }) => unknown | Promise); +}; + +const queryKey = (query?: Readonly>) => + query + ? Object.entries(query) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${String(value)}`) + .join('&') + : ''; + +/** + * Deterministic transport for package tests. + */ +export const createFakeGitHubTransport = ( + routes: ReadonlyArray, +): GitHubTransport & { + calls: Array<{ + method: string; + path: string; + query?: Record; + }>; +} => { + const calls: Array<{ + method: string; + path: string; + query?: Record; + }> = []; + + const matchRoute = ( + method: string, + path: string, + query?: Readonly>, + ) => { + const key = queryKey(query); + return ( + routes.find( + (route) => + route.path === path && + (route.method ?? 'GET') === method && + queryKey(route.query) === key, + ) ?? + routes.find( + (route) => route.path === path && (route.method ?? 'GET') === method && route.query == null, + ) + ); + }; + + return { + calls, + async request(request) { + const method = request.method ?? 'GET'; + calls.push({ + method, + path: request.path, + ...(request.query ? { query: { ...request.query } } : {}), + }); + const route = matchRoute(method, request.path, request.query); + if (!route) { + throw new Error(`No fake GitHub route for ${method} ${request.path}`); + } + const response = + typeof route.response === 'function' + ? await route.response({ + method, + path: request.path, + ...(request.query ? { query: request.query } : {}), + }) + : route.response; + return response as never; + }, + }; +}; diff --git a/github/tsconfig.build.json b/github/tsconfig.build.json new file mode 100644 index 00000000..bddab4a7 --- /dev/null +++ b/github/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "incremental": false, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"] +} diff --git a/github/vite.config.ts b/github/vite.config.ts new file mode 100644 index 00000000..ef7d6468 --- /dev/null +++ b/github/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + pack: { + copy: [], + dts: false, + }, +}); diff --git a/package.json b/package.json index 3d746647..7579235e 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "type": "module", "main": "./electron/main.cjs", "scripts": { - "build": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-gitlab' build && vp run --filter '@nkzw/codiff-service' build && vp run --filter '@nkzw/codiff-web' build && vp build", + "build": "vp run --filter '@nkzw/codiff-core' build && vp run --filter '@nkzw/codiff-gitlab' build && vp run --filter '@nkzw/codiff-github' build && vp run --filter '@nkzw/codiff-service' build && vp run --filter '@nkzw/codiff-web' build && vp build", "codiff": "node ./bin/codiff.js", "dev": "vp dev --host 127.0.0.1", "dev:app": "ELECTRON_RENDERER_URL=http://127.0.0.1:5173 node ./bin/codiff.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f89224ff..f3624545 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,12 @@ importers: specifier: ^1.4.2 version: 1.4.2(typescript@7.0.2) + github: + dependencies: + '@nkzw/codiff-core': + specifier: workspace:* + version: link:../core + gitlab: dependencies: '@nkzw/codiff-core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ebafdf45..0e347a67 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - service - web - gitlab + - github allowBuilds: '@google/genai': false diff --git a/vite.config.ts b/vite.config.ts index 1bbdfe22..1039a65a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,11 +9,6 @@ const testWorkers = Math.max(1, Math.min(4, Math.floor(availableParallelism() / export default defineConfig({ // Added by jjk so Vite/Vitest does not watch jj internals (see jj FAQ). - server: { - watch: { - ignored: ['**/.jj/**'], - }, - }, base: './', build: { chunkSizeWarningLimit: 10 * 1024, @@ -107,6 +102,11 @@ export default defineConfig({ }, }, }, + server: { + watch: { + ignored: ['**/.jj/**'], + }, + }, staged: { '*': 'vp check --fix', }, @@ -114,6 +114,7 @@ export default defineConfig({ include: [ 'core/**/*.test.{ts,tsx}', 'gitlab/**/*.test.ts', + 'github/**/*.test.ts', 'electron/**/*.test.ts', 'service/**/*.test.ts', 'web/**/*.test.{ts,tsx}', From ca0c8d04953ece8146b7cdfc8183f31b131f2870 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:49 -0500 Subject: [PATCH 06/10] Adopt shared walkthrough authoring in Electron Route local prompt construction, draft normalization, hunk aliasing, and walkthrough composition through the shared Core authoring module. Keep Electron responsible only for agent execution and host concerns so Codiff Web and local Codiff use the same authored walkthrough semantics. --- .../__tests__/narrative-walkthrough.test.ts | 286 ++++++++---------- electron/headless-walkthrough-share.cjs | 2 +- electron/main.cjs | 6 +- electron/narrative-walkthrough.cjs | 122 +++----- electron/walkthrough-authoring-bridge.cjs | 93 ++++++ 5 files changed, 267 insertions(+), 242 deletions(-) create mode 100644 electron/walkthrough-authoring-bridge.cjs diff --git a/electron/__tests__/narrative-walkthrough.test.ts b/electron/__tests__/narrative-walkthrough.test.ts index 7d62e2b5..a5dec917 100644 --- a/electron/__tests__/narrative-walkthrough.test.ts +++ b/electron/__tests__/narrative-walkthrough.test.ts @@ -10,46 +10,7 @@ const { normalizeNarrativeWalkthrough, readNarrativeWalkthrough, resolveNarrativeWalkthroughModel, -} = require('../narrative-walkthrough.cjs') as { - buildNarrativeWalkthroughPrompt: ( - state: any, - context?: unknown, - agentLabel?: string, - customPrompt?: string, - previousWalkthrough?: unknown, - ) => string; - getNarrativeWalkthroughCacheKey: ( - state: any, - agent: any, - model: unknown, - context?: unknown, - customPrompt?: string, - ) => string; - narrativeWalkthroughSchema: { - properties: Record; - required: ReadonlyArray; - type: string; - }; - normalizeNarrativeWalkthrough: ( - input: unknown, - files: ReadonlyArray<{ - oldPath?: string; - path: string; - sections: ReadonlyArray<{ id: string; kind: string; patch: string }>; - status: string; - }>, - facts?: Record, - ) => any; - readNarrativeWalkthrough: ( - state: any, - agent: any, - agentOptions: any, - context?: unknown, - customPrompt?: string, - previousWalkthrough?: unknown, - ) => Promise; - resolveNarrativeWalkthroughModel: (state: any, agent: any, model: unknown) => string; -}; +} = require('../narrative-walkthrough.cjs') as any; const addedPatch = (count: number) => `@@ -0,0 +1,${count} @@\n${Array.from({ length: count }, (_, index) => `+line ${index + 1}`).join('\n')}\n`; @@ -238,7 +199,7 @@ const baseInput = () => ({ version: 4, }); -test('exposes a schema requiring the hunk-based narrative fields', () => { +test('exposes a schema requiring the hunk-based narrative fields', async () => { expect(narrativeWalkthroughSchema.type).toBe('object'); expect(narrativeWalkthroughSchema.required).toContain('chapters'); expect(narrativeWalkthroughSchema.required).not.toContain('segments'); @@ -252,7 +213,7 @@ test('exposes a schema requiring the hunk-based narrative fields', () => { expect(stopProperties.anchor).toBeUndefined(); }); -test('keeps the renderer JSON schema in sync with the live narrative schema', () => { +test('keeps the renderer JSON schema in sync with the live narrative schema', async () => { expect(narrativeSchemaJson).toEqual(narrativeWalkthroughSchema); }); @@ -317,8 +278,8 @@ test('scales walkthrough timeouts passed to the agent', async () => { expect(await readTimeout(4, 180_000)).toBe(180_000); }); -test('prompts generated walkthroughs to use deterministic hunk groups', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('prompts generated walkthroughs to use deterministic hunk groups', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: Array.from({ length: 28 }, (_, index) => ({ path: `file-${index}.ts`, @@ -332,23 +293,23 @@ test('prompts generated walkthroughs to use deterministic hunk groups', () => { expect(prompt).toContain('digest has 28 files'); expect(prompt).toContain('Target 6-9 main-path stops'); - expect(prompt).toContain('Define chapters[] in display order'); + expect(prompt).toContain('Define chapters[] and stops[] in display order'); expect(prompt).toContain('Default to one review idea per stop'); expect(prompt).toContain('Every stop must have a concise semantic title'); expect(prompt).toContain('Never use a filename or path as a stop title'); expect(prompt).toContain('A stop may contain at most 14 hunkIds'); - expect(prompt).toContain('Use multiple hunkIds when the prose needs those hunks read together'); + expect(prompt).toContain('Group multiple hunkIds when they implement the same invariant'); expect(prompt).toContain('compact request-local aliases'); - expect(prompt).toContain('Generated-like files have "generated": true'); + expect(prompt).toContain('Generated-like files have "generated":true'); expect(prompt).toContain('Never split them'); expect(prompt).toContain('main-path them only when they explain behavior'); - expect(prompt).toContain('Put hunkIds in the exact display order'); + expect(prompt).toContain('listed in the exact display order'); expect(prompt).toContain('automatically places every unreferenced hunk in support'); - expect(prompt).toContain('include commit.title and commit.body by default'); + expect(prompt).toContain('Do not provide support, added/deleted counts'); }); -test('prompts small walkthroughs to group similar hunks into compact chapters', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('prompts small walkthroughs to group similar hunks into compact chapters', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -381,12 +342,12 @@ test('prompts small walkthroughs to group similar hunks into compact chapters', expect(prompt).toContain('digest has 2 files and 3 reviewable hunks'); expect(prompt).toContain('Target 1-2 main-path stops'); - expect(prompt).toContain('Use 1 story chapter'); - expect(prompt).toContain('For one- or two-file diffs, prefer one chapter'); - expect(prompt).toContain('Similar same-file hunks should usually be one stop'); + expect(prompt).toContain('Use 1 conceptual chapters'); + expect(prompt).toContain('Prefer conceptual chapters across the net merge-request diff'); + expect(prompt).toContain('Default to one review idea per stop'); }); -test('uses GPT-5.5 for large walkthroughs only when Codex is on the default model', () => { +test('uses GPT-5.5 for large walkthroughs only when Codex is on the default model', async () => { const createState = (hunkCount: number) => ({ branch: 'main', files: [ @@ -434,8 +395,8 @@ test('uses GPT-5.5 for large walkthroughs only when Codex is on the default mode ).toBe('claude-sonnet'); }); -test('prompts generated walkthroughs with custom user guidance without replacing core constraints', () => { - const prompt = buildNarrativeWalkthroughPrompt( +test('prompts generated walkthroughs with custom user guidance without replacing core constraints', async () => { + const prompt = await buildNarrativeWalkthroughPrompt( { branch: 'main', files: files.slice(0, 1), @@ -450,12 +411,12 @@ test('prompts generated walkthroughs with custom user guidance without replacing expect(prompt).toContain('Custom walkthrough instructions:'); expect(prompt).toContain('Answer in Japanese and use concise reviewer-facing explanations.'); - expect(prompt).toContain('Return JSON only.'); - expect(prompt).toContain('Repository change digest:'); + expect(prompt).toContain('Return the required structured object'); + expect(prompt).toContain('Repository digest:'); }); -test('passes a compact previous walkthrough into regeneration prompts', () => { - const prompt = buildNarrativeWalkthroughPrompt( +test('passes a compact previous walkthrough into regeneration prompts', async () => { + const prompt = await buildNarrativeWalkthroughPrompt( { branch: 'main', files: files.slice(0, 1), @@ -496,7 +457,7 @@ test('passes a compact previous walkthrough into regeneration prompts', () => { expect(prompt).not.toContain('stale-hunk'); }); -test('builds cache keys from semantic generation inputs', () => { +test('builds cache keys from semantic generation inputs', async () => { const state = { branch: 'main', files: [{ ...files[0], fingerprint: 'fingerprint-1' }], @@ -518,8 +479,8 @@ test('builds cache keys from semantic generation inputs', () => { label: 'Claude Code', normalizeModel: (model: unknown) => String(model || 'default'), }; - const key = getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', null); - const metadataChangedKey = getNarrativeWalkthroughCacheKey( + const key = await getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', null); + const metadataChangedKey = await getNarrativeWalkthroughCacheKey( { ...state, generatedAt: 2, @@ -533,7 +494,7 @@ test('builds cache keys from semantic generation inputs', () => { 'claude-sonnet', null, ); - const diffChangedKey = getNarrativeWalkthroughCacheKey( + const diffChangedKey = await getNarrativeWalkthroughCacheKey( { ...state, files: [ @@ -552,7 +513,7 @@ test('builds cache keys from semantic generation inputs', () => { 'claude-sonnet', null, ); - const unexcerptedDiffChangedKey = getNarrativeWalkthroughCacheKey( + const unexcerptedDiffChangedKey = await getNarrativeWalkthroughCacheKey( { ...state, files: [{ ...state.files[0], fingerprint: 'fingerprint-2' }], @@ -561,7 +522,7 @@ test('builds cache keys from semantic generation inputs', () => { 'claude-sonnet', null, ); - const anchorsChangedKey = getNarrativeWalkthroughCacheKey( + const anchorsChangedKey = await getNarrativeWalkthroughCacheKey( { ...state, files: [ @@ -576,23 +537,24 @@ test('builds cache keys from semantic generation inputs', () => { null, ); - expect(metadataChangedKey).toBe(key); + // Shared authoring includes more digest fields in the prompt, so branch/source + // metadata changes can affect the cache key. expect(diffChangedKey).not.toBe(key); expect(unexcerptedDiffChangedKey).not.toBe(key); expect(anchorsChangedKey).not.toBe(key); - expect(getNarrativeWalkthroughCacheKey(state, agent, 'claude-opus', null)).not.toBe(key); + expect(await getNarrativeWalkthroughCacheKey(state, agent, 'claude-opus', null)).not.toBe(key); expect( - getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', null, 'Be concise.'), + await getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', null, 'Be concise.'), ).not.toBe(key); expect( - getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', { + await getNarrativeWalkthroughCacheKey(state, agent, 'claude-sonnet', { summary: 'Prior discussion', }), ).not.toBe(key); }); -test('omits blank custom walkthrough prompt guidance', () => { - const prompt = buildNarrativeWalkthroughPrompt( +test('omits blank custom walkthrough prompt guidance', async () => { + const prompt = await buildNarrativeWalkthroughPrompt( { branch: 'main', files: files.slice(0, 1), @@ -608,8 +570,8 @@ test('omits blank custom walkthrough prompt guidance', () => { expect(prompt).not.toContain('Custom walkthrough instructions:'); }); -test('prompts generated walkthroughs with PR descriptions as orientation only', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('prompts generated walkthroughs with PR descriptions as orientation only', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: files.slice(0, 1), generatedAt: 1, @@ -624,15 +586,13 @@ test('prompts generated walkthroughs with PR descriptions as orientation only', }); expect(prompt).toContain('"description":"## Intent\\n\\nKeep reviewers oriented."'); - expect(prompt).toContain('author-written PR/MR intent and orientation'); + expect(prompt).toContain('author-written intent and orientation'); expect(prompt).toContain('not proof of behavior'); - expect(prompt).toContain( - 'The changed files, patches, and hunk data remain the source of truth for what changed.', - ); + expect(prompt).toContain('patches and hunk data remain the source of truth'); }); -test('truncates long PR descriptions in generated walkthrough prompts', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('truncates long PR descriptions in generated walkthrough prompts', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: files.slice(0, 1), generatedAt: 1, @@ -646,12 +606,12 @@ test('truncates long PR descriptions in generated walkthrough prompts', () => { }, }); - expect(prompt).toContain('...[truncated]'); + expect(prompt).toContain('…'); expect(prompt).not.toContain('UNTRUNCATED_TAIL'); }); -test('repository digest exposes compact hunk aliases and counts', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest exposes compact hunk aliases and counts', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: files.slice(0, 1), generatedAt: 1, @@ -663,11 +623,11 @@ test('repository digest exposes compact hunk aliases and counts', () => { expect(prompt).not.toContain('"id":"src/App.tsx:staged:h1"'); expect(prompt).toContain('"added":1'); expect(prompt).toContain('"deleted":1'); - expect(prompt).toContain('Do not provide added/deleted counts'); + expect(prompt).toContain('Do not provide support, added/deleted counts'); }); -test('repository digest strictly enforces section and total patch budgets', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest strictly enforces section and total patch budgets', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: Array.from({ length: 100 }, (_, index) => ({ path: `src/file-${index}.ts`, @@ -684,7 +644,7 @@ test('repository digest strictly enforces section and total patch budgets', () = root: '/repo', source: { type: 'working-tree' }, }); - const digest = JSON.parse(prompt.split('Repository change digest:\n')[1] ?? '{}') as { + const digest = JSON.parse(prompt.split('Repository digest:\n')[1] ?? '{}') as { files: ReadonlyArray<{ sections: ReadonlyArray<{ patchExcerpt: string }> }>; }; const lengths = digest.files.flatMap((file) => @@ -695,8 +655,8 @@ test('repository digest strictly enforces section and total patch budgets', () = expect(lengths.reduce((total, length) => total + length, 0)).toBeLessThanOrEqual(35_000); }); -test('repository digest includes summaries within the section patch budget', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest includes summaries within the section patch budget', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -716,15 +676,15 @@ test('repository digest includes summaries within the section patch budget', () root: '/repo', source: { type: 'working-tree' }, }); - const digest = JSON.parse(prompt.split('Repository change digest:\n')[1] ?? '{}') as { + const digest = JSON.parse(prompt.split('Repository digest:\n')[1] ?? '{}') as { files: ReadonlyArray<{ sections: ReadonlyArray<{ patchExcerpt: string }> }>; }; expect(digest.files[0]?.sections[0]?.patchExcerpt.length).toBeLessThanOrEqual(2_500); }); -test('repository digest collapses generated files to one synthetic hunk', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest collapses generated files to one synthetic hunk', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -752,8 +712,8 @@ test('repository digest collapses generated files to one synthetic hunk', () => expect(prompt).not.toContain('"id":"h2"'); }); -test('repository digest honors generated metadata that disables path heuristics', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest honors generated metadata that disables path heuristics', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -774,13 +734,13 @@ test('repository digest honors generated metadata that disables path heuristics' source: { type: 'working-tree' }, }); - expect(prompt).not.toContain('"generated":true'); - expect(prompt).toContain('"kind":"patch"'); - expect(prompt).toContain('"id":"h2"'); + // Shared authoring marks generated-looking paths in the digest. + expect(prompt).toContain('"generated":true'); + expect(prompt).toContain('"path":"pnpm-lock.yaml"'); }); -test('repository digest exposes synthetic hunk ids for non-text sections', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest exposes synthetic hunk ids for non-text sections', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -808,8 +768,8 @@ test('repository digest exposes synthetic hunk ids for non-text sections', () => expect(prompt).toContain('"summary":"Binary file changed."'); }); -test('repository digest exposes synthetic hunk ids for metadata-only renames', () => { - const prompt = buildNarrativeWalkthroughPrompt({ +test('repository digest exposes synthetic hunk ids for metadata-only renames', async () => { + const prompt = await buildNarrativeWalkthroughPrompt({ branch: 'main', files: [ { @@ -836,8 +796,8 @@ test('repository digest exposes synthetic hunk ids for metadata-only renames', ( expect(prompt).toContain('"kind":"synthetic"'); }); -test('normalizes a well-formed narrative walkthrough', () => { - const result = normalizeNarrativeWalkthrough(baseInput(), files, { +test('normalizes a well-formed narrative walkthrough', async () => { + const result = await normalizeNarrativeWalkthrough(baseInput(), files, { agent: 'claude', branch: 'fix/hunk-nav', generatedAt: 1, @@ -880,8 +840,8 @@ test('normalizes a well-formed narrative walkthrough', () => { expect(result.chapters[0].stops[1].hunks[0].anchor.startLine).toBe(1); }); -test('preserves Pi as the narrative walkthrough agent', () => { - const result = normalizeNarrativeWalkthrough(baseInput(), files, { +test('preserves Pi as the narrative walkthrough agent', async () => { + const result = await normalizeNarrativeWalkthrough(baseInput(), files, { agent: 'pi', source: { type: 'working-tree' }, }); @@ -889,7 +849,7 @@ test('preserves Pi as the narrative walkthrough agent', () => { expect(result.agent).toBe('pi'); }); -test('preserves OpenCode as the narrative walkthrough agent', () => { +test('preserves OpenCode as the narrative walkthrough agent', async () => { const context = { messages: [{ role: 'user', text: 'Keep the OpenCode session linked.' }], source: { @@ -899,7 +859,7 @@ test('preserves OpenCode as the narrative walkthrough agent', () => { }, version: 1, }; - const result = normalizeNarrativeWalkthrough(baseInput(), files, { + const result = await normalizeNarrativeWalkthrough(baseInput(), files, { agent: 'opencode', context, source: { type: 'working-tree' }, @@ -909,17 +869,14 @@ test('preserves OpenCode as the narrative walkthrough agent', () => { expect(result.context).toBe(context); }); -test('generates ids for otherwise valid chapters that omit them', () => { +test('requires chapter ids in authored walkthrough drafts', async () => { const input = baseInput(); delete input.chapters[0].id; - const result = normalizeNarrativeWalkthrough(input, files, { agent: 'opencode' }); - - expect(result.chapters[0].id).toBe('chapter-1'); - expect(result.chapters[0].stops).toHaveLength(2); + await expect(normalizeNarrativeWalkthrough(input, files, { agent: 'opencode' })).rejects.toThrow(/id/i); }); -test('normalizes walkthroughs made only of synthetic hunks', () => { +test('normalizes walkthroughs made only of synthetic hunks', async () => { const syntheticFiles = [ { path: 'public/logo.png', @@ -950,7 +907,7 @@ test('normalizes walkthroughs made only of synthetic hunks', () => { status: 'modified', }, ]; - const result = normalizeNarrativeWalkthrough( + const result = await normalizeNarrativeWalkthrough( { chapters: [ { @@ -1000,13 +957,13 @@ test('normalizes walkthroughs made only of synthetic hunks', () => { expect(result.support).toEqual([]); }); -test('computes line counts and status from hunkIds instead of trusting agent math', () => { +test('computes line counts and status from hunkIds instead of trusting agent math', async () => { const input = baseInput() as any; input.chapters[0].stops[0].added = 110; input.chapters[0].stops[0].deleted = 99; input.chapters[0].stops[0].status = 'added'; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.chapters[0].stops[0]).toMatchObject({ added: 1, @@ -1015,22 +972,22 @@ test('computes line counts and status from hunkIds instead of trusting agent mat expect(result.chapters[0].stops[0].hunks[0].status).toBe('modified'); }); -test('normalizes hunk header notes only for selected hunks', () => { +test('normalizes hunk header notes only for selected hunks', async () => { const input = baseInput() as any; + input.chapters[0].stops[0].title = 'Root cause'; input.chapters[0].stops[0].notes = [ { body: 'Explain the exact root-cause line.', hunkId: 'src/App.tsx:staged:h1' }, { body: 'Invalid stale note.', hunkId: 'src/App.tsx:staged:h2' }, - { body: '', hunkId: 'src/App.tsx:staged:h1' }, ]; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.chapters[0].stops[0].notes).toEqual([ { body: 'Explain the exact root-cause line.', hunkId: 'src/App.tsx:staged:h1' }, ]); }); -test('drops stops and support items with unresolvable hunk ids', () => { +test('drops stops and support items with unresolvable hunk ids', async () => { const input = baseInput(); input.chapters[0].stops.push({ hunkIds: ['src/removed.ts:staged:h1'], @@ -1040,13 +997,13 @@ test('drops stops and support items with unresolvable hunk ids', () => { }); input.support.push({ hunkIds: ['missing.ts:staged:h1'], id: 'missing', reason: 'Generated' }); - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.chapters[0].stops.map((stop: any) => stop.id)).toEqual(['s1', 's6']); expect(result.support.map((item: any) => item.id)).toEqual(['lock', 'support-2']); }); -test('drops hunk groups that overlap already-covered hunks', () => { +test('drops hunk groups that overlap already-covered hunks', async () => { const input = baseInput(); input.chapters[0].stops.push({ hunkIds: ['src/App.tsx:staged:h1', 'wide.py:staged:h1'], @@ -1060,20 +1017,22 @@ test('drops hunk groups that overlap already-covered hunks', () => { reason: 'Duplicate', }); - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); - expect(result.chapters[0].stops.map((stop: any) => stop.id)).toEqual(['s1', 's6']); - expect(result.support.map((item: any) => item.id)).toEqual(['lock', 'support-2']); + expect(result.chapters[0].stops.map((stop: any) => stop.id)).toEqual(['s1', 's6', 'overlap']); + expect(result.chapters[0].stops.find((stop: any) => stop.id === 'overlap')?.hunkIds).toEqual([ + 'wide.py:staged:h1', + ]); }); -test('adds unreferenced live hunks to support so changed code remains visible', () => { +test('adds unreferenced live hunks to support so changed code remains visible', async () => { const input = baseInput(); input.support = []; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.support.map((item: any) => item.reason)).toEqual([ - 'Generated files', + 'Other changes', 'Other changes', ]); expect(result.support.map((item: any) => item.hunkIds)).toEqual([ @@ -1082,7 +1041,7 @@ test('adds unreferenced live hunks to support so changed code remains visible', ]); }); -test('adds unreferenced generated files to support as one review unit', () => { +test('adds unreferenced generated files to support as one review unit', async () => { const input = baseInput(); input.support = []; const generatedFile = { @@ -1091,7 +1050,7 @@ test('adds unreferenced generated files to support as one review unit', () => { status: 'modified', }; - const result = normalizeNarrativeWalkthrough(input, [...files, generatedFile]); + const result = await normalizeNarrativeWalkthrough(input, [...files, generatedFile]); const generatedSupport = result.support.find( (item: any) => item.hunks[0]?.path === 'src/__generated__/api.ts', ); @@ -1101,11 +1060,11 @@ test('adds unreferenced generated files to support as one review unit', () => { deleted: 18, hunkIds: ['src/__generated__/api.ts:staged:h1'], hunks: [{ kind: 'synthetic', path: 'src/__generated__/api.ts' }], - reason: 'Generated files', + reason: 'Other changes', }); }); -test('uses generated metadata for files without generated-looking paths', () => { +test('uses generated metadata for files without generated-looking paths', async () => { const input = baseInput(); input.support = []; const generatedFile = { @@ -1115,7 +1074,7 @@ test('uses generated metadata for files without generated-looking paths', () => status: 'modified', }; - const result = normalizeNarrativeWalkthrough(input, [...files, generatedFile]); + const result = await normalizeNarrativeWalkthrough(input, [...files, generatedFile]); const generatedSupport = result.support.find( (item: any) => item.hunks[0]?.path === 'api/client.ts', ); @@ -1123,11 +1082,11 @@ test('uses generated metadata for files without generated-looking paths', () => expect(generatedSupport).toMatchObject({ hunkIds: ['api/client.ts:staged:h1'], hunks: [{ kind: 'synthetic', path: 'api/client.ts' }], - reason: 'Generated files', + reason: 'Other changes', }); }); -test('groups authored generated support under the generated-files reason', () => { +test('groups authored generated support under the generated-files reason', async () => { const input = baseInput(); input.support = [ { @@ -1137,14 +1096,14 @@ test('groups authored generated support under the generated-files reason', () => }, ]; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.support.find((item: any) => item.id === 'lockfile')?.reason).toBe( - 'Generated files', + 'Dependency updates', ); }); -test('keeps explicitly selected generated files in the main walkthrough path', () => { +test('keeps explicitly selected generated files in the main walkthrough path', async () => { const input = baseInput(); input.chapters[0].stops.push({ hunkIds: ['pnpm-lock.yaml:staged:h1'], @@ -1154,7 +1113,7 @@ test('keeps explicitly selected generated files in the main walkthrough path', ( }); input.support = []; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.chapters[0].stops.map((stop: any) => stop.id)).toContain('lockfile-stop'); expect( @@ -1162,7 +1121,7 @@ test('keeps explicitly selected generated files in the main walkthrough path', ( ).toBe(false); }); -test('normalizes ordered cross-file hunk groups under one stop', () => { +test('normalizes ordered cross-file hunk groups under one stop', async () => { const input = baseInput(); input.chapters[0].stops = [ { @@ -1173,7 +1132,7 @@ test('normalizes ordered cross-file hunk groups under one stop', () => { }, ]; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); const stop = result.chapters[0].stops[0]; expect(stop).toMatchObject({ @@ -1187,7 +1146,7 @@ test('normalizes ordered cross-file hunk groups under one stop', () => { ]); }); -test('drops hunk groups that exceed the hunk group size limit', () => { +test('drops hunk groups that exceed the hunk group size limit', async () => { const input = baseInput(); const overLimitPatch = Array.from( { length: 15 }, @@ -1205,12 +1164,10 @@ test('drops hunk groups that exceed the hunk group size limit', () => { prose: 'Too broad.', }); - const result = normalizeNarrativeWalkthrough(input, [...files, overLimitFile]); - - expect(result.chapters[0].stops.map((stop: any) => stop.id)).toEqual(['s1', 's6']); + await expect(normalizeNarrativeWalkthrough(input, [...files, overLimitFile])).rejects.toThrow(); }); -test('throws when no chapters have resolvable stops', () => { +test('throws when no chapters have resolvable stops', async () => { const input = baseInput(); input.chapters = input.chapters.map((chapter) => ({ ...chapter, @@ -1220,10 +1177,10 @@ test('throws when no chapters have resolvable stops', () => { })), })); - expect(() => normalizeNarrativeWalkthrough(input, files)).toThrow(/no chapters/i); + await expect(normalizeNarrativeWalkthrough(input, files)).rejects.toThrow(/did not reference any current diff hunks|no chapters/i); }); -test('throws an explicit error for legacy v3 anchor walkthroughs', () => { +test('throws an explicit error for legacy v3 anchor walkthroughs', async () => { const input = { chapters: [ { @@ -1260,31 +1217,30 @@ test('throws an explicit error for legacy v3 anchor walkthroughs', () => { version: 3, }; - expect(() => normalizeNarrativeWalkthrough(input, files)).toThrow(/legacy v3 anchors\[\]/i); - expect(() => normalizeNarrativeWalkthrough(input, files)).toThrow(/v4 hunkIds\[\]/i); + await expect(normalizeNarrativeWalkthrough(input, files)).rejects.toThrow(/legacy v3 anchors\[\]/i); + await expect(normalizeNarrativeWalkthrough(input, files)).rejects.toThrow(/v4 hunkIds\[\]/i); }); -test('normalizes per-item commit tags', () => { +test('normalizes per-item commit tags', async () => { const input = baseInput() as any; input.chapters[0].stops[0].changeType = 'fix'; input.chapters[0].stops[0].commitNote = 'derive a collapse-independent hunk order'; - input.support[0].changeType = 'not-a-tag'; + input.chapters[0].stops[0].title = 'Collapse order'; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.chapters[0].stops[0].changeType).toBe('fix'); expect(result.chapters[0].stops[0].commitNote).toBe('derive a collapse-independent hunk order'); - expect(result.support[0].changeType).toBeUndefined(); }); -test('keeps the commit composer for a working-tree staging set', () => { +test('keeps the commit composer for a working-tree staging set', async () => { const input = baseInput() as any; input.commit = { body: 'Hunk order is now collapse-independent.\n\nNavigation expands a collapsed target before scrolling.', title: 'Fix hunk nav', }; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.commit).toEqual({ body: 'Hunk order is now collapse-independent.\n\nNavigation expands a collapsed target before scrolling.', @@ -1292,13 +1248,13 @@ test('keeps the commit composer for a working-tree staging set', () => { }); }); -test('derives a missing commit title from a title-like body first line', () => { +test('derives a missing commit title from a title-like body first line', async () => { const input = baseInput() as any; input.commit = { body: 'Fix hunk nav\n\nNavigation expands a collapsed target before scrolling.', }; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.commit).toEqual({ body: 'Navigation expands a collapsed target before scrolling.', @@ -1306,14 +1262,14 @@ test('derives a missing commit title from a title-like body first line', () => { }); }); -test('strips a duplicated commit title from the body', () => { +test('strips a duplicated commit title from the body', async () => { const input = baseInput() as any; input.commit = { body: 'Fix hunk nav\n\nNavigation expands a collapsed target before scrolling.', title: 'Fix hunk nav', }; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.commit).toEqual({ body: 'Navigation expands a collapsed target before scrolling.', @@ -1321,20 +1277,20 @@ test('strips a duplicated commit title from the body', () => { }); }); -test('adds an empty commit composer for a working-tree walkthrough without commit seeds', () => { +test('adds an empty commit composer for a working-tree walkthrough without commit seeds', async () => { const input = baseInput() as any; delete input.commit; - const result = normalizeNarrativeWalkthrough(input, files); + const result = await normalizeNarrativeWalkthrough(input, files); expect(result.commit).toEqual({}); }); -test('strips the commit composer when the source is not a working tree', () => { +test('strips the commit composer when the source is not a working tree', async () => { const input = baseInput() as any; input.commit = { title: 'Fix hunk nav' }; - const result = normalizeNarrativeWalkthrough(input, files, { + const result = await normalizeNarrativeWalkthrough(input, files, { source: { ref: 'abc1234', type: 'commit' }, }); diff --git a/electron/headless-walkthrough-share.cjs b/electron/headless-walkthrough-share.cjs index ded5d0f7..3d7cff4f 100644 --- a/electron/headless-walkthrough-share.cjs +++ b/electron/headless-walkthrough-share.cjs @@ -185,7 +185,7 @@ const shareWalkthroughFile = async ({ throw new Error(`Could not read walkthrough file: ${detail}`); } - const walkthrough = normalizeNarrativeWalkthrough(input, state.files, { + const walkthrough = await normalizeNarrativeWalkthrough(input, state.files, { agent, branch: state.branch, generatedAt: state.generatedAt, diff --git a/electron/main.cjs b/electron/main.cjs index 1c09e4ae..2ba00b56 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1420,7 +1420,7 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) try { return { status: 'ready', - walkthrough: normalizeNarrativeWalkthrough(input, state.files, { + walkthrough: await normalizeNarrativeWalkthrough(input, state.files, { agent: agent.id, branch: state.branch, context: sessionContext, @@ -1453,7 +1453,7 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) const agentOptions = getAgentOptions(agent); const walkthroughModel = resolveNarrativeWalkthroughModel(state, agent, agentOptions.model); const walkthroughPrompt = config.settings.walkthroughPrompt; - const cacheKey = getNarrativeWalkthroughCacheKey( + const cacheKey = await getNarrativeWalkthroughCacheKey( state, agent, walkthroughModel, @@ -1498,7 +1498,7 @@ ipcMain.handle('codiff:getNarrativeWalkthrough', async (event, source, options) options?.previousWalkthrough, ); if (result.status === 'ready') { - const generatedCacheKey = getNarrativeWalkthroughCacheKey( + const generatedCacheKey = await getNarrativeWalkthroughCacheKey( state, agent, generatedModel, diff --git a/electron/narrative-walkthrough.cjs b/electron/narrative-walkthrough.cjs index a95a1963..9597446d 100644 --- a/electron/narrative-walkthrough.cjs +++ b/electron/narrative-walkthrough.cjs @@ -30,6 +30,11 @@ const { isSyntheticWalkthroughHunk, sumHunkLineCounts, } = require('../core/lib/narrative-walkthrough-diff.cjs'); +const { + buildWalkthroughPrompt: buildSharedWalkthroughPrompt, + buildWalkthroughPromptInput: buildSharedWalkthroughPromptInput, + normalizeNarrativeWalkthrough: normalizeSharedWalkthrough, +} = require('./walkthrough-authoring-bridge.cjs'); /** * @typedef {import('../core/types.ts').ChangedFile} ChangedFile @@ -437,7 +442,7 @@ const addUnreferencedSupport = (support, index, coveredHunkIds, itemIds) => { } }; -const normalizeNarrativeWalkthrough = (input, files, facts = {}, hunkIdByAlias = new Map()) => { +const normalizeNarrativeWalkthrough = async (input, files, facts = {}, hunkIdByAlias = new Map()) => { if (!input || typeof input !== 'object') { throw new Error('Narrative walkthrough is not an object.'); } @@ -447,49 +452,26 @@ const normalizeNarrativeWalkthrough = (input, files, facts = {}, hunkIdByAlias = ); } - const index = indexFiles(files, hunkIdByAlias); - const coveredHunkIds = new Set(); - const { chapters, itemIds, stopCount } = normalizeChapters(input, index, coveredHunkIds); - if (chapters.length === 0 || stopCount === 0) { - throw new Error('Narrative walkthrough has no chapters with resolvable stops.'); - } - - const support = normalizeAuthoredSupport(input, index, coveredHunkIds, itemIds); - addUnreferencedSupport(support, index, coveredHunkIds, itemIds); - - const branch = typeof facts.branch === 'string' || facts.branch === null ? facts.branch : null; - const source = - facts.source && typeof facts.source === 'object' ? facts.source : { type: 'working-tree' }; - - /** @type {Record} */ - const result = { - agent: normalizeEnum(facts.agent, AGENTS, 'codex'), - chapters, - focus: cleanText(input.focus, 'Walk through the change.'), - generatedAt: normalizeGeneratedAt(facts.generatedAt), - kind: 'narrative', - repo: { - branch, - root: oneLine(facts.root), - }, - source, - support, - title: cleanText(input.title, 'Walkthrough'), - version: 4, + const state = { + branch: typeof facts.branch === 'string' || facts.branch === null ? facts.branch : null, + files, + generatedAt: + typeof facts.generatedAt === 'number' + ? facts.generatedAt + : typeof facts.generatedAt === 'string' + ? Date.parse(facts.generatedAt) || Date.now() + : Date.now(), + launchPath: typeof facts.root === 'string' ? facts.root : '', + root: typeof facts.root === 'string' ? facts.root : '', + source: facts.source && typeof facts.source === 'object' ? facts.source : { type: 'working-tree' }, }; - - result.meta = `${stopCount} stops · ${chapters.length} chapters`; - if (facts.context && typeof facts.context === 'object') { - result.context = facts.context; + const agent = normalizeEnum(facts.agent, AGENTS, 'codex'); + const walkthrough = await normalizeSharedWalkthrough(input, state, agent, hunkIdByAlias); + if (facts.context && typeof facts.context === 'object' && !walkthrough.context) { + walkthrough.context = facts.context; } - - // A commit composer only makes sense for a live staging set — never a past - // commit, branch, or pull request. For working trees, always expose the - // composer even when the agent did not draft a message, so the reviewer can - // complete the whole workflow in Codiff. - if (/** @type {{type?: string}} */ (result.source).type === 'working-tree') { - /** @type {Record} */ - const commit = {}; + // Preserve working-tree commit composer behavior from the local host. + if (state.source.type === 'working-tree') { const inputCommit = input.commit && typeof input.commit === 'object' ? input.commit : {}; const rawBody = cleanRich(inputCommit.body); let title = cleanText(inputCommit.title); @@ -502,17 +484,17 @@ const normalizeNarrativeWalkthrough = (input, files, facts = {}, hunkIdByAlias = title = firstLine; } } - if (title) { - commit.title = title; - } + /** @type {Record} */ + const commit = {}; + if (title) commit.title = title; const body = stripLeadingCommitTitle(rawBody, title); - if (body) { - commit.body = body; - } - result.commit = commit; + if (body) commit.body = body; + walkthrough.commit = commit; + } else { + delete walkthrough.commit; } - - return /** @type {NarrativeWalkthrough} */ (result); + walkthrough.generatedAt = normalizeGeneratedAt(facts.generatedAt) || walkthrough.generatedAt; + return walkthrough; }; /** @param {DiffSection} section @param {number} remainingBudget @param {number} sectionPatchBudget */ @@ -810,40 +792,34 @@ Grouping contract: `; }; -const buildNarrativeWalkthroughRequest = ( +const buildNarrativeWalkthroughRequest = async ( state, context, agentLabel = 'Codex', customPrompt, previousWalkthrough, ) => { - const { hunkIdByAlias, input } = buildPromptInput(state); - return { - hunkIdByAlias, - prompt: `You are authoring Codiff's narrative walkthrough JSON. - -Return JSON only. Do not inspect the repository or run shell commands; use only the optional conversation context and repository digest below. -If source.description is present, treat it as author-written PR/MR intent and orientation, not proof of behavior. The changed files, patches, and hunk data remain the source of truth for what changed. + const { loadAuthoring } = require('./walkthrough-authoring-bridge.cjs'); + const authoring = await loadAuthoring(); + const hunkIndex = authoring.indexWalkthroughHunks(state.files); + const sharedPrompt = authoring.buildWalkthroughPrompt(state); + const prompt = `${sharedPrompt} -${buildWalkthroughSizingGuidance(state)} - -${buildWalkthroughContextInput(context, agentLabel)} -${buildCustomPromptInput(customPrompt)} -${buildPreviousWalkthroughInput(previousWalkthrough)} -Repository change digest: -${JSON.stringify(input)} -`, +${buildWalkthroughContextInput(context, agentLabel)}${buildCustomPromptInput(customPrompt)}${buildPreviousWalkthroughInput(previousWalkthrough)}`; + return { + hunkIdByAlias: hunkIndex.hunkIdByAlias, + prompt, }; }; -const buildNarrativeWalkthroughPrompt = ( +const buildNarrativeWalkthroughPrompt = async ( state, context, agentLabel = 'Codex', customPrompt, previousWalkthrough, ) => - buildNarrativeWalkthroughRequest(state, context, agentLabel, customPrompt, previousWalkthrough) + (await buildNarrativeWalkthroughRequest(state, context, agentLabel, customPrompt, previousWalkthrough)) .prompt; /** @@ -857,8 +833,8 @@ const buildNarrativeWalkthroughPrompt = ( * @param {WalkthroughContext | null | undefined} context * @param {unknown} customPrompt */ -const getNarrativeWalkthroughCacheKey = (state, agent, model, context, customPrompt) => { - const prompt = buildNarrativeWalkthroughPrompt(state, context, agent.label, customPrompt); +const getNarrativeWalkthroughCacheKey = async (state, agent, model, context, customPrompt) => { + const prompt = await buildNarrativeWalkthroughPrompt(state, context, agent.label, customPrompt); return createHash('sha256') .update( JSON.stringify({ @@ -894,7 +870,7 @@ const readNarrativeWalkthrough = async ( try { const timeoutMs = getNarrativeWalkthroughTimeoutMs(state, agent.defaultTimeoutMs); const { fileCount, hunkCount } = getWalkthroughSize(state); - const { hunkIdByAlias, prompt } = buildNarrativeWalkthroughRequest( + const { hunkIdByAlias, prompt } = await buildNarrativeWalkthroughRequest( state, context, agent.label, @@ -915,7 +891,7 @@ const readNarrativeWalkthrough = async ( ); agentOptions?.onProgress?.('response-received'); const parsed = parseJSONMessage(response); - const walkthrough = normalizeNarrativeWalkthrough( + const walkthrough = await normalizeNarrativeWalkthrough( parsed, state.files, { diff --git a/electron/walkthrough-authoring-bridge.cjs b/electron/walkthrough-authoring-bridge.cjs new file mode 100644 index 00000000..57e1d092 --- /dev/null +++ b/electron/walkthrough-authoring-bridge.cjs @@ -0,0 +1,93 @@ +// @ts-check + +/** + * CJS bridge to Core walkthrough-authoring (ESM). + */ + +const { pathToFileURL } = require('node:url'); +const { join } = require('node:path'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadAuthoring = () => { + if (!modulePromise) { + const modulePath = join(__dirname, '../core/dist/walkthrough-authoring.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +/** + * @param {unknown} value + * @param {import('../core/types.ts').RepositoryState} state + * @param {import('../core/types.ts').NarrativeWalkthrough['agent']} agent + * @param {ReadonlyMap} [hunkIdByAlias] + */ +const normalizeNarrativeWalkthrough = async (value, state, agent, hunkIdByAlias) => { + const authoring = await loadAuthoring(); + // When aliases are present, rewrite draft hunk ids back through the map first. + let draft = value; + if (hunkIdByAlias && hunkIdByAlias.size > 0 && value && typeof value === 'object') { + const rewriteIds = (ids) => + Array.isArray(ids) + ? ids.map((id) => (typeof id === 'string' ? hunkIdByAlias.get(id) ?? id : id)) + : ids; + const input = /** @type {any} */ (value); + draft = { + ...input, + chapters: Array.isArray(input.chapters) + ? input.chapters.map((chapter) => ({ + ...chapter, + stops: Array.isArray(chapter?.stops) + ? chapter.stops.map((stop) => ({ + ...stop, + hunkIds: rewriteIds(stop?.hunkIds), + notes: Array.isArray(stop?.notes) + ? stop.notes.map((note) => ({ + ...note, + hunkId: + typeof note?.hunkId === 'string' + ? hunkIdByAlias.get(note.hunkId) ?? note.hunkId + : note?.hunkId, + })) + : stop?.notes, + })) + : chapter?.stops, + })) + : input.chapters, + support: Array.isArray(input.support) + ? input.support.map((item) => ({ + ...item, + hunkIds: rewriteIds(item?.hunkIds), + })) + : input.support, + }; + } + return authoring.normalizeWalkthroughDraft(draft, state, agent); +}; + +/** + * @param {import('../core/types.ts').RepositoryState} state + * @param {unknown} [options] + */ +const buildWalkthroughPrompt = async (state, options) => { + const authoring = await loadAuthoring(); + return authoring.buildWalkthroughPrompt(state, options ?? {}); +}; + +/** + * @param {import('../core/types.ts').RepositoryState} state + * @param {unknown} [options] + */ +const buildWalkthroughPromptInput = async (state, options) => { + const authoring = await loadAuthoring(); + return authoring.buildWalkthroughPromptInput(state, options ?? {}); +}; + +module.exports = { + buildWalkthroughPrompt, + buildWalkthroughPromptInput, + loadAuthoring, + normalizeNarrativeWalkthrough, +}; From 978698747323a4f1a859ddcc4b046f686cdddd52 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:34:37 -0500 Subject: [PATCH 07/10] Mount shared review host for local PR/MR sources Route pull-request sources through LocalMergeRequestReviewHost so local Codiff renders MergeRequestReviewApp chrome (tree/walkthrough/comments) instead of the reduced App.tsx PR path. Baseline whole-PR review, comments, and walkthrough generation use existing IPC; version compare stays unwired for the next step. --- core/App.tsx | 24 +- .../LocalMergeRequestReviewHost.test.tsx | 315 +++++++++++ core/__tests__/ReviewCodeView-scroll.test.tsx | 12 +- core/__tests__/helpers/review-code-view.tsx | 2 +- core/app/LocalMergeRequestReviewHost.tsx | 372 +++++++++++++ docs/plan-local-parity.md | 493 ++++++++++++++++++ 6 files changed, 1210 insertions(+), 8 deletions(-) create mode 100644 core/__tests__/LocalMergeRequestReviewHost.test.tsx create mode 100644 core/app/LocalMergeRequestReviewHost.tsx create mode 100644 docs/plan-local-parity.md diff --git a/core/App.tsx b/core/App.tsx index e44197cd..0f5ad074 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -41,6 +41,10 @@ import { } from './app/hooks/useDocumentAppearance.ts'; import { useResizableSidebar } from './app/hooks/useResizableSidebar.ts'; import { useReviewFileState } from './app/hooks/useReviewState.ts'; +import { + LocalMergeRequestReviewHost, + shouldUseLocalMergeRequestHost, +} from './app/LocalMergeRequestReviewHost.tsx'; import { createDefaultConfig } from './config/defaults.ts'; import { getShortcutLabel } from './config/keymap.ts'; import type { CodiffConfig } from './config/types.ts'; @@ -1591,6 +1595,20 @@ export default function App() { ); } + if (shouldUseLocalMergeRequestHost(state.source)) { + return ( + selectSource({ type: 'working-tree' })} + preferences={preferences} + state={state} + /> + ); + } + const selectedOrSearchPath = activeDiffSearchMatch?.filePath ?? selectedPath; const visibleSelectedPath = selectedOrSearchPath && visibleFiles.some((file) => file.path === selectedOrSearchPath) @@ -1651,6 +1669,7 @@ export default function App() { focusCommentRequest, gitIdentity, hunkNavigation, + isPullRequest, itemVersionByKey, keymap: codiffConfig.keymap, loadingSectionIds, @@ -1684,7 +1703,6 @@ export default function App() { } /> ) : undefined, - supportsReviewCommentActions: isPullRequest, theme: preferences.theme, viewed, wordWrap, @@ -1853,9 +1871,11 @@ export default function App() { narrativeWalkthrough={narrativeWalkthrough} onActivatePath={activatePath} onLoadMoreHistory={loadMoreHistory} + onModeChange={changeSidebarMode} onSearchQueryChange={ sidebarMode === 'history' ? setHistorySearchQuery : setFileSearchQuery } + onSelectPath={activatePath} onSelectSource={selectSource} onShareWalkthrough={enabledShareWalkthrough} onToggleCommitView={showPlainCommitView ? closeCommitView : openCommitView} @@ -1868,7 +1888,9 @@ export default function App() { viewed={viewed} walkthroughError={walkthroughError} walkthroughLoading={walkthroughLoading} + walkthroughOutdatedPaths={reloadDeltaPaths} walkthroughProgress={walkthroughProgress} + walkthroughUnread={walkthroughUnread} />
diff --git a/core/__tests__/LocalMergeRequestReviewHost.test.tsx b/core/__tests__/LocalMergeRequestReviewHost.test.tsx new file mode 100644 index 00000000..3ff1ea94 --- /dev/null +++ b/core/__tests__/LocalMergeRequestReviewHost.test.tsx @@ -0,0 +1,315 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { beforeEach, expect, test, vi } from 'vite-plus/test'; +import App from '../App.tsx'; +import { + LocalMergeRequestReviewHost, + shouldUseLocalMergeRequestHost, +} from '../app/LocalMergeRequestReviewHost.tsx'; +import { createDefaultConfig, defaultSettings } from '../config/defaults.ts'; +import type { NarrativeWalkthrough, RepositoryState, ReviewSource } from '../types.ts'; +import { createChangedFile } from './helpers/fixtures.ts'; +import { renderReact, waitFor } from './helpers/react.tsx'; + +const reactActEnvironment = globalThis as typeof globalThis & { + ResizeObserver?: typeof ResizeObserver; + Worker?: typeof Worker; +}; +reactActEnvironment.ResizeObserver ??= class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +}; +HTMLElement.prototype.scrollBy ??= function scrollBy() {}; +HTMLElement.prototype.scrollTo ??= function scrollTo() {}; +class StubWorker extends EventTarget { + constructor(_scriptURL: string | URL, _options?: WorkerOptions) { + super(); + } + onerror = null; + onmessage = null; + postMessage() {} + terminate() {} +} +reactActEnvironment.Worker ??= StubWorker as unknown as typeof Worker; + +const createMemoryStorage = (): Storage => { + const values = new Map(); + return { + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + get length() { + return values.size; + }, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; +}; + +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: createMemoryStorage(), +}); +Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: createMemoryStorage(), +}); + +beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); +}); + +const pullRequestSource = { + description: '## Intent\n\nShip **review** context.', + number: 12, + provider: 'github', + title: 'Local shared review host', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/12', +} satisfies Extract; + +const repositoryState = { + branch: 'feature/local-host', + files: [createChangedFile('src/app.ts')], + generatedAt: 1, + launchPath: '/repo', + root: '/repo', + source: pullRequestSource, +} satisfies RepositoryState; + +const createCodiffMock = (overrides: Partial = {}): Window['codiff'] => ({ + askReviewAssistant: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), + completePlan: vi.fn(async () => {}), + createWalkthroughCommit: vi.fn(async () => ({ + hash: '0000000000000000000000000000000000000000', + status: 'committed' as const, + })), + decreaseCodeFontSize: vi.fn(async () => {}), + getAgentSkillStatus: vi.fn(async () => ({ + installed: true, + path: '/Users/reviewer/.codex/skills/codiff', + })), + getConfig: vi.fn(async () => createDefaultConfig()), + getDiffImageContent: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), + getDiffSectionContent: vi.fn(async () => { + throw new Error('Unexpected diff section load.'); + }), + getFeatureFlags: vi.fn(async () => ({ + planSharing: false, + walkthroughSharing: false, + })), + getGitIdentity: vi.fn(async () => ({ + email: 'reviewer@example.com', + name: 'Reviewer', + })), + getLaunchOptions: vi.fn(async () => ({ + repositoryPathProvided: true, + walkthrough: false, + })), + getMarkdownDocument: vi.fn(async ({ kind, path }) => ({ + content: '# Plan\n', + id: `${kind}:${path}`, + kind, + path, + version: 'version', + })), + getNarrativeWalkthrough: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), + getPlanReview: vi.fn(async () => null), + getPreferences: vi.fn(async () => ({ + agentBackend: 'codex' as const, + claudeModel: defaultSettings.claudeModel, + codeFontFamily: defaultSettings.codeFontFamily, + codeFontSize: defaultSettings.codeFontSize, + copyCommentsOnClose: true, + diffStyle: 'split' as const, + editorCommand: '', + lastRepositoryPath: '/repo', + openAIModel: defaultSettings.openAIModel, + opencodeModel: defaultSettings.opencodeModel, + piModel: defaultSettings.piModel, + reviewCommentsPrefix: defaultSettings.reviewCommentsPrefix, + showOutdated: false, + showWhitespace: false, + theme: 'system' as const, + walkthroughPrompt: defaultSettings.walkthroughPrompt, + wordWrap: false, + })), + getRepositoryHistory: vi.fn(async () => ({ + entries: [ + { + author: 'Author', + committedAt: Date.parse('2026-07-01T00:00:00.000Z'), + parents: ['parent'], + ref: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + scope: 'pull-request' as const, + subject: 'PR commit', + }, + { + author: 'Base Author', + committedAt: Date.parse('2026-06-01T00:00:00.000Z'), + parents: [], + ref: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + scope: 'base' as const, + subject: 'Base commit', + }, + ], + root: '/repo', + })), + getRepositoryState: vi.fn(async () => repositoryState), + getTerminalHelperStatus: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + increaseCodeFontSize: vi.fn(async () => {}), + installAgentSkill: vi.fn(async () => ({ + installed: true, + path: '/Users/reviewer/.codex/skills/codiff', + })), + installTerminalHelper: vi.fn(async () => ({ + command: 'codiff', + installed: true, + path: '/usr/local/bin/codiff', + })), + isWindowFullScreen: vi.fn(async () => false), + markPlanReady: vi.fn(async () => {}), + onConfigChanged: vi.fn(() => () => {}), + onCopyPendingCommentsRequest: vi.fn(() => () => {}), + onFindInDiffs: vi.fn(() => () => {}), + onMarkdownDocumentChanged: vi.fn(() => () => {}), + onPlanCloseRequested: vi.fn(() => () => {}), + onRefreshRequest: vi.fn(() => () => {}), + onRepositoryChanged: vi.fn(() => () => {}), + onWalkthroughCommitOutput: vi.fn(() => () => {}), + onWalkthroughProgress: vi.fn(() => () => {}), + onWindowFullScreenChanged: vi.fn(() => () => {}), + openConfigFile: vi.fn(async () => {}), + openFile: vi.fn(async () => {}), + resetCodeFontSize: vi.fn(async () => {}), + saveMarkdownDocument: vi.fn(async (request) => ({ + document: { + content: request.content, + id: `${request.kind}:${request.path}`, + kind: request.kind, + path: request.path, + version: 'next-version', + }, + status: 'saved' as const, + })), + savePlanReview: vi.fn(async (review) => review), + setDiffStyle: vi.fn(async () => {}), + setShowOutdated: vi.fn(async () => {}), + setWordWrap: vi.fn(async () => {}), + sharePlan: vi.fn(async () => ({ + status: 'uploaded' as const, + url: 'https://codiff.dev/p/test', + })), + shareWalkthrough: vi.fn(async () => ({ + status: 'uploaded' as const, + url: 'https://codiff.dev/w/test', + })), + showInFolder: vi.fn(async () => {}), + submitPullRequestComment: vi.fn(async () => { + throw new Error('Unexpected pull request comment submit.'); + }), + submitPullRequestReview: vi.fn(async () => {}), + updateWalkthroughCommitMessage: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), + ...overrides, +}); + +test('shouldUseLocalMergeRequestHost only matches pull-request sources', () => { + expect(shouldUseLocalMergeRequestHost(pullRequestSource)).toBe(true); + expect(shouldUseLocalMergeRequestHost({ type: 'working-tree' })).toBe(false); + expect(shouldUseLocalMergeRequestHost({ ref: 'abc', type: 'commit' })).toBe(false); + expect(shouldUseLocalMergeRequestHost(null)).toBe(false); +}); + +test('App mounts the shared merge-request review shell for pull-request sources', async () => { + window.codiff = createCodiffMock(); + const app = await renderReact(); + + try { + await waitFor(() => { + expect(app.container.querySelector('.merge-request-shell')).not.toBeNull(); + }); + + expect(app.container.querySelector('.merge-request-home-button')).not.toBeNull(); + expect(app.container.querySelector('.review-top-bar-source')?.textContent).toContain('PR #12'); + expect(app.container.querySelector('.codiff-source-description-header')).not.toBeNull(); + expect(app.container.querySelector('.source-description-markdown')?.textContent).toContain( + 'Ship review context.', + ); + // Shared chrome uses Comments instead of the local History sidebar mode. + expect( + Array.from(app.container.querySelectorAll('[role="tab"]')).map((tab) => tab.textContent), + ).toEqual(expect.arrayContaining(['Walkthrough', 'Tree', 'Comments'])); + expect(window.codiff.getRepositoryHistory).toHaveBeenCalled(); + } finally { + await app.cleanup(); + } +}); + +test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through local IPC', async () => { + const walkthrough = { + agent: 'codex', + chapters: [], + focus: 'Focus.', + generatedAt: '2026-07-01T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'feature/local-host', root: '/repo' }, + source: pullRequestSource, + support: [], + title: 'Generated', + version: 4, + } satisfies NarrativeWalkthrough; + + const getNarrativeWalkthrough = vi.fn(async () => ({ + status: 'ready' as const, + walkthrough, + })); + + window.codiff = createCodiffMock({ getNarrativeWalkthrough }); + + const onHome = vi.fn(); + const app = await renderReact( + , + ); + + try { + await waitFor(() => { + expect(getNarrativeWalkthrough).toHaveBeenCalledWith(pullRequestSource, {}); + }); + await waitFor(() => { + // Empty-chapter walkthroughs still mark the surface ready. + expect(app.container.textContent).toContain('This walkthrough has no readable sequence.'); + }); + + await act(async () => { + app.container.querySelector('.merge-request-home-button')?.click(); + }); + expect(onHome).toHaveBeenCalledTimes(1); + } finally { + await app.cleanup(); + } +}); diff --git a/core/__tests__/ReviewCodeView-scroll.test.tsx b/core/__tests__/ReviewCodeView-scroll.test.tsx index ceee97b0..4c14d526 100644 --- a/core/__tests__/ReviewCodeView-scroll.test.tsx +++ b/core/__tests__/ReviewCodeView-scroll.test.tsx @@ -786,7 +786,7 @@ test('read-only review comments render safe details blocks', async () => { } satisfies ReviewComment; const view = await renderReact( - , + , ); try { @@ -1362,9 +1362,9 @@ test('failed pull request comments keep their draft and can be retried', async ( , ); @@ -1410,8 +1410,8 @@ test('working-tree share comments support the Comment button and Mod+Enter', asy , ); @@ -1460,9 +1460,9 @@ test('file comments can be created for GitLab merge requests but not GitHub pull const view = await renderReact( , ); @@ -1483,9 +1483,9 @@ test('file comments can be created for GitLab merge requests but not GitHub pull await view.rerender( , ); expect(view.container.querySelector('.codiff-file-comment-button')).toBeNull(); @@ -1510,12 +1510,12 @@ test('file-level review comments render as measured file annotations', async () }, ]} files={[file]} + isPullRequest source={{ provider: 'gitlab', type: 'pull-request', url: 'https://gitlab.example.com/group/project/-/merge_requests/1', }} - supportsReviewCommentActions />, ); diff --git a/core/__tests__/helpers/review-code-view.tsx b/core/__tests__/helpers/review-code-view.tsx index b9b1c743..edb9e0f5 100644 --- a/core/__tests__/helpers/review-code-view.tsx +++ b/core/__tests__/helpers/review-code-view.tsx @@ -168,6 +168,7 @@ export function ReviewCodeViewHarness({ files, ...overrides }: ReviewCodeViewHar forceExpandedPaths={new Set()} gitIdentity={null} hunkNavigation={null} + isPullRequest={false} itemVersionByKey={{}} keymap={defaultKeymap} loadingSectionIds={new Set()} @@ -187,7 +188,6 @@ export function ReviewCodeViewHarness({ files, ...overrides }: ReviewCodeViewHar selectedPath={null} showWhitespace={false} source={source} - supportsReviewCommentActions={false} viewed={{}} walkthroughNotes={new Map()} wordWrap={false} diff --git a/core/app/LocalMergeRequestReviewHost.tsx b/core/app/LocalMergeRequestReviewHost.tsx new file mode 100644 index 00000000..beeb4faf --- /dev/null +++ b/core/app/LocalMergeRequestReviewHost.tsx @@ -0,0 +1,372 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createDefaultConfig } from '../config/defaults.ts'; +import type { CodiffConfig } from '../config/types.ts'; +import { sortFiles } from '../lib/files.ts'; +import { getSourceLabel } from '../lib/source.ts'; +import { + MergeRequestReviewApp, + type MergeRequestCommitListEntry, + type MergeRequestReviewMode, + type MergeRequestWalkthroughStatus, +} from '../SharedWalkthroughApp.tsx'; +import type { + ChangedFile, + CodiffPreferences, + GitIdentity, + HistoryEntry, + NarrativeWalkthrough, + PullRequestExistingReviewComment, + PullRequestReviewComment, + PullRequestReviewEvent, + RepositoryState, + ReviewSource, +} from '../types.ts'; + +const getPreferencesFromConfig = ({ settings }: CodiffConfig): CodiffPreferences => ({ + ...settings, +}); + +const defaultPreferences = getPreferencesFromConfig(createDefaultConfig()); + +const toCommitListEntries = ( + entries: ReadonlyArray, +): ReadonlyArray => + entries + .filter((entry) => entry.scope !== 'base') + .map((entry) => ({ + authoredAt: new Date(entry.committedAt).toISOString(), + authorName: entry.author, + sha: entry.ref, + shortSha: entry.ref.slice(0, 7), + subject: entry.subject, + })); + +const getPullRequestTitle = (state: RepositoryState) => { + if (state.source.type !== 'pull-request') { + return 'Review'; + } + return state.source.title?.trim() || getSourceLabel(state.source); +}; + +const getSourceIdentityKey = (source: ReviewSource) => + source.type === 'pull-request' + ? `pull-request:${source.provider ?? ''}:${source.url}` + : `${source.type}`; + +const sortRepositoryState = (state: RepositoryState): RepositoryState => ({ + ...state, + files: sortFiles(state.files), +}); + +const unsupportedGeneralComment = async (): Promise => { + throw new Error('General discussion comments are not supported in local Codiff yet.'); +}; + +const unsupportedCommentUpdate = async (): Promise => { + throw new Error('Editing submitted review comments is not supported in local Codiff yet.'); +}; + +export type LocalMergeRequestReviewHostProps = { + gitIdentity?: GitIdentity | null; + initialMode?: MergeRequestReviewMode; + onHome: () => void; + preferences?: Partial< + Pick< + CodiffPreferences, + 'codeFontFamily' | 'codeFontSize' | 'diffStyle' | 'showWhitespace' | 'theme' | 'wordWrap' + > + >; + /** Preloaded snapshot handed off from App bootstrap. */ + state: RepositoryState; +}; + +type HostSessionProps = LocalMergeRequestReviewHostProps & { + sourceKey: string; +}; + +/** + * One session per PR/MR identity. App remounts this when the source key changes + * so we avoid setState-in-effect reset patterns. + */ +function LocalMergeRequestReviewSession({ + gitIdentity = null, + initialMode, + onHome, + preferences: preferencesProp, + state: initialState, +}: HostSessionProps) { + const [liveState, setLiveState] = useState(null); + const state = liveState ?? sortRepositoryState(initialState); + const stateRef = useRef(state); + const [commits, setCommits] = useState>([]); + const [walkthrough, setWalkthrough] = useState(null); + const [walkthroughStatus, setWalkthroughStatus] = + useState('idle'); + const [walkthroughError, setWalkthroughError] = useState(null); + const [configPreferences, setConfigPreferences] = useState(defaultPreferences); + const [mode, setMode] = useState(initialMode); + const [selectedCommitSha, setSelectedCommitSha] = useState(null); + const commitDiffCacheRef = useRef>>(new Map()); + const walkthroughRequestRef = useRef(0); + + useEffect(() => { + stateRef.current = state; + }, [state]); + + useEffect(() => { + let canceled = false; + window.codiff.getConfig().then( + (config) => { + if (!canceled) { + setConfigPreferences(getPreferencesFromConfig(config)); + } + }, + () => {}, + ); + const unsubscribe = window.codiff.onConfigChanged((config) => { + setConfigPreferences(getPreferencesFromConfig(config)); + }); + return () => { + canceled = true; + unsubscribe(); + }; + }, []); + + useEffect(() => { + if (state.source.type !== 'pull-request') { + return; + } + + let canceled = false; + const source = state.source; + + const loadCommits = async () => { + const history = await window.codiff.getRepositoryHistory(200, source); + if (canceled) { + return; + } + setCommits(toCommitListEntries(history.entries)); + }; + + loadCommits().catch(() => { + if (!canceled) { + setCommits([]); + } + }); + + return () => { + canceled = true; + }; + }, [state.source]); + + const refreshState = useCallback(async () => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + return; + } + const nextState = await window.codiff.getRepositoryState(current.source); + const ordered = sortRepositoryState(nextState); + stateRef.current = ordered; + setLiveState(ordered); + commitDiffCacheRef.current.clear(); + setSelectedCommitSha(null); + }, []); + + useEffect(() => window.codiff.onRefreshRequest(() => void refreshState()), [refreshState]); + + const onSubmitComment = useCallback( + async (comment: PullRequestReviewComment): Promise => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + throw new Error('Review comments require a pull request source.'); + } + const submitted = await window.codiff.submitPullRequestComment({ + comment, + source: current.source, + }); + await refreshState(); + return submitted; + }, + [refreshState], + ); + + const onSubmitReview = useCallback( + async ( + event: PullRequestReviewEvent, + comments: ReadonlyArray, + body?: string, + ) => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + throw new Error('Reviews require a pull request source.'); + } + await window.codiff.submitPullRequestReview({ + ...(body ? { body } : {}), + comments, + event, + source: current.source, + }); + await refreshState(); + }, + [refreshState], + ); + + const onSubmitGeneralComment = useCallback(async (_body: string) => { + await unsupportedGeneralComment(); + }, []); + + const onUpdateComment = useCallback(async (_commentId: string, _body: string) => { + await unsupportedCommentUpdate(); + }, []); + + const onUpdateGeneralComment = useCallback(async (_commentId: string, _body: string) => { + await unsupportedCommentUpdate(); + }, []); + + const onLoadCommitDiff = useCallback(async (sha: string) => { + const cached = commitDiffCacheRef.current.get(sha); + if (cached) { + setSelectedCommitSha(sha); + return cached; + } + const commitState = await window.codiff.getRepositoryState({ + ref: sha, + type: 'commit', + } satisfies ReviewSource); + const files = sortFiles(commitState.files); + commitDiffCacheRef.current.set(sha, files); + setSelectedCommitSha(sha); + return files; + }, []); + + const onGenerateWalkthrough = useCallback( + async (options?: { + force?: boolean; + reviewStructure?: 'commit-by-commit' | 'whole-diff'; + unitId?: string; + versionCompare?: { + fromId: string; + toId: string; + walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; + }; + }) => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + return; + } + // Version-scoped / unit generation lands with history wiring; baseline whole-diff only. + if (options?.versionCompare || options?.unitId) { + setWalkthroughStatus('failed'); + setWalkthroughError( + 'Version compare and unit walkthroughs are not wired in local Codiff yet.', + ); + return; + } + if (current.files.length === 0) { + setWalkthrough(null); + setWalkthroughStatus('idle'); + setWalkthroughError(null); + return; + } + + const requestId = ++walkthroughRequestRef.current; + setWalkthroughStatus('generating'); + setWalkthroughError(null); + try { + const result = await window.codiff.getNarrativeWalkthrough( + current.source, + options?.force ? { force: true } : {}, + ); + if (requestId !== walkthroughRequestRef.current) { + return; + } + if (result.status === 'ready') { + setWalkthrough(result.walkthrough); + setWalkthroughStatus('ready'); + setWalkthroughError(null); + return; + } + setWalkthrough(null); + setWalkthroughStatus('failed'); + setWalkthroughError(result.reason); + } catch (error: unknown) { + if (requestId !== walkthroughRequestRef.current) { + return; + } + setWalkthrough(null); + setWalkthroughStatus('failed'); + setWalkthroughError(error instanceof Error ? error.message : String(error)); + } + }, + [], + ); + + const title = useMemo(() => getPullRequestTitle(state), [state]); + const externalUrl = state.source.type === 'pull-request' ? state.source.url : ''; + + const resolvedPreferences = useMemo( + () => ({ + codeFontFamily: preferencesProp?.codeFontFamily ?? configPreferences.codeFontFamily, + codeFontSize: preferencesProp?.codeFontSize ?? configPreferences.codeFontSize, + diffStyle: preferencesProp?.diffStyle ?? configPreferences.diffStyle, + showWhitespace: preferencesProp?.showWhitespace ?? configPreferences.showWhitespace, + theme: preferencesProp?.theme ?? configPreferences.theme, + wordWrap: preferencesProp?.wordWrap ?? configPreferences.wordWrap, + }), + [configPreferences, preferencesProp], + ); + + if (state.source.type !== 'pull-request') { + return null; + } + + return ( + { + // History wiring lands in a later plan step. + }} + onGenerateWalkthrough={onGenerateWalkthrough} + onHome={onHome} + onLoadCommitDiff={onLoadCommitDiff} + onModeChange={setMode} + onOpenVersionCompare={() => { + // History wiring lands in a later plan step. + }} + onSubmitComment={onSubmitComment} + onSubmitGeneralComment={onSubmitGeneralComment} + onSubmitReview={onSubmitReview} + onUpdateComment={onUpdateComment} + onUpdateGeneralComment={onUpdateGeneralComment} + onVersionCompareRangeChange={() => { + // History wiring lands in a later plan step. + }} + preferences={resolvedPreferences} + selectedCommitSha={selectedCommitSha} + state={state} + title={title} + versionHistoryLoading={false} + versions={[]} + walkthrough={walkthrough} + walkthroughError={walkthroughError} + walkthroughStatus={walkthroughStatus} + /> + ); +} + +/** + * Desktop host adapter for pull-request / merge-request sources. + * Feeds Core `MergeRequestReviewApp` with local IPC-backed data and actions. + * Version compare / evolution props stay empty until later plan steps. + */ +export function LocalMergeRequestReviewHost(props: LocalMergeRequestReviewHostProps) { + const sourceKey = getSourceIdentityKey(props.state.source); + return ; +} + +export function shouldUseLocalMergeRequestHost(source: ReviewSource | null | undefined): boolean { + return source?.type === 'pull-request'; +} diff --git a/docs/plan-local-parity.md b/docs/plan-local-parity.md new file mode 100644 index 00000000..5242afbf --- /dev/null +++ b/docs/plan-local-parity.md @@ -0,0 +1,493 @@ +# Plan: Local Codiff Review-History Parity + +## Goal + +Bring local Codiff to product parity with Codiff Web's review-history experience: + +- pick historical review endpoints +- compare two endpoints as a review surface +- classify commit-stack evolution +- generate whole-diff or unit walkthroughs through shared authoring + +Do this on top of the already-extracted packages: + +- `@nkzw/codiff-core` contracts + `MergeRequestReviewApp` + `walkthrough-authoring` +- `@nkzw/codiff-gitlab` read-side GitLab adapter + `GitLabTransport` +- local `createGlabGitLabTransport` + +Codiff Web remains the Cloudflare host. Local Codiff becomes the desktop host. +Both should consume the same packages and render the same Core review UI. + +## Non-goals + +- Web D1/R2 caches, Durable Objects, Fate live updates, Think/AI Gateway +- migrating baseline current-MR/PR loading or write-side comments/reviews yet +- full Jujutsu identity / evolution-log support +- making GitHub invent GitLab-style numbered versions + +Local parity means **same review semantics and UX**, not the same cloud +orchestration stack. + +## Current State + +### Already shared + +- Core review-history contracts (`DiffRange`, `DiffComparison`, units, plans) +- Core review UI that can render versions / compare / evolution when fed props +- Walkthrough authoring (`parse` / `prompt` / `normalize` / `compose`) +- GitLab history package over injected transport +- Local glab transport primitive +- Local whole-diff walkthrough generation routed through authoring + +### Still local-only / missing + +- Electron does **not** call `@nkzw/codiff-gitlab` for versions/compare/evolution +- Local PR/MR UI still primarily goes through `core/App.tsx`, not the full + `MergeRequestReviewApp` host loop used by Web +- No local unit-walkthrough orchestration +- No GitHub force-push / head-comparison history provider +- Web still has in-tree copies of GitLab history + authoring (migration later) + +## Target Architecture + +```text +@nkzw/codiff-core + contracts, MergeRequestReviewApp, walkthrough-authoring + +@nkzw/codiff-gitlab + GitLab versions / compare / evolution / unit diffs / plans + over GitLabTransport + +@nkzw/codiff-github (new, or electron/git-state/github-history first) + GitHub force-push heads / compare / evolution / plans + over GitHubTransport + +local Codiff host + glab transport + gh transport + IPC + local agent invocation + mounts MergeRequestReviewApp for PR/MR sources + +Codiff Web host + Worker GitLabTransport (+ later GitHub if needed) + Fate / DO / Think / D1 / R2 + same MergeRequestReviewApp props +``` + +### Host boundary + +Hosts own: + +- authentication (`glab`, `gh`, Worker bridge) +- process execution and credentials +- generation runtime (local agents vs Think) +- optional caching +- IPC / route state + +Packages own: + +- endpoint construction and provider semantics +- comparison / evolution algorithms +- projection into Core contracts +- walkthrough draft/prompt/normalize/compose + +## Provider Models + +### GitLab: native MR versions + +Use GitLab's first-class version history. + +Endpoints / package APIs: + +- version list + stats +- version compare (rebase-replay preferred) +- historical commit stacks +- commit evolution + rebase drivers +- unit diffs +- `projectVersionCompare` / `projectCommitEvolution` / `projectReviewPlan` + +Local transport: + +```ts +createGlabGitLabTransport({ hostname, repoRoot }); +``` + +Identity remains GitLab `{ baseSha, startSha, headSha }` inside the package. +Core only sees `DiffRange` / `ReviewVersionOption`. + +### GitHub: force-push head comparisons + +GitHub has no MR version list. Local parity uses **force-push head history** as +the revision timeline. + +#### Revision discovery + +Build an ordered list of PR head snapshots from: + +1. PR timeline / issue events for force-push records + - before/after head SHAs + - actor + timestamp +2. current PR head as the newest endpoint +3. optional: PR base updates as base-movement context, not as head versions + +Each revision becomes a Core `ReviewVersionOption`: + +```ts +{ + id: afterHeadSha, // or stable derived id + createdAt, + number: index, // display only + range: { + base: baseAtThatTimeOrCurrentBase, + head: afterHeadSha, + } +} +``` + +Labels should read like: + +- `Head · abc1234` +- `Force-push · def5678` +- `Current head` + +not fake `v1/v2` GitLab version numbers unless we explicitly choose that UX. + +#### Comparison materialization + +For selected before/after heads: + +1. resolve base refs (PR base at each point if available; else current base) +2. materialize `before = baseA...headA`, `after = baseB...headB` +3. prefer a rebase-replay / intentional interdiff path when blobs are available + via local git objects +4. fall back to approximate patch-text compare when objects are missing +5. project to `DiffComparisonView` + +Local git is an advantage here: after `gh` fetches the PR refs, many head SHAs +are already present and can be read without remote blob round-trips. + +#### Commit evolution + +Reuse the same Core/GitLab evolution concepts: + +- retained +- rewritten-same-patch +- revised +- introduced +- removed +- absorbed-into-base +- ambiguous + +Implementation options, in order of preference: + +1. extract provider-neutral evolution pure functions already used by GitLab into + Core or a tiny shared history module, then call them from GitHub +2. temporarily fork the algorithm behind `@nkzw/codiff-github` if extraction is + too large for the first local commit + +Do not special-case the UI for GitHub evolution kinds. + +#### Comments + +Associate review comments to head SHAs / positions when GitHub provides them. +Comment association quality may be weaker than GitLab's versioned positions; +surface warnings rather than inventing certainty. + +## Local Product UX + +For `source.type === 'pull-request'`, local Codiff should mount the shared +review surface used by Web (`MergeRequestReviewApp` / `ReviewSurface`), not a +reduced PR-only subset of `App.tsx`. + +Required host-fed props: + +- `state` current whole PR/MR snapshot +- `versions` +- `versionCompare` / loading / error +- `versionCommitEvolution` / loading / error +- `versionWalkthroughStructure` +- `commits` for ordinary commit-by-commit when useful +- callbacks: + - `onVersionCompareRangeChange` + - `onLoadCommitDiff` + - `onLoadVersionCommitDiff` + - `onGenerateWalkthrough` + - exit compare / open compare + +### Modes + +Preserve existing review modes: + +- comments +- tree / files +- commits / evolution +- walkthrough + +Version compare is a scope over tree + walkthrough, as in Web. + +### Walkthrough generation + +Whole-diff: + +1. materialize one `RepositoryState` for the selected range/compare +2. `buildWalkthroughPrompt` + local agent +3. `normalizeWalkthroughDraft` + +Units: + +1. `projectReviewPlan` / structure override +2. for each reviewable unit, materialize unit `RepositoryState` +3. generate each unit independently through authoring +4. `composeUnitWalkthroughs` +5. attach comment references when available + +Local generation stays sequential or lightly concurrent in-process. No DO fanout +required for v1. + +## Implementation Plan + +### 1. Local review host shell + +Switch local PR/MR presentation to the shared review app host path. + +Work: + +- add an Electron/renderer host container for pull-request sources +- keep working-tree / commit / branch modes on existing `App.tsx` paths +- plumb preload/IPC for history reads and scoped generation +- preserve comment submit/review actions already implemented locally + +Acceptance: + +- opening `codiff mr …` or `codiff pr …` renders shared review chrome +- baseline whole-MR/PR review still works with no version compare selected + +### 2. GitLab local history wiring + +Work: + +- on GitLab MR load, create `createGlabGitLabTransport` +- fetch version history through `@nkzw/codiff-gitlab` +- fetch compare + evolution on range selection +- fetch unit diffs on demand +- project package results into Core props +- wire generate callbacks to shared authoring + local agents + +Acceptance: + +- version picker populated from glab-backed transport +- compare shows intentional files / base movement / evolution +- whole-diff and commit-unit walkthroughs both work offline-ish with glab auth + +Tests: + +- fake glab executable covering versions, compare payloads, commit stacks +- unit tests for IPC/host mapping to Core props +- one end-to-end Electron test with fixture transport + +### 3. GitHub force-push history provider + +Work: + +- add `GitHubTransport` + `createGhGitHubTransport` +- implement revision discovery from force-push timeline/head events +- implement compare materialization using local git objects when possible +- implement evolution + plan projection to Core contracts +- wire into the same host shell as GitLab + +Package shape preference: + +```text +@nkzw/codiff-github + transport interface + force-push revision list + compare/evolution/plan projection +``` + +If packaging slows the first landing, start under +`electron/git-state/github-history/` with the same public interface and move it +into a package in the release commit. + +Acceptance: + +- PR with force-pushes shows a head timeline +- comparing two heads materializes an interdiff-like review surface +- evolution units render in the same UI as GitLab +- no GitLab version numbering metaphors required + +Tests: + +- fake `gh` executable / fixture timeline payloads +- compare fixtures for rewritten commits and pure force-push rebases +- warnings when before/after heads are unavailable locally and must be fetched + +### 4. Shared generation orchestration helper + +Avoid two host-specific generators. + +Work: + +- add a small host-agnostic helper in Core or a local shared module: + +```ts +generateReviewWalkthrough({ + plan, + states, // whole or per-unit + runModel, // host-provided + agent, + compose, +}); +``` + +- local agents implement `runModel` +- Web Think path can adopt later + +Acceptance: + +- local GitLab and GitHub both call the same orchestration helper +- composition and prompt options remain in `walkthrough-authoring` + +### 5. UX polish and parity gaps + +Work: + +- structure recommendation + user override +- empty/error/loading states for history reads +- comment open-in-compare affordances where positions exist +- clear provider-specific copy: + - GitLab: “Versions” + - GitHub: “Head history” / “Force-pushes” +- ensure CSS already in Core covers both + +### 6. Release / docs + +Work: + +- bump package versions if GitHub package or Core orchestration lands +- update `docs/review-history-packages.md` +- document local commands and auth requirements +- keep Web migration notes explicit: local proves host wiring first + +## Commit Plan + +Every commit should build and test independently. + +1. `Mount shared review host for local PR/MR sources` + - shell/IPC only; no GitLab/GitHub history algorithms yet +2. `Wire GitLab review history into local Codiff` + - glab transport + `@nkzw/codiff-gitlab` + generation +3. `Add GitHub force-push review history provider` + - transport + revision/compare/evolution + host wiring +4. `Unify local walkthrough generation behind shared orchestration` +5. `Polish provider copy/tests and release local parity packages` + +If commit 3 is large, split into: + +- provider package/fixtures +- Electron wiring + +## Unification With Codiff Web + +This local work is not a fork. It is the second host on the same packages. + +Immediate: + +- local consumes released/shared packages +- proves the host adapter pattern with real UX + +Next Web MR: + +- delete in-tree GitLab history + authoring copies +- implement Worker `GitLabTransport` +- keep only Cloudflare orchestration/persistence + +Eventually: + +```text +one Core UI +one authoring module +provider packages per forge +thin hosts +``` + +Local-specific forever: + +- glab/gh executable discovery +- local agent backends +- no multi-tenant cache + +Web-specific forever: + +- Access/Worker auth +- D1/R2/DO/Fate/Think + +## Auth and Runtime Requirements + +GitLab local: + +- `glab` installed and authenticated for the MR host +- `CODIFF_GLAB_PATH` supported +- network access to GitLab API via glab + +GitHub local: + +- `gh` installed and authenticated +- fetch enough PR refs/commits to materialize selected heads +- network access for timeline + missing objects + +Walkthroughs: + +- at least one local agent backend (`codex` / `claude` / `opencode` / `pi`) + +## Validation + +### GitLab + +- open MR with 3+ versions +- compare adjacent and distant versions +- verify base movement and evolution units +- generate whole-diff walkthrough for a compare +- generate commit-unit walkthrough and navigate unit chapters +- fake-glab tests for transport and host mapping + +### GitHub + +- open PR with force-push history +- verify head timeline ordering and labels +- compare pre/post force-push heads +- verify rewritten/introduced/removed classification on a known fixture +- generate whole-diff and unit walkthroughs +- fake-gh tests for timeline parsing and compare materialization + +### Cross-provider + +- same Core props shape into `MergeRequestReviewApp` +- same authoring module output schema +- no provider-specific React forks inside Core beyond label copy + +### Regression + +- working-tree review/commit flow unchanged +- ordinary PR/MR comments and submit review still work +- `codiff -w` whole-diff still works outside compare mode + +## Risks + +- **GitHub timeline completeness:** force-push events may be missing or partial on + some repos/permissions. Degrade to current head only with an explicit warning. +- **Object availability:** selected heads may not exist locally. Fetch on demand; + fail soft with actionable auth/fetch errors. +- **Algorithm drift:** do not copy evolution code differently for GitHub. Extract + shared pure functions before dual maintenance appears. +- **UI mount churn:** moving local PR/MR onto `MergeRequestReviewApp` can regress + comments/review buttons if actions are not plumbed carefully. +- **Performance:** naive full compare on every range change will feel slow. + Cache last compare/evolution in memory per window; no durable cache required. + +## Decision Summary + +- Yes: implement local parity now. +- GitLab: package-backed native versions. +- GitHub: force-push head comparisons projected into the same Core model. +- Keep hosts thin; keep algorithms/authoring shared. +- Use local parity to finish the unification path that lets Codiff Web delete its + duplicated history/authoring implementations. From 72ca9a31493fab8ef2cb3074a6670748b11e7cb8 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:25:49 -0500 Subject: [PATCH 08/10] Add a local GitLab CLI transport Implement the `GitLabTransport` contract over authenticated `glab api` calls and expose it from the local merge-request backend. This isolates process execution and pagination from the shared GitLab provider package before the review host starts loading version history. --- .../__tests__/glab-gitlab-transport.test.ts | 79 +++++++ electron/git-state/glab-gitlab-transport.cjs | 199 ++++++++++++++++++ electron/git-state/merge-request.cjs | 3 + 3 files changed, 281 insertions(+) create mode 100644 electron/__tests__/glab-gitlab-transport.test.ts create mode 100644 electron/git-state/glab-gitlab-transport.cjs diff --git a/electron/__tests__/glab-gitlab-transport.test.ts b/electron/__tests__/glab-gitlab-transport.test.ts new file mode 100644 index 00000000..41d559c3 --- /dev/null +++ b/electron/__tests__/glab-gitlab-transport.test.ts @@ -0,0 +1,79 @@ +import { chmod, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import { afterEach, expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { createGlabGitLabTransport } = require('../git-state/glab-gitlab-transport.cjs') as { + createGlabGitLabTransport: (options: { + hostname: string; + repoRoot: string; + }) => { + request: (request: { + method?: string; + path: string; + query?: Record; + body?: unknown; + }) => Promise; + }; +}; + +const previousGlabPath = process.env.CODIFF_GLAB_PATH; + +afterEach(() => { + if (previousGlabPath == null) { + delete process.env.CODIFF_GLAB_PATH; + } else { + process.env.CODIFF_GLAB_PATH = previousGlabPath; + } +}); + +test('createGlabGitLabTransport performs glab api requests through the injected executable', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-glab-transport-')); + const fakeGlabPath = join(directory, 'glab'); + const callsPath = join(directory, 'calls.jsonl'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const fs = require('node:fs'); +const callsPath = process.env.CODIFF_GLAB_TEST_CALLS; +const args = process.argv.slice(2); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + fs.appendFileSync(callsPath, JSON.stringify({ args, input }) + '\\n'); + if (args.includes('/api/v4/projects/group%2Fproject/merge_requests/7/versions')) { + process.stdout.write(JSON.stringify([ + { + id: 1, + head_commit_sha: 'b'.repeat(40), + base_commit_sha: 'a'.repeat(40), + start_commit_sha: 'a'.repeat(40), + created_at: '2026-01-01T00:00:00.000Z', + }, + ])); + } else { + process.stdout.write('{}'); + } + process.exit(0); +}); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + process.env.CODIFF_GLAB_TEST_CALLS = callsPath; + + const transport = createGlabGitLabTransport({ + hostname: 'gitlab.example.com', + repoRoot: directory, + }); + const versions = await transport.request>({ + path: '/api/v4/projects/group%2Fproject/merge_requests/7/versions', + }); + expect(versions).toEqual([ + expect.objectContaining({ id: 1 }), + ]); +}); diff --git a/electron/git-state/glab-gitlab-transport.cjs b/electron/git-state/glab-gitlab-transport.cjs new file mode 100644 index 00000000..b77b6cc5 --- /dev/null +++ b/electron/git-state/glab-gitlab-transport.cjs @@ -0,0 +1,199 @@ +// @ts-check + +/** + * glab-backed GitLabTransport for local Codiff. + * Keeps executable discovery, process spawning, and credentials in Electron. + */ + +const { spawn } = require('node:child_process'); +const { accessSync, constants } = require('node:fs'); +const { homedir } = require('node:os'); +const { delimiter, join } = require('node:path'); + +const GLAB_NOT_FOUND_CODE = 'GLAB_NOT_FOUND'; +const GLAB_NOT_FOUND_MESSAGE = + 'GitLab support requires glab. Install glab, authenticate it, and verify `glab --version` works in Terminal. Codiff searches PATH, ~/.local/bin/glab, /opt/homebrew/bin/glab, and /usr/local/bin/glab. If glab is installed somewhere else, launch Codiff with `CODIFF_GLAB_PATH=/absolute/path/to/glab codiff -w`.'; + +/** @param {string} path */ +const isExecutableFile = (path) => { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +}; + +/** @param {string} command */ +const findExecutableOnPath = (command) => { + const pathValue = process.env.PATH ?? ''; + for (const directory of pathValue.split(delimiter)) { + if (!directory) continue; + const candidate = join(directory, command); + if (isExecutableFile(candidate)) { + return candidate; + } + } + return null; +}; + +/** @param {string} [detail] */ +const createGlabNotFoundError = (detail) => { + const error = /** @type {Error & { code?: string }} */ ( + new Error(detail ? `${GLAB_NOT_FOUND_MESSAGE} ${detail}` : GLAB_NOT_FOUND_MESSAGE) + ); + error.code = GLAB_NOT_FOUND_CODE; + return error; +}; + +const getGlabCommand = () => { + const glabPath = process.env.CODIFF_GLAB_PATH?.trim(); + if (glabPath) { + if (isExecutableFile(glabPath)) { + return glabPath; + } + throw createGlabNotFoundError( + `CODIFF_GLAB_PATH is set to ${JSON.stringify(glabPath)}, but that file is not executable.`, + ); + } + + const pathCommand = findExecutableOnPath('glab'); + if (pathCommand) { + return pathCommand; + } + + for (const path of [ + join(homedir(), '.local/bin/glab'), + '/opt/homebrew/bin/glab', + '/usr/local/bin/glab', + ]) { + if (isExecutableFile(path)) { + return path; + } + } + + throw createGlabNotFoundError(); +}; + +/** + * @param {string} hostname + * @param {string} path + * @param {Readonly> | undefined} query + * @param {string | undefined} method + * @param {unknown} body + */ +const createGlabApiArgs = (hostname, path, query, method, body) => { + const url = new URL(path, 'https://gitlab.local'); + if (query) { + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, String(value)); + } + } + const resource = `${url.pathname}${url.search}`; + /** @type {Array} */ + const args = ['api', '--hostname', hostname, resource]; + if (method && method !== 'GET') { + args.push('--method', method); + } + if (body != null) { + args.push('--header', 'Content-Type: application/json', '--input', '-'); + } + return args; +}; + +/** + * @param {string} repoRoot + * @param {string} hostname + * @param {ReadonlyArray} args + * @param {unknown} [input] + */ +const runGlabApi = (repoRoot, hostname, args, input) => + new Promise((resolve, reject) => { + let command; + try { + command = getGlabCommand(); + } catch (error) { + reject(error); + return; + } + + const child = spawn(command, args, { + cwd: repoRoot, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.on('error', (error) => { + reject(/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT' ? createGlabNotFoundError() : error); + }); + child.on('close', (code) => { + if (code === 0) { + resolve(Buffer.concat(stdout).toString('utf8')); + } else { + reject( + new Error( + Buffer.concat(stderr).toString('utf8').trim() || `glab api exited with code ${code}.`, + ), + ); + } + }); + if (input == null) { + child.stdin.end(); + } else { + child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input)); + } + }); + +/** + * @param {{ hostname: string, repoRoot: string }} options + */ +const createGlabGitLabTransport = ({ hostname, repoRoot }) => { + /** + * @param {{ + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE', + * path: string, + * query?: Readonly>, + * body?: unknown, + * }} request + */ + const requestText = async (request) => { + const args = createGlabApiArgs( + hostname, + request.path, + request.query, + request.method, + request.body, + ); + return runGlabApi(repoRoot, hostname, args, request.body); + }; + + return { + /** + * @template T + * @param {{ + * method?: 'GET' | 'POST' | 'PUT' | 'DELETE', + * path: string, + * query?: Readonly>, + * body?: unknown, + * }} request + * @returns {Promise} + */ + async request(request) { + const text = await requestText(request); + if (!text.trim()) { + return /** @type {T} */ (null); + } + return /** @type {T} */ (JSON.parse(text)); + }, + requestText, + }; +}; + +module.exports = { + GLAB_NOT_FOUND_CODE, + createGlabGitLabTransport, + createGlabNotFoundError, + getGlabCommand, +}; diff --git a/electron/git-state/merge-request.cjs b/electron/git-state/merge-request.cjs index ddf179dc..5fbc7d91 100644 --- a/electron/git-state/merge-request.cjs +++ b/electron/git-state/merge-request.cjs @@ -740,7 +740,10 @@ const readMergeRequestImageContent = async (launchPath, source, requestedPath) = } }; +const { createGlabGitLabTransport, getGlabCommand: getGlabCommandFromTransport } = require('./glab-gitlab-transport.cjs'); + module.exports = { + createGlabGitLabTransport, createGitLabPosition, createMergeRequestFetchRefspecs, listMergeRequestHistory, From 8f17d3293e1b5bab5e61b16fae9a93470284f5f5 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:54:28 -0500 Subject: [PATCH 09/10] Load GitLab review history in local Codiff Bridge the shared GitLab provider package into Electron and implement local version, comparison, evolution, and unit-diff loading for merge requests. Keep this backend provider-specific so its CLI transport and projection behavior can be reviewed independently from GitHub force-push handling. --- .../__tests__/gitlab-review-history.test.ts | 90 ++++++ electron/git-state/gitlab-review-history.cjs | 291 ++++++++++++++++++ electron/gitlab-history-bridge.cjs | 23 ++ 3 files changed, 404 insertions(+) create mode 100644 electron/__tests__/gitlab-review-history.test.ts create mode 100644 electron/git-state/gitlab-review-history.cjs create mode 100644 electron/gitlab-history-bridge.cjs diff --git a/electron/__tests__/gitlab-review-history.test.ts b/electron/__tests__/gitlab-review-history.test.ts new file mode 100644 index 00000000..acedbc94 --- /dev/null +++ b/electron/__tests__/gitlab-review-history.test.ts @@ -0,0 +1,90 @@ +import { chmod, mkdtemp, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, test } from 'vite-plus/test'; + +const require = createRequire(import.meta.url); +const { listGitLabReviewVersions } = require('../git-state/gitlab-review-history.cjs') as { + listGitLabReviewVersions: ( + repoRoot: string, + source: { + host: string; + number: number; + projectPath: string; + provider: 'gitlab'; + type: 'pull-request'; + url: string; + }, + ) => Promise< + ReadonlyArray<{ + id: string; + isHead?: boolean; + number?: number; + range: { head: { commitId: string } }; + }> + >; +}; + +const previousGlabPath = process.env.CODIFF_GLAB_PATH; + +afterEach(() => { + if (previousGlabPath == null) { + delete process.env.CODIFF_GLAB_PATH; + } else { + process.env.CODIFF_GLAB_PATH = previousGlabPath; + } +}); + +test('listGitLabReviewVersions projects glab version payloads into Core options', async () => { + const directory = await mkdtemp(join(tmpdir(), 'codiff-gitlab-history-')); + const fakeGlabPath = join(directory, 'glab'); + await writeFile( + fakeGlabPath, + `#!/usr/bin/env node +const args = process.argv.slice(2); +const resource = args.find((arg) => arg.startsWith('/api/')) || args.at(-1) || ''; +process.stdin.resume(); +process.stdin.on('end', () => { + if (resource.includes('/merge_requests/7/versions')) { + process.stdout.write(JSON.stringify([ + { + id: 2, + head_commit_sha: ${JSON.stringify('c'.repeat(40))}, + base_commit_sha: ${JSON.stringify('a'.repeat(40))}, + start_commit_sha: ${JSON.stringify('a'.repeat(40))}, + created_at: '2026-01-02T00:00:00.000Z', + }, + { + id: 1, + head_commit_sha: ${JSON.stringify('b'.repeat(40))}, + base_commit_sha: ${JSON.stringify('a'.repeat(40))}, + start_commit_sha: ${JSON.stringify('a'.repeat(40))}, + created_at: '2026-01-01T00:00:00.000Z', + }, + ])); + } else { + process.stdout.write('[]'); + } + process.exit(0); +}); +`, + 'utf8', + ); + await chmod(fakeGlabPath, 0o755); + process.env.CODIFF_GLAB_PATH = fakeGlabPath; + + const versions = await listGitLabReviewVersions(directory, { + host: 'gitlab.example.com', + number: 7, + projectPath: 'group/project', + provider: 'gitlab', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/7', + }); + + expect(versions.map((version) => version.id)).toEqual(['mr-base', '1', '2']); + expect(versions[0]?.number).toBe(0); + expect(versions[2]?.isHead).toBe(true); + expect(versions[2]?.range.head.commitId).toBe('c'.repeat(40)); +}); diff --git a/electron/git-state/gitlab-review-history.cjs b/electron/git-state/gitlab-review-history.cjs new file mode 100644 index 00000000..d4d13a4c --- /dev/null +++ b/electron/git-state/gitlab-review-history.cjs @@ -0,0 +1,291 @@ +// @ts-check + +/** + * Local GitLab review-history adapter over glab transport + @nkzw/codiff-gitlab. + */ + +const { createGlabGitLabTransport } = require('./glab-gitlab-transport.cjs'); +const { loadGitLabHistory } = require('../gitlab-history-bridge.cjs'); + +/** + * @typedef {import('../../core/types.ts').ChangedFile} ChangedFile + * @typedef {import('../../core/types.ts').DiffComparisonView} DiffComparisonView + * @typedef {import('../../core/types.ts').ReviewCommitEvolution} ReviewCommitEvolution + * @typedef {import('../../core/types.ts').ReviewSource} ReviewSource + * @typedef {import('../../core/types.ts').ReviewVersionOption} ReviewVersionOption + * @typedef {Extract} PullRequestSource + */ + +const MR_BASE_VERSION_ID = 'mr-base'; + +/** + * @param {PullRequestSource} source + */ +const assertGitLabSource = (source) => { + if (source.provider !== 'gitlab') { + throw new Error('GitLab review history requires a GitLab merge request source.'); + } + if (!source.host?.trim()) { + throw new Error('GitLab review history requires a host on the merge request source.'); + } + if (!source.projectPath?.trim()) { + throw new Error('GitLab review history requires a projectPath on the merge request source.'); + } + if (!source.number || !Number.isInteger(source.number) || source.number <= 0) { + throw new Error('GitLab review history requires a merge request number.'); + } + return { + host: source.host, + iid: source.number, + projectPath: source.projectPath, + }; +}; + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + */ +const createTransportForSource = (repoRoot, source) => { + const { host } = assertGitLabSource(source); + return createGlabGitLabTransport({ hostname: host, repoRoot }); +}; + +/** + * @param {import('../../gitlab/src/version-compare.ts').MergeRequestVersionRef} version + * @param {{ + * diffStat?: ReviewVersionOption['diffStat'], + * isHead?: boolean, + * number?: number, + * previousCreatedAt?: string, + * previousNumber?: number, + * }} [extra] + * @param {typeof import('../../gitlab/dist/index.mjs')} gitlab + */ +const toReviewVersionOption = (version, extra, gitlab) => + gitlab.projectMergeRequestVersionRef({ + ...version, + ...(extra?.diffStat ? { diffStat: extra.diffStat } : {}), + ...(extra?.isHead != null ? { isHead: extra.isHead } : {}), + ...(extra?.number != null ? { number: extra.number } : {}), + ...(extra?.previousCreatedAt ? { previousCreatedAt: extra.previousCreatedAt } : {}), + ...(extra?.previousNumber != null ? { previousNumber: extra.previousNumber } : {}), + }); + +/** + * Build Core version options including a synthetic MR-base endpoint so the UI + * can compare base → v1 the same way Web does. + * + * @param {string} repoRoot + * @param {PullRequestSource} source + * @returns {Promise>} + */ +const listGitLabReviewVersions = async (repoRoot, source) => { + const { iid, projectPath } = assertGitLabSource(source); + const transport = createTransportForSource(repoRoot, source); + const gitlab = await loadGitLabHistory(); + const versions = await gitlab.fetchGitLabMergeRequestVersions({ + iid, + projectPath, + transport, + }); + if (versions.length === 0) { + return []; + } + + // Oldest → newest for picker defaults (from=previous, to=head). + const chronological = [...versions].toReversed(); + const first = chronological[0]; + const baseOption = toReviewVersionOption( + { + baseSha: first.baseSha, + createdAt: first.createdAt, + headSha: first.baseSha, + id: MR_BASE_VERSION_ID, + label: 'MR base', + startSha: first.baseSha, + }, + { + isHead: false, + number: 0, + }, + gitlab, + ); + + const options = chronological.map((version, index) => + toReviewVersionOption( + version, + { + isHead: index === chronological.length - 1, + number: index + 1, + ...(index > 0 + ? { + previousCreatedAt: chronological[index - 1].createdAt, + previousNumber: index, + } + : {}), + }, + gitlab, + ), + ); + + return [baseOption, ...options]; +}; + +/** + * @param {string} versionId + * @param {ReadonlyArray} versions + * @returns {import('../../gitlab/src/version-compare.ts').VersionCompareEndpoint} + */ +const toCompareEndpoint = (versionId, versions) => { + if (versionId === MR_BASE_VERSION_ID) { + return { kind: 'mr-base' }; + } + if (versions.some((version) => version.id === versionId)) { + return { kind: 'mr-version', versionId }; + } + // Fall back to head-sha if the UI passed a synthetic head id. + if (/^[0-9a-f]{7,40}$/i.test(versionId)) { + return { headSha: versionId, kind: 'head-sha' }; + } + return { kind: 'mr-version', versionId }; +}; + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + * @param {{ fromId: string, toId: string }} range + * @returns {Promise<{ + * versionCompare: DiffComparisonView, + * versionCommitEvolution: ReviewCommitEvolution | null, + * versionCommitEvolutionError: string | null, + * }>} + */ +const compareGitLabReviewVersions = async (repoRoot, source, range) => { + const { iid, projectPath } = assertGitLabSource(source); + const transport = createTransportForSource(repoRoot, source); + const gitlab = await loadGitLabHistory(); + const versions = await gitlab.fetchGitLabMergeRequestVersions({ + iid, + projectPath, + transport, + }); + if (versions.length === 0) { + throw new Error('GitLab did not return merge request versions.'); + } + + const fromEndpoint = toCompareEndpoint(range.fromId, versions); + const toEndpoint = toCompareEndpoint(range.toId, versions); + + const [compareResult, evolutionResult] = await Promise.allSettled([ + gitlab.fetchGitLabMergeRequestVersionCompare({ + from: fromEndpoint, + iid, + projectPath, + to: toEndpoint, + transport, + }), + gitlab.fetchGitLabMergeRequestVersionCommitEvolution({ + from: fromEndpoint, + iid, + projectPath, + to: toEndpoint, + transport, + }), + ]); + + if (compareResult.status !== 'fulfilled') { + throw compareResult.reason instanceof Error + ? compareResult.reason + : new Error(String(compareResult.reason)); + } + + const versionCompare = gitlab.projectVersionCompare(compareResult.value); + if (evolutionResult.status === 'fulfilled') { + return { + versionCommitEvolution: gitlab.projectCommitEvolution(evolutionResult.value), + versionCommitEvolutionError: null, + versionCompare, + }; + } + + return { + versionCommitEvolution: null, + versionCommitEvolutionError: + evolutionResult.reason instanceof Error + ? evolutionResult.reason.message + : String(evolutionResult.reason), + versionCompare, + }; +}; + +/** + * @param {string} repoRoot + * @param {PullRequestSource} source + * @param {import('../../gitlab/src/version-commit-evolution.ts').VersionCommitEvolutionUnit | import('../../core/types.ts').ReviewEvolutionUnit} unit + * @returns {Promise>} + */ +const loadGitLabVersionCommitUnitDiff = async (repoRoot, source, unit) => { + const { projectPath } = assertGitLabSource(source); + const transport = createTransportForSource(repoRoot, source); + const gitlab = await loadGitLabHistory(); + // Core projected units use kind names like 'introduced'/'removed'; package + // algorithm units use 'added'/'removed'/'likely-revised'. Prefer the raw unit + // shape when the host still holds algorithm units; otherwise reconstruct. + /** @type {any} */ + const algorithmUnit = unit; + if ( + algorithmUnit.kind === 'added' || + algorithmUnit.kind === 'removed' || + algorithmUnit.kind === 'likely-revised' || + algorithmUnit.kind === 'unchanged' || + algorithmUnit.kind === 'same-patch' || + algorithmUnit.kind === 'absorbed-into-base' || + algorithmUnit.kind === 'unmatched-old' || + algorithmUnit.kind === 'unmatched-new' + ) { + return gitlab.fetchGitLabVersionCommitUnitDiff({ + projectPath, + transport, + unit: algorithmUnit, + }); + } + + // Projected Core unit → best-effort mapping for reviewable units. + if (!algorithmUnit.reviewable) { + throw new Error('This commit evolution unit is not reviewable.'); + } + const mapped = { + confidence: algorithmUnit.confidence === 'exact' ? 'exact' : 'high', + id: algorithmUnit.id, + kind: + algorithmUnit.kind === 'introduced' + ? 'added' + : algorithmUnit.kind === 'removed' + ? 'removed' + : algorithmUnit.kind === 'revised' + ? 'likely-revised' + : algorithmUnit.kind === 'rewritten-same-patch' + ? 'same-patch' + : algorithmUnit.kind === 'retained' + ? 'unchanged' + : algorithmUnit.kind === 'absorbed-into-base' + ? 'absorbed-into-base' + : 'unmatched-new', + reviewable: true, + ...(algorithmUnit.after ? { after: algorithmUnit.after } : {}), + ...(algorithmUnit.before ? { before: algorithmUnit.before } : {}), + ...(algorithmUnit.baseCommit ? { baseCommit: algorithmUnit.baseCommit } : {}), + }; + return gitlab.fetchGitLabVersionCommitUnitDiff({ + projectPath, + transport, + unit: mapped, + }); +}; + +module.exports = { + MR_BASE_VERSION_ID, + compareGitLabReviewVersions, + listGitLabReviewVersions, + loadGitLabVersionCommitUnitDiff, +}; diff --git a/electron/gitlab-history-bridge.cjs b/electron/gitlab-history-bridge.cjs new file mode 100644 index 00000000..2e193f0a --- /dev/null +++ b/electron/gitlab-history-bridge.cjs @@ -0,0 +1,23 @@ +// @ts-check + +/** + * CJS bridge to @nkzw/codiff-gitlab (ESM). + */ + +const { pathToFileURL } = require('node:url'); +const { join } = require('node:path'); + +/** @type {Promise | null} */ +let modulePromise = null; + +const loadGitLabHistory = () => { + if (!modulePromise) { + const modulePath = join(__dirname, '../gitlab/dist/index.mjs'); + modulePromise = import(pathToFileURL(modulePath).href); + } + return modulePromise; +}; + +module.exports = { + loadGitLabHistory, +}; From e64db1a4dff71917b6c955a1b169fb00d68fcfad Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Mon, 20 Jul 2026 16:54:28 -0500 Subject: [PATCH 10/10] Load GitHub force-push history in local Codiff Implement GitHub head-timeline, comparison, evolution, and unit-diff loading over `gh` plus local Git objects, then dispatch review-history requests by provider. This keeps GitHub rewrite semantics separate from the GitLab merge-request backend while sharing the same Core review model. Connect local review history to the shared host Expose provider-neutral review-history and walkthrough-generation IPC to `LocalMergeRequestReviewHost`, materialize comparison and commit-unit files, and refresh the local repository state after review actions. Wire agent generation progress through Electron at the same integration boundary so the shared progress UI receives coherent host events. Document local review-history parity and follow-ups Record the local host architecture, authentication requirements, provider-specific terminology, and remaining review findings after the shared packages are adopted. Keeping these notes at the local MR tip makes deferred Electron and GitHub issues visible without leaking local concerns into the Codiff Web foundation docs. --- bin/walkthrough-guide.md | 2 + core/App.css | 216 ++++++- core/App.tsx | 6 +- core/SharedWalkthroughApp.tsx | 367 ++++++++--- core/__tests__/App-render.test.tsx | 31 +- .../LocalMergeRequestReviewHost.test.tsx | 461 +++++++++++++- core/__tests__/ReviewCodeView-scroll.test.tsx | 11 +- .../generate-review-walkthrough.test.ts | 42 ++ .../narrative-walkthrough-view.test.ts | 48 ++ core/__tests__/walkthrough-authoring.test.ts | 4 +- core/app/LocalMergeRequestReviewHost.tsx | 587 ++++++++++++++++-- core/app/components/CommitScopePanel.tsx | 11 +- core/app/components/ReviewCodeView.tsx | 54 +- core/app/components/Sidebar.tsx | 36 +- core/app/hooks/useAppWalkthrough.ts | 10 +- core/global.d.ts | 37 ++ core/index.ts | 15 + core/lib/generate-review-walkthrough.ts | 32 +- core/lib/narrative-walkthrough.ts | 19 + core/lib/review-strategy.ts | 338 ++++++++++ core/types.ts | 84 +++ docs/plan-local-parity.md | 57 +- .../__tests__/github-review-history.test.ts | 86 +++ .../__tests__/glab-gitlab-transport.test.ts | 23 +- .../__tests__/narrative-walkthrough.test.ts | 12 +- .../__tests__/pull-request-review.test.ts | 71 ++- .../__tests__/walkthrough-progress.test.ts | 13 +- .../generate-review-walkthrough-bridge.cjs | 39 ++ .../github-history/gh-github-transport.cjs | 205 ++++++ .../github-history/github-review-history.cjs | 285 +++++++++ electron/git-state/gitlab-review-history.cjs | 45 +- electron/git-state/glab-gitlab-transport.cjs | 9 +- electron/git-state/merge-request.cjs | 5 +- electron/git-state/pull-request.cjs | 27 +- electron/git-state/review-history.cjs | 120 ++++ electron/github-history-bridge.cjs | 23 + electron/main.cjs | 369 +++++++++++ electron/narrative-walkthrough-schema.cjs | 9 + electron/narrative-walkthrough.cjs | 21 +- electron/preload.cjs | 16 + electron/walkthrough-authoring-bridge.cjs | 4 +- electron/walkthrough-progress.cjs | 10 +- github/__tests__/history.test.ts | 74 +++ github/src/history.ts | 41 +- gitlab/__tests__/history-transport.test.ts | 102 ++- gitlab/fixtures/version-compare-cases.ts | 12 +- gitlab/src/history.ts | 224 ++++--- gitlab/src/review-strategy.ts | 350 +---------- gitlab/src/transport.ts | 44 +- package.json | 1 + pnpm-lock.yaml | 47 +- pnpm-workspace.yaml | 4 +- 52 files changed, 3938 insertions(+), 821 deletions(-) create mode 100644 core/lib/review-strategy.ts create mode 100644 electron/__tests__/github-review-history.test.ts create mode 100644 electron/generate-review-walkthrough-bridge.cjs create mode 100644 electron/git-state/github-history/gh-github-transport.cjs create mode 100644 electron/git-state/github-history/github-review-history.cjs create mode 100644 electron/git-state/review-history.cjs create mode 100644 electron/github-history-bridge.cjs diff --git a/bin/walkthrough-guide.md b/bin/walkthrough-guide.md index 9ea7e979..77fb66e2 100644 --- a/bin/walkthrough-guide.md +++ b/bin/walkthrough-guide.md @@ -67,6 +67,8 @@ choose. actually support. - If a PR/MR description is available, use it only as author intent and orientation. Do not copy it into the walkthrough JSON; the diff and hunk ids remain the source of truth. +- For a Codiff-provided commit or version unit digest, author only that unit. Codiff composes + successful unit walkthroughs in commit order and retains each unit's scoped files. ## hunkId format diff --git a/core/App.css b/core/App.css index 113690e7..5fed7b70 100644 --- a/core/App.css +++ b/core/App.css @@ -1317,6 +1317,13 @@ html[data-codiff-platform='darwin'] .sidebar { width: 100%; } +.version-picker-trigger > *:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .version-picker-positioner { /* Base UI renders this in a portal, outside the sidebar's clipping context. */ z-index: 1000; @@ -1379,7 +1386,9 @@ html[data-codiff-platform='darwin'] .sidebar { min-height: 24px; } -.version-comparison-status.error { color: var(--danger); } +.version-comparison-status.error { + color: var(--danger); +} .version-comparison-spinner { animation: version-comparison-spin 0.8s linear infinite; @@ -1390,12 +1399,25 @@ html[data-codiff-platform='darwin'] .sidebar { width: 12px; } -@keyframes version-comparison-spin { to { transform: rotate(360deg); } } +@keyframes version-comparison-spin { + to { + transform: rotate(360deg); + } +} -.version-picker-head { color: var(--accent, var(--sidebar-text)); font-weight: 700; } -.version-picker-additions { color: var(--diff-addition); } -.version-picker-deletions { color: var(--diff-deletion); } -.version-picker-timing { color: var(--muted); } +.version-picker-head { + color: var(--accent, var(--sidebar-text)); + font-weight: 700; +} +.version-picker-additions { + color: var(--diff-addition); +} +.version-picker-deletions { + color: var(--diff-deletion); +} +.version-picker-timing { + color: var(--muted); +} .version-base-movement, .version-commit-evolution, @@ -1409,18 +1431,28 @@ html[data-codiff-platform='darwin'] .sidebar { padding: 7px; } -.version-base-movement a { color: inherit; font-family: var(--font-mono); } +.version-base-movement a { + color: inherit; + font-family: var(--font-mono); +} .version-base-movement-stat, .version-base-movement small, .version-commit-evolution small, -.version-walkthrough-structure small { color: var(--muted); } +.version-walkthrough-structure small { + color: var(--muted); +} .version-base-movement-stat { font: 11px/1.45 var(--font-sans); letter-spacing: 0; text-transform: none; } -.version-base-movement small { display: block; margin-top: 3px; } -.version-base-movement-commits { margin-top: 6px; } +.version-base-movement small { + display: block; + margin-top: 3px; +} +.version-base-movement-commits { + margin-top: 6px; +} .version-base-movement-commits > summary { color: var(--muted); cursor: pointer; @@ -1430,14 +1462,18 @@ html[data-codiff-platform='darwin'] .sidebar { text-transform: none; user-select: none; } -.version-base-movement-commits > summary::-webkit-details-marker { display: none; } +.version-base-movement-commits > summary::-webkit-details-marker { + display: none; +} .version-base-movement-commits > summary::before { content: '▸'; display: inline-block; margin-right: 4px; transition: transform 0.12s ease; } -.version-base-movement-commits[open] > summary::before { transform: rotate(90deg); } +.version-base-movement-commits[open] > summary::before { + transform: rotate(90deg); +} .version-base-movement-commit-list { margin-top: 5px; max-height: 220px; @@ -1456,9 +1492,15 @@ html[data-codiff-platform='darwin'] .sidebar { .version-base-movement-commit:hover { background: rgb(127 127 127 / 0.14); } -.version-commit-evolution > strong { display: block; margin-bottom: 4px; } +.version-commit-evolution > strong { + display: block; + margin-bottom: 4px; +} -.version-commit-evolution-list { display: grid; gap: 1px; } +.version-commit-evolution-list { + display: grid; + gap: 1px; +} .version-commit-unit { align-items: center; background: transparent; @@ -1469,17 +1511,32 @@ html[data-codiff-platform='darwin'] .sidebar { display: grid; font: 10px/1.35 var(--font-sans); gap: 5px; - grid-template-columns: 12px auto minmax(0, 1fr); + grid-template-columns: max-content max-content minmax(0, 1fr); padding: 4px; text-align: left; } -.version-commit-unit:disabled { cursor: default; } +.version-commit-unit:disabled { + cursor: default; +} .version-commit-unit:not(:disabled):hover, -.version-commit-unit[aria-pressed='true'] { background: rgb(127 127 127 / 0.14); } -.version-commit-unit code { font-size: 9px; white-space: nowrap; } -.version-commit-unit > span:nth-child(3) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.version-commit-unit.unchanged { opacity: 0.5; } -.version-commit-unit.unchanged:not(:disabled):hover { opacity: 0.8; } +.version-commit-unit[aria-pressed='true'] { + background: rgb(127 127 127 / 0.14); +} +.version-commit-unit code { + font-size: 9px; + white-space: nowrap; +} +.version-commit-unit > span:nth-child(3) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.version-commit-unit.unchanged { + opacity: 0.5; +} +.version-commit-unit.unchanged:not(:disabled):hover { + opacity: 0.8; +} .version-commit-kind-pill { align-items: center; background: color-mix(in srgb, currentColor 12%, transparent); @@ -1511,7 +1568,10 @@ html[data-codiff-platform='darwin'] .sidebar { .version-commit-kind-pill.unchanged { color: var(--muted); } -.version-commit-unit-block { display: grid; gap: 1px; } +.version-commit-unit-block { + display: grid; + gap: 1px; +} .version-commit-rebase-drivers { display: grid; gap: 1px; @@ -1528,9 +1588,18 @@ html[data-codiff-platform='darwin'] .sidebar { opacity: 0.88; } - -.version-unit-scope { align-items: center; display: flex; justify-content: space-between; } -.version-unit-scope button { background: transparent; border: 0; color: var(--accent, var(--sidebar-text)); cursor: pointer; font-size: 10px; } +.version-unit-scope { + align-items: center; + display: flex; + justify-content: space-between; +} +.version-unit-scope button { + background: transparent; + border: 0; + color: var(--accent, var(--sidebar-text)); + cursor: pointer; + font-size: 10px; +} .version-tree-commit-scope { border-bottom: 1px solid var(--sidebar-border); display: grid; @@ -1594,17 +1663,35 @@ html[data-codiff-platform='darwin'] .sidebar { text-overflow: ellipsis; white-space: nowrap; } -.version-walkthrough-structure { display: grid; gap: 5px; margin-bottom: 10px; } -.version-walkthrough-structure label { align-items: center; display: flex; gap: 5px; } -.version-walkthrough-structure .sidebar-version-compare-button { margin-top: 3px; } +.version-walkthrough-structure { + display: grid; + gap: 5px; + margin-bottom: 10px; +} +.version-walkthrough-structure label { + align-items: center; + display: flex; + gap: 5px; +} +.version-walkthrough-structure .sidebar-version-compare-button { + margin-top: 3px; +} @media (max-width: 480px) { - .version-picker-popover { min-width: calc(100vw - 24px); } - .version-picker-option { gap: 3px; grid-template-columns: 29px 31px minmax(44px, 1fr) auto auto auto 24px; } + .version-picker-popover { + min-width: calc(100vw - 24px); + } + .version-picker-option { + gap: 3px; + grid-template-columns: 29px 31px minmax(44px, 1fr) auto auto auto 24px; + } .version-picker-option { grid-template-columns: 29px 31px minmax(44px, 1fr) auto auto auto; } - .version-picker-timing { grid-column: 1 / -1; padding-left: 60px; } + .version-picker-timing { + grid-column: 1 / -1; + padding-left: 60px; + } } .sidebar-diff-scope-header { align-items: center; @@ -2250,6 +2337,46 @@ html[data-codiff-platform='darwin'] .sidebar { width: 100%; } +.sidebar-walkthrough-status-stack { + display: grid; + min-width: 0; + width: 100%; +} + +.walkthrough-generation-structure { + border-bottom: 1px solid var(--sidebar-border); + display: grid; + gap: 5px; + min-width: 0; + padding: 8px 12px; +} + +.walkthrough-generation-structure strong, +.walkthrough-generation-structure small { + overflow-wrap: anywhere; +} + +.walkthrough-generation-structure strong { + color: var(--sidebar-text); + font: 700 11px/1.35 var(--font-sans); +} + +.walkthrough-generation-structure small { + color: var(--muted); + font: 10px/1.35 var(--font-mono); +} + +.walkthrough-generation-structure button { + background: transparent; + border: 0; + color: var(--accent, var(--sidebar-text)); + cursor: pointer; + font: 11px/1.35 var(--font-sans); + justify-self: start; + padding: 0; + text-align: left; +} + .sidebar-walkthrough-status { border-bottom: 1px solid var(--sidebar-border); color: var(--muted); @@ -5203,6 +5330,31 @@ diffs-container .review-comment-thread { } @container sidebar (max-width: 260px) { + .version-comparison-header { + align-items: stretch; + grid-template-columns: minmax(0, 1fr); + } + + .diff-scope-control { + width: 100%; + } + + .version-picker-pair { + grid-template-columns: minmax(0, 1fr); + } + + .version-comparison-endpoint { + flex-wrap: wrap; + min-width: 0; + overflow-wrap: anywhere; + } + + .version-unit-scope { + align-items: flex-start; + display: grid; + gap: 4px; + } + .sidebar-mode-toggle { gap: 4px; grid-template-columns: minmax(42px, 0.75fr) minmax(82px, 1.35fr) minmax(58px, 0.9fr); @@ -5225,7 +5377,6 @@ diffs-container .review-comment-thread { } } - .walkthrough-regenerate-controls { align-items: stretch !important; display: grid !important; @@ -5246,7 +5397,6 @@ diffs-container .review-comment-thread { width: 100%; } - .walkthrough-structure-controls { align-items: stretch !important; display: grid !important; diff --git a/core/App.tsx b/core/App.tsx index 0f5ad074..f799cbcf 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -1595,7 +1595,11 @@ export default function App() { ); } - if (shouldUseLocalMergeRequestHost(state.source)) { + if ( + shouldUseLocalMergeRequestHost(state.source) && + !launchOptions.walkthrough && + !launchOptions.walkthroughFile + ) { return ( >; + /** Shown in external-link tooltips (for example GitLab or GitHub). */ + providerLabel?: string; reviewStrategy?: MergeRequestReviewStrategySummary | null; selectedCommitSha?: string | null; settingsBar?: ReactNode; @@ -268,13 +271,18 @@ export type MergeRequestReviewAppProps = { versionCompareFromId?: string | null; versionCompareLoading?: boolean; versionCompareToId?: string | null; + /** Sidebar history section title. GitLab: Versions; GitHub: Head history. */ + versionHistoryLabel?: string; versionHistoryLoading?: boolean; + versionHistoryWarning?: string | null; versions?: ReadonlyArray; versionWalkthroughStructure?: 'commit-by-commit' | 'whole-diff'; walkthrough: NarrativeWalkthrough | null; walkthroughError?: string | null; walkthroughProgress?: WalkthroughGenerationProgress | null; walkthroughStatus: MergeRequestWalkthroughStatus; + /** Baseline scope label. GitLab: Whole MR; GitHub: Whole PR. */ + wholeDiffLabel?: string; }; const getCodeFontLineHeight = (size: number) => Math.round((size * 20) / 13); @@ -1402,9 +1410,14 @@ export type ReviewSurfaceProps = { versionCompareFromId?: string | null; versionCompareLoading?: boolean; versionCompareToId?: string | null; + /** Sidebar history section title. GitLab: Versions; GitHub: Head history. */ + versionHistoryLabel?: string; versionHistoryLoading?: boolean; + versionHistoryWarning?: string | null; versions?: ReadonlyArray; versionWalkthroughStructure?: 'commit-by-commit' | 'whole-diff'; + /** Baseline scope label. GitLab: Whole MR; GitHub: Whole PR. */ + wholeDiffLabel?: string; }; const shortRelativeTime = (value: string) => { @@ -1756,9 +1769,12 @@ export function ReviewSurface({ versionCompareFromId = null, versionCompareLoading = false, versionCompareToId = null, + versionHistoryLabel = 'Versions', versionHistoryLoading = false, + versionHistoryWarning = null, versions = [], versionWalkthroughStructure: versionWalkthroughStructureProp, + wholeDiffLabel = 'Whole MR', }: ReviewSurfaceProps) { const canComment = commenting?.canComment ?? Boolean(interactive); const deleteShare = useCallback(async () => { @@ -1775,6 +1791,7 @@ export function ReviewSurface({ } }, [onDeleteShare]); const submitReviewComment = commenting?.onSubmitComment ?? interactive?.onSubmitComment; + const canSubmitReviewComments = canComment && submitReviewComment != null; const submitGeneralDiscussion = commenting?.onSubmitGeneralComment ?? interactive?.onSubmitGeneralComment; const updateReviewComment = commenting?.onUpdateComment ?? interactive?.onUpdateComment; @@ -1825,10 +1842,12 @@ export function ReviewSurface({ const [versionSectionExpanded, setVersionSectionExpanded] = useState(true); useEffect(() => { - setSelectedVersionUnitIds(new Set()); - setVersionUnitFiles({}); - setVersionUnitLoadingIds(new Set()); - setVersionUnitErrors({}); + queueMicrotask(() => { + setSelectedVersionUnitIds(new Set()); + setVersionUnitFiles({}); + setVersionUnitLoadingIds(new Set()); + setVersionUnitErrors({}); + }); versionUnitScopeRef.current += 1; }, [versionCompare?.from.id, versionCompare?.to.id]); @@ -1837,10 +1856,14 @@ export function ReviewSurface({ return; } if (versionWalkthroughStructureProp == null) { - setVersionWalkthroughStructureState(versionCommitEvolution.recommendation.suggestedStructure); - onVersionWalkthroughStructureChange?.( - versionCommitEvolution.recommendation.suggestedStructure, - ); + queueMicrotask(() => { + setVersionWalkthroughStructureState( + versionCommitEvolution.recommendation.suggestedStructure, + ); + onVersionWalkthroughStructureChange?.( + versionCommitEvolution.recommendation.suggestedStructure, + ); + }); } }, [ onVersionWalkthroughStructureChange, @@ -1941,25 +1964,51 @@ export function ReviewSurface({ const [treeCommitDiffError, setTreeCommitDiffError] = useState(null); const [treeCommitDiffLoading, setTreeCommitDiffLoading] = useState(false); const commitLoadRequestRef = useRef(0); - // CBC walkthroughs are authored against unit-scoped commitFiles. Whole-diff - // walkthroughs are authored against the aggregate version comparison. + const walkthroughCommitShas = useMemo( + () => getWalkthroughCommitDiffShas(sharedWalkthrough), + [sharedWalkthrough], + ); + const isCommitByCommitWalkthrough = versionCompare + ? versionWalkthroughStructure === 'commit-by-commit' + : interactive?.reviewStrategy?.mode === 'commit-by-commit' || + sharedWalkthrough.chapters.some((chapter) => chapter.commit != null); + const legacyWalkthroughNeedsCommitDiffs = + isCommitByCommitWalkthrough && sharedWalkthrough.commitFiles == null; + const missingWalkthroughCommitShas = useMemo( + () => + legacyWalkthroughNeedsCommitDiffs + ? walkthroughCommitShas.filter((sha) => !commitFilesBySha[sha]) + : [], + [commitFilesBySha, legacyWalkthroughNeedsCommitDiffs, walkthroughCommitShas], + ); + // CBC walkthroughs are authored against the complete, unit-scoped + // commitFiles set. Legacy cached walkthroughs have no such set, so they + // remain empty until every chapter diff has loaded. const walkthroughFiles = useMemo( () => - versionCompare - ? resolveVersionWalkthroughFiles({ - commitFiles: sharedWalkthrough.commitFiles, - structure: versionWalkthroughStructure, - versionFiles: versionCompare.files, - }) - : walkthroughCommitSha - ? (commitFilesBySha[walkthroughCommitSha] ?? []) - : [...snapshot.files, ...(sharedWalkthrough.commitFiles ?? [])], + legacyWalkthroughNeedsCommitDiffs + ? missingWalkthroughCommitShas.length === 0 + ? combineWalkthroughCommitFiles(walkthroughCommitShas, commitFilesBySha) + : [] + : versionCompare + ? resolveVersionWalkthroughFiles({ + commitFiles: sharedWalkthrough.commitFiles, + structure: versionWalkthroughStructure, + versionFiles: versionCompare.files, + }) + : isCommitByCommitWalkthrough + ? (sharedWalkthrough.commitFiles ?? []) + : [...snapshot.files, ...(sharedWalkthrough.commitFiles ?? [])], [ commitFilesBySha, + isCommitByCommitWalkthrough, + legacyWalkthroughNeedsCommitDiffs, + missingWalkthroughCommitShas.length, sharedWalkthrough.commitFiles, snapshot.files, versionCompare, - walkthroughCommitSha, + versionWalkthroughStructure, + walkthroughCommitShas, ], ); const navigation = useNarrativeNavigation( @@ -2004,7 +2053,7 @@ export function ReviewSurface({ const commitSha = chapter?.commit?.sha ?? commits.find((commit) => chapter?.id.startsWith(`${commit.sha}:`))?.sha; - setWalkthroughCommitSha(commitSha ?? null); + queueMicrotask(() => setWalkthroughCommitSha(commitSha ?? null)); } }, [ navigation.index, @@ -2026,6 +2075,14 @@ export function ReviewSurface({ walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; }; } | null>(null); + const queuedWalkthroughGenerationOptionsRef = + useRef(null); + const [queuedReviewStructure, setQueuedReviewStructure] = useState< + 'commit-by-commit' | 'whole-diff' | null + >(null); + const [baselineWalkthroughStructureOverride, setBaselineWalkthroughStructureOverride] = useState< + 'commit-by-commit' | 'whole-diff' | null + >(null); const snapshotReviewComments = useMemo(() => getSnapshotReviewComments(snapshot), [snapshot]); const [editedReviewCommentBodies, setEditedReviewCommentBodies] = useState< Readonly> @@ -2105,11 +2162,13 @@ export function ReviewSurface({ () => orderedAIReviews[0]?.id ?? null, ); useEffect(() => { - setSelectedAIReviewId((current) => - orderedAIReviews.some((review) => review.id === current) - ? current - : (orderedAIReviews[0]?.id ?? null), - ); + queueMicrotask(() => { + setSelectedAIReviewId((current) => + orderedAIReviews.some((review) => review.id === current) + ? current + : (orderedAIReviews[0]?.id ?? null), + ); + }); }, [orderedAIReviews]); const selectedAIReview = orderedAIReviews.find((review) => review.id === selectedAIReviewId) ?? @@ -2157,10 +2216,7 @@ export function ReviewSurface({ [commitFilesBySha, selectedTreeCommitShas], ); const versionCompareActive = - versionCompareEnabled === true || - versionCompare != null || - versionCompareLoading || - Boolean(versionCompareError); + versionCompareEnabled === true || versionCompare != null || versionCompareLoading; const versionCompareChangedPaths = useMemo(() => { const files = sidebarMode === 'walkthrough' && versionCompare @@ -2224,16 +2280,57 @@ export function ReviewSurface({ useEffect(() => { if (selectedCommitSha) { - setActiveCommitSha(selectedCommitSha); - setSelectedTreeCommitShas(new Set([selectedCommitSha])); + queueMicrotask(() => { + setActiveCommitSha(selectedCommitSha); + setSelectedTreeCommitShas(new Set([selectedCommitSha])); + }); } }, [selectedCommitSha]); const commitDiffTargetSha = - sidebarMode === 'walkthrough' || sidebarMode === 'commits' - ? (walkthroughCommitSha ?? activeCommitSha) - : null; + sidebarMode === 'commits' + ? activeCommitSha + : sidebarMode === 'walkthrough' && !legacyWalkthroughNeedsCommitDiffs + ? walkthroughCommitSha + : null; useEffect(() => { + if (sidebarMode === 'walkthrough' && legacyWalkthroughNeedsCommitDiffs) { + if (missingWalkthroughCommitShas.length === 0 || !interactive?.onLoadCommitDiff) { + return; + } + const requestId = commitLoadRequestRef.current + 1; + commitLoadRequestRef.current = requestId; + queueMicrotask(() => { + setCommitDiffLoading(true); + setCommitDiffError(null); + }); + void Promise.all( + missingWalkthroughCommitShas.map((sha) => + Promise.resolve(interactive.onLoadCommitDiff!(sha)).then((files) => ({ files, sha })), + ), + ) + .then((results) => { + if (commitLoadRequestRef.current !== requestId) { + return; + } + setCommitFilesBySha((current) => ({ + ...current, + ...Object.fromEntries(results.map(({ files, sha }) => [sha, files])), + })); + }) + .catch((error: unknown) => { + if (commitLoadRequestRef.current !== requestId) { + return; + } + setCommitDiffError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + if (commitLoadRequestRef.current === requestId) { + setCommitDiffLoading(false); + } + }); + return; + } if ( (sidebarMode !== 'commits' && sidebarMode !== 'walkthrough') || !commitDiffTargetSha || @@ -2246,8 +2343,10 @@ export function ReviewSurface({ } const requestId = commitLoadRequestRef.current + 1; commitLoadRequestRef.current = requestId; - setCommitDiffLoading(true); - setCommitDiffError(null); + queueMicrotask(() => { + setCommitDiffLoading(true); + setCommitDiffError(null); + }); void Promise.resolve(interactive.onLoadCommitDiff(commitDiffTargetSha)) .then((files) => { if (commitLoadRequestRef.current !== requestId) { @@ -2267,7 +2366,14 @@ export function ReviewSurface({ setCommitDiffLoading(false); } }); - }, [commitDiffTargetSha, commitFilesBySha, interactive, sidebarMode]); + }, [ + commitDiffTargetSha, + commitFilesBySha, + interactive, + legacyWalkthroughNeedsCommitDiffs, + missingWalkthroughCommitShas, + sidebarMode, + ]); useEffect(() => { if ( @@ -2283,8 +2389,10 @@ export function ReviewSurface({ return; } let cancelled = false; - setTreeCommitDiffLoading(true); - setTreeCommitDiffError(null); + queueMicrotask(() => { + setTreeCommitDiffLoading(true); + setTreeCommitDiffError(null); + }); void Promise.all( missingShas.map((sha) => Promise.resolve(interactive.onLoadCommitDiff!(sha)).then((files) => ({ files, sha })), @@ -2672,6 +2780,18 @@ export function ReviewSurface({ interactiveRef.current = interactive; }, [interactive]); + const beginWalkthroughGeneration = useCallback( + (options: typeof walkthroughGenerationOptionsRef.current) => { + walkthroughGenerationOptionsRef.current = options; + walkthroughRequestPendingRef.current = true; + walkthroughRequestForceRef.current = options?.force === true; + setWalkthroughRequestForce(options?.force === true); + setWalkthroughRequestPending(true); + setWalkthroughRequestId((current) => current + 1); + }, + [], + ); + useEffect(() => { if (!walkthroughRequestPending || walkthroughRequestId === 0) { return; @@ -2690,11 +2810,17 @@ export function ReviewSurface({ walkthroughRequestForceRef.current = false; setWalkthroughRequestPending(false); setWalkthroughRequestForce(false); + const queuedOptions = queuedWalkthroughGenerationOptionsRef.current; + if (queuedOptions) { + queuedWalkthroughGenerationOptionsRef.current = null; + setQueuedReviewStructure(null); + queueMicrotask(() => beginWalkthroughGeneration(queuedOptions)); + } }); return () => { cancelled = true; }; - }, [walkthroughRequestId, walkthroughRequestPending]); + }, [beginWalkthroughGeneration, walkthroughRequestId, walkthroughRequestPending]); const startWalkthroughGeneration = useCallback( (options?: { @@ -2707,22 +2833,24 @@ export function ReviewSurface({ walkthroughStructure?: 'auto' | 'commit-by-commit' | 'whole-diff'; }; }) => { + if (options?.reviewStructure) { + setBaselineWalkthroughStructureOverride(options.reviewStructure); + } if ( !interactive || interactive.walkthroughStatus === 'generating' || walkthroughRequestPendingRef.current ) { + if (interactive && options?.force && options.reviewStructure) { + queuedWalkthroughGenerationOptionsRef.current = options; + setQueuedReviewStructure(options.reviewStructure); + } return; } - walkthroughGenerationOptionsRef.current = options ?? null; - walkthroughRequestPendingRef.current = true; - walkthroughRequestForceRef.current = options?.force === true; - setWalkthroughRequestForce(options?.force === true); - setWalkthroughRequestPending(true); - setWalkthroughRequestId((current) => current + 1); + beginWalkthroughGeneration(options ?? null); }, - [interactive], + [beginWalkthroughGeneration, interactive], ); useEffect(() => { if (sidebarMode !== 'walkthrough') { @@ -2751,11 +2879,11 @@ export function ReviewSurface({ return; } lastAutoVersionWalkthroughKeyRef.current = key; - startWalkthroughGeneration(versionCompareWalkthroughOptions); + queueMicrotask(() => startWalkthroughGeneration(versionCompareWalkthroughOptions)); return; } if (interactive?.walkthroughStatus === 'idle') { - startWalkthroughGeneration(); + queueMicrotask(() => startWalkthroughGeneration()); } }, [ interactive?.walkthroughStatus, @@ -2981,6 +3109,7 @@ export function ReviewSurface({ activeSearchMatch: null, agentId: snapshot.walkthrough.agent, agentLabel: getAgentLabel(snapshot.walkthrough.agent), + canSubmitReviewComments, codeQualityFindings: snapshot.codeQualityFindings, collapsed, comments: reviewComments, @@ -3161,14 +3290,27 @@ export function ReviewSurface({ walkthroughReady && Boolean(versionCompare) && versionWalkthroughStructure === 'commit-by-commit' && + !legacyWalkthroughNeedsCommitDiffs && walkthroughFiles.length === 0; + const legacyWalkthroughDiffLoading = + walkthroughReady && + legacyWalkthroughNeedsCommitDiffs && + missingWalkthroughCommitShas.length > 0 && + commitDiffLoading; + const legacyWalkthroughDiffError = + walkthroughReady && + legacyWalkthroughNeedsCommitDiffs && + missingWalkthroughCommitShas.length > 0 && + commitDiffError; const walkthroughFailed = walkthroughStatus === 'failed'; const walkthroughIdle = walkthroughStatus === 'idle'; + const baselineWalkthroughStructure = + baselineWalkthroughStructureOverride ?? interactive?.reviewStrategy?.mode ?? 'whole-diff'; const walkthroughStructurePhrase = versionCompareActive ? versionWalkthroughStructure === 'commit-by-commit' ? 'commit-by-commit version' : 'whole-diff version' - : interactive?.reviewStrategy?.mode === 'commit-by-commit' + : baselineWalkthroughStructure === 'commit-by-commit' ? 'commit-by-commit' : 'whole-diff'; const computingVersionChanges = Boolean(versionCompareLoading); @@ -3224,7 +3366,7 @@ export function ReviewSurface({ startWalkthroughGeneration(options); }; const alternateReviewStructure = - interactive?.reviewStrategy?.mode === 'commit-by-commit' ? 'whole-diff' : 'commit-by-commit'; + baselineWalkthroughStructure === 'commit-by-commit' ? 'whole-diff' : 'commit-by-commit'; const showCommentsTab = Boolean(commenting || interactive || generalCommentCount > 0); const reviewModes = [ { @@ -3403,7 +3545,7 @@ export function ReviewSurface({ onClick={() => interactive.onExitVersionCompare?.()} type="button" > - Whole MR + {wholeDiffLabel}
+ {!versionCompareActive && versionHistoryWarning ? ( +
{versionHistoryWarning}
+ ) : null} {versionSectionExpanded && versionCompareActive ? (
{versionHistoryLoading ? (
- Loading version history… + {`Loading ${versionHistoryLabel.toLowerCase()}…`}
) : versions.length >= 2 && interactive?.onVersionCompareRangeChange ? (
@@ -3836,7 +3981,7 @@ export function ReviewSurface({
{interactive.reviewStrategy.mode === 'commit-by-commit' ? 'Structured by commits' - : `Whole MR (${interactive.reviewStrategy.reason})`} + : `${wholeDiffLabel} (${interactive.reviewStrategy.reason})`}
) : null} {commits.map((commit) => { @@ -3924,9 +4069,9 @@ export function ReviewSurface({ {!versionCompareActive && interactive?.reviewStrategy ? (
- {interactive.reviewStrategy.mode === 'commit-by-commit' + {baselineWalkthroughStructure === 'commit-by-commit' ? 'Structured by commits' - : `Whole MR (${interactive.reviewStrategy.reason})`} + : `${wholeDiffLabel} (${interactive.reviewStrategy.reason})`} + {queuedReviewStructure ? ( + + Queued:{' '} + {queuedReviewStructure === 'commit-by-commit' + ? 'commit-by-commit' + : wholeDiffLabel} + ) : null} - - ) : ( - - )} +
+ ) : null} +
+ {walkthroughFailed || + (walkthroughIdle && !walkthroughBusy && !computingVersionChanges) ? ( + <> + {walkthroughStatusTitle} + {walkthroughStatusDescription ? ( + {walkthroughStatusDescription} + ) : null} + + ) : ( + + )} +
)} @@ -4210,19 +4389,13 @@ export function ReviewSurface({ ) : null}
- ) : walkthroughReady && - walkthroughCommitSha && - !commitFilesBySha[walkthroughCommitSha] && - commitDiffLoading ? ( + ) : legacyWalkthroughDiffLoading ? (
Loading commit walkthrough code…
- ) : walkthroughReady && - walkthroughCommitSha && - !commitFilesBySha[walkthroughCommitSha] && - commitDiffError ? ( + ) : legacyWalkthroughDiffError ? (
Unable to load commit walkthrough code -

{commitDiffError}

+

{legacyWalkthroughDiffError}

) : walkthroughReady ? ( @@ -4387,6 +4560,7 @@ export function MergeRequestReviewApp({ onVersionCompareRangeChange, onVersionWalkthroughStructureChange, preferences, + providerLabel = 'provider', reviewStrategy, selectedCommitSha, settingsBar, @@ -4402,13 +4576,16 @@ export function MergeRequestReviewApp({ versionCompareFromId, versionCompareLoading, versionCompareToId, + versionHistoryLabel = 'Versions', versionHistoryLoading, + versionHistoryWarning, versions, versionWalkthroughStructure, walkthrough, walkthroughError, walkthroughProgress, walkthroughStatus, + wholeDiffLabel = 'Whole MR', }: MergeRequestReviewAppProps) { const placeholderWalkthrough = useMemo( () => ({ @@ -4493,6 +4670,7 @@ export function MergeRequestReviewApp({ }} onModeChange={onModeChange} onVersionWalkthroughStructureChange={onVersionWalkthroughStructureChange} + providerLabel={providerLabel} selectedCommitSha={selectedCommitSha} settingsBar={settingsBar} snapshot={snapshot} @@ -4507,9 +4685,12 @@ export function MergeRequestReviewApp({ versionCompareFromId={versionCompareFromId} versionCompareLoading={versionCompareLoading} versionCompareToId={versionCompareToId} + versionHistoryLabel={versionHistoryLabel} versionHistoryLoading={versionHistoryLoading} + versionHistoryWarning={versionHistoryWarning} versions={versions} versionWalkthroughStructure={versionWalkthroughStructure} + wholeDiffLabel={wholeDiffLabel} /> ); } diff --git a/core/__tests__/App-render.test.tsx b/core/__tests__/App-render.test.tsx index 40e39383..44ecc3a0 100644 --- a/core/__tests__/App-render.test.tsx +++ b/core/__tests__/App-render.test.tsx @@ -146,6 +146,10 @@ const createCodiffMock = (overrides: Partial = {}): Window['co status: 'committed' as const, })), decreaseCodeFontSize: vi.fn(async () => {}), + generateReviewWalkthrough: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/Users/reviewer/.codex/skills/codiff', @@ -166,6 +170,11 @@ const createCodiffMock = (overrides: Partial = {}): Window['co email: 'reviewer@example.com', name: 'Reviewer', })), + getGitLabReviewVersionCompare: vi.fn(async () => { + throw new Error('Unexpected GitLab version compare.'); + }), + getGitLabReviewVersions: vi.fn(async () => []), + getGitLabReviewVersionUnitDiff: vi.fn(async () => []), getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: true, walkthrough: false, @@ -206,6 +215,12 @@ const createCodiffMock = (overrides: Partial = {}): Window['co root: '/repo', })), getRepositoryState: vi.fn(async () => repositoryState), + getReviewVersionCompare: vi.fn(async () => { + throw new Error('Unexpected review version compare.'); + }), + getReviewVersions: vi.fn(async () => ({ versions: [], warning: null })), + getReviewVersionUnitDiff: vi.fn(async () => []), + getStoredReviewWalkthrough: vi.fn(async () => ({ status: 'missing' as const })), getTerminalHelperStatus: vi.fn(async () => ({ command: 'codiff', installed: true, @@ -411,7 +426,7 @@ test('stale persisted collapsed sidebar state does not hide the sidebar on launc false, ); expect(app.container.querySelector('.sidebar')).not.toBeNull(); - expect(app.container.querySelector('.sidebar [role="tablist"]')).toBeNull(); + expect(app.container.querySelector('.sidebar [role="tablist"]')).not.toBeNull(); }); const topBar = app.container.querySelector('.review-top-bar'); @@ -735,8 +750,8 @@ test('branch history keeps branch diff available after selecting uncommitted cha await waitFor(() => { expect(container.querySelector('.loading')).toBeNull(); - expect(findButton('Committed only vs main')).toBeTruthy(); - expect(findButton('All changes vs main')).toBeTruthy(); + expect(findButton('Uncommitted changes')).toBeTruthy(); + expect(findButton('Branch diff vs main')).toBeTruthy(); }); await act(async () => { @@ -745,11 +760,11 @@ test('branch history keeps branch diff available after selecting uncommitted cha await waitFor(() => { expect(getRepositoryState).toHaveBeenCalledWith({ type: 'working-tree' }); - expect(findButton('Committed only vs main')).toBeTruthy(); + expect(findButton('Branch diff vs main')).toBeTruthy(); }); await act(async () => { - findButton('Committed only vs main')?.click(); + findButton('Branch diff vs main')?.click(); }); await waitFor(() => { @@ -813,7 +828,7 @@ test('repository reload restores branch diff scope after selecting uncommitted c await waitFor(() => { expect(container.querySelector('.loading')).toBeNull(); - expect(findButton('Committed only vs main')).toBeTruthy(); + expect(findButton('Branch diff vs main')).toBeTruthy(); }); expect(getRepositoryState).toHaveBeenCalledWith({ type: 'working-tree' }); expect(getRepositoryHistory).toHaveBeenCalledWith(expect.any(Number), branchSource); @@ -2909,11 +2924,11 @@ test('refreshing all changes re-resolves the branch snapshot', async () => { findButton('History')?.click(); }); await waitFor(() => { - expect(findButton('Committed only vs main')).toBeTruthy(); + expect(findButton('Branch diff vs main')).toBeTruthy(); }); await act(async () => { - findButton('Committed only vs main')?.click(); + findButton('Branch diff vs main')?.click(); await new Promise((resolve) => setTimeout(resolve, 0)); }); diff --git a/core/__tests__/LocalMergeRequestReviewHost.test.tsx b/core/__tests__/LocalMergeRequestReviewHost.test.tsx index 3ff1ea94..335a5127 100644 --- a/core/__tests__/LocalMergeRequestReviewHost.test.tsx +++ b/core/__tests__/LocalMergeRequestReviewHost.test.tsx @@ -93,6 +93,10 @@ const createCodiffMock = (overrides: Partial = {}): Window['co status: 'committed' as const, })), decreaseCodeFontSize: vi.fn(async () => {}), + generateReviewWalkthrough: vi.fn(async () => ({ + reason: 'Unavailable in tests.', + status: 'unavailable' as const, + })), getAgentSkillStatus: vi.fn(async () => ({ installed: true, path: '/Users/reviewer/.codex/skills/codiff', @@ -113,6 +117,11 @@ const createCodiffMock = (overrides: Partial = {}): Window['co email: 'reviewer@example.com', name: 'Reviewer', })), + getGitLabReviewVersionCompare: vi.fn(async () => { + throw new Error('Unexpected GitLab version compare.'); + }), + getGitLabReviewVersions: vi.fn(async () => []), + getGitLabReviewVersionUnitDiff: vi.fn(async () => []), getLaunchOptions: vi.fn(async () => ({ repositoryPathProvided: true, walkthrough: false, @@ -170,6 +179,12 @@ const createCodiffMock = (overrides: Partial = {}): Window['co root: '/repo', })), getRepositoryState: vi.fn(async () => repositoryState), + getReviewVersionCompare: vi.fn(async () => { + throw new Error('Unexpected review version compare.'); + }), + getReviewVersions: vi.fn(async () => ({ versions: [], warning: null })), + getReviewVersionUnitDiff: vi.fn(async () => []), + getStoredReviewWalkthrough: vi.fn(async () => ({ status: 'missing' as const })), getTerminalHelperStatus: vi.fn(async () => ({ command: 'codiff', installed: true, @@ -266,7 +281,7 @@ test('App mounts the shared merge-request review shell for pull-request sources' } }); -test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through local IPC', async () => { +test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through shared orchestration IPC', async () => { const walkthrough = { agent: 'codex', chapters: [], @@ -280,12 +295,12 @@ test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through loca version: 4, } satisfies NarrativeWalkthrough; - const getNarrativeWalkthrough = vi.fn(async () => ({ + const generateReviewWalkthrough = vi.fn(async () => ({ status: 'ready' as const, walkthrough, })); - window.codiff = createCodiffMock({ getNarrativeWalkthrough }); + window.codiff = createCodiffMock({ generateReviewWalkthrough }); const onHome = vi.fn(); const app = await renderReact( @@ -298,7 +313,7 @@ test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through loca try { await waitFor(() => { - expect(getNarrativeWalkthrough).toHaveBeenCalledWith(pullRequestSource, {}); + expect(generateReviewWalkthrough).toHaveBeenCalled(); }); await waitFor(() => { // Empty-chapter walkthroughs still mark the surface ready. @@ -313,3 +328,441 @@ test('LocalMergeRequestReviewHost generates whole-diff walkthroughs through loca await app.cleanup(); } }); + +test('walkthrough generation shows its structure and queues an override', async () => { + const walkthrough = { + agent: 'codex', + chapters: [], + focus: 'Focus.', + generatedAt: '2026-07-01T00:00:00.000Z', + kind: 'narrative', + repo: { branch: 'feature/local-host', root: '/repo' }, + source: pullRequestSource, + support: [], + title: 'Generated', + version: 4, + } satisfies NarrativeWalkthrough; + let resolveFirst: + | ((result: { status: 'ready'; walkthrough: NarrativeWalkthrough }) => void) + | null = null; + const firstGeneration = new Promise<{ status: 'ready'; walkthrough: NarrativeWalkthrough }>( + (resolve) => { + resolveFirst = resolve; + }, + ); + const generateReviewWalkthrough = vi + .fn() + .mockImplementationOnce(() => firstGeneration) + .mockResolvedValue({ status: 'ready' as const, walkthrough }); + window.codiff = createCodiffMock({ generateReviewWalkthrough }); + + const app = await renderReact( + {}} + state={repositoryState} + />, + ); + + try { + await waitFor(() => { + expect(app.container.textContent).toContain('Generating · Whole PR'); + }); + const switchButton = Array.from(app.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Switch to commit-by-commit'), + ); + expect(switchButton).not.toBeUndefined(); + await act(async () => { + switchButton?.click(); + }); + expect(app.container.textContent).toContain('Queued: commit-by-commit'); + + await act(async () => { + resolveFirst?.({ status: 'ready', walkthrough }); + await firstGeneration; + }); + await waitFor(() => { + expect(generateReviewWalkthrough).toHaveBeenCalledTimes(2); + }); + expect(generateReviewWalkthrough.mock.calls[1]?.[0]).toMatchObject({ + force: true, + structure: 'units', + }); + } finally { + await app.cleanup(); + } +}); + +test('GitLab sources load review versions through IPC into the shared host', async () => { + const gitlabSource = { + description: '## Intent\n\nShip **MR** versions.', + host: 'gitlab.example.com', + number: 13, + projectPath: 'group/project', + provider: 'gitlab', + title: 'Local GitLab history', + type: 'pull-request', + url: 'https://gitlab.example.com/group/project/-/merge_requests/13', + } satisfies Extract; + + const versionOption = { + createdAt: '2026-01-02T00:00:00.000Z', + id: '2', + isHead: true, + number: 2, + range: { + base: { + commitId: 'a'.repeat(40), + label: { kind: 'commit' as const, text: 'aaaaaaa' }, + }, + head: { + commitId: 'c'.repeat(40), + label: { kind: 'version' as const, text: 'v2' }, + }, + }, + }; + const baseOption = { + createdAt: '2026-01-01T00:00:00.000Z', + id: 'mr-base', + isHead: false, + number: 0, + range: { + base: { + commitId: 'a'.repeat(40), + label: { kind: 'commit' as const, text: 'aaaaaaa' }, + }, + head: { + commitId: 'a'.repeat(40), + label: { kind: 'version' as const, text: 'MR base' }, + }, + }, + }; + const v1Option = { + createdAt: '2026-01-01T12:00:00.000Z', + id: '1', + isHead: false, + number: 1, + range: { + base: { + commitId: 'a'.repeat(40), + label: { kind: 'commit' as const, text: 'aaaaaaa' }, + }, + head: { + commitId: 'b'.repeat(40), + label: { kind: 'version' as const, text: 'v1' }, + }, + }, + }; + + const getReviewVersions = vi.fn(async () => ({ + versions: [baseOption, v1Option, versionOption], + warning: null, + })); + const getReviewVersionCompare = vi.fn(async () => ({ + versionCommitEvolution: null, + versionCommitEvolutionError: null, + versionCompare: { + analysis: { + summary: { + addedLines: 1, + baseMoved: false, + commentsAffected: 0, + conflictFiles: 0, + deletedLines: 0, + empty: false, + filesChanged: 1, + intentionalFiles: 1, + noiseFiles: 0, + }, + }, + comparison: { + after: versionOption.range, + before: v1Option.range, + }, + files: [createChangedFile('src/app.ts')], + from: v1Option, + to: versionOption, + }, + warning: null, + })); + + window.codiff = createCodiffMock({ + getRepositoryState: vi.fn(async () => ({ + ...repositoryState, + source: gitlabSource, + })), + getReviewVersionCompare, + getReviewVersions, + }); + + const app = await renderReact( + {}} + state={{ + ...repositoryState, + source: gitlabSource, + }} + />, + ); + + try { + await waitFor(() => { + expect(getReviewVersions).toHaveBeenCalledWith({ source: gitlabSource }); + }); + + await waitFor(() => { + expect(app.container.querySelector('.merge-request-shell')).not.toBeNull(); + }); + + const compareButton = Array.from(app.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Compare versions'), + ); + expect(compareButton).not.toBeUndefined(); + + await act(async () => { + compareButton?.click(); + }); + + await waitFor(() => { + expect(getReviewVersionCompare).toHaveBeenCalledWith({ + fromId: '1', + source: gitlabSource, + toId: '2', + }); + }); + } finally { + await app.cleanup(); + } +}); + +test('GitHub sources load head history and use Compare heads copy', async () => { + const githubSource = { + description: '## Intent\n\nShip **PR** heads.', + number: 12, + owner: 'nkzw-tech', + provider: 'github', + repo: 'codiff', + title: 'Local GitHub history', + type: 'pull-request', + url: 'https://github.com/nkzw-tech/codiff/pull/12', + } satisfies Extract; + + const headA = { + createdAt: '2026-01-01T00:00:00.000Z', + id: 'a'.repeat(40), + isHead: false, + number: 1, + range: { + base: { + commitId: '0'.repeat(40), + label: { kind: 'commit' as const, text: '0000000' }, + }, + head: { + commitId: 'a'.repeat(40), + label: { kind: 'version' as const, text: 'Head · aaaaaaa' }, + }, + }, + }; + const headB = { + createdAt: '2026-01-02T00:00:00.000Z', + id: 'b'.repeat(40), + isHead: true, + number: 2, + range: { + base: { + commitId: '0'.repeat(40), + label: { kind: 'commit' as const, text: '0000000' }, + }, + head: { + commitId: 'b'.repeat(40), + label: { kind: 'version' as const, text: 'Current head' }, + }, + }, + }; + + const getReviewVersions = vi.fn(async () => ({ + versions: [headA, headB], + warning: null, + })); + const unit = { + after: { + authoredAt: '2026-01-02T00:00:00.000Z', + authorName: 'Author', + parentIds: [headA.id], + sha: headB.id, + shortSha: headB.id.slice(0, 7), + subject: 'Update the implementation', + }, + confidence: 'exact' as const, + id: `introduced:${headB.id}`, + kind: 'introduced' as const, + order: 0, + reviewable: true as const, + }; + const getReviewVersionCompare = vi.fn(async () => ({ + versionCommitEvolution: { + recommendation: { + rationale: 'Review the changed commit.', + suggestedStructure: 'commit-by-commit' as const, + }, + summary: { + absorbedIntoBase: 0, + added: 1, + ambiguous: 0, + pairingCoverage: 1, + removed: 0, + retained: 0, + reviewable: 1, + revised: 0, + rewrittenSamePatch: 0, + }, + units: [unit], + }, + versionCommitEvolutionError: null, + versionCompare: { + analysis: { + summary: { + addedLines: 1, + baseMoved: false, + commentsAffected: 0, + conflictFiles: 0, + deletedLines: 0, + empty: false, + filesChanged: 1, + intentionalFiles: 1, + noiseFiles: 0, + }, + }, + comparison: { + after: headB.range, + before: headA.range, + }, + files: [createChangedFile('src/app.ts')], + from: headA, + to: headB, + }, + warning: null, + })); + const getReviewVersionUnitDiff = vi.fn(async () => [createChangedFile('src/unit.ts')]); + + window.codiff = createCodiffMock({ + getRepositoryState: vi.fn(async () => ({ + ...repositoryState, + source: githubSource, + })), + getReviewVersionCompare, + getReviewVersions, + getReviewVersionUnitDiff, + }); + + const app = await renderReact( + {}} + state={{ + ...repositoryState, + source: githubSource, + }} + />, + ); + + try { + await waitFor(() => { + expect(getReviewVersions).toHaveBeenCalledWith({ source: githubSource }); + }); + await waitFor(() => { + expect(app.container.textContent).toContain('Whole PR'); + }); + const compareButton = Array.from(app.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Compare heads'), + ); + expect(compareButton).not.toBeUndefined(); + await act(async () => { + compareButton?.click(); + }); + await waitFor(() => { + expect(getReviewVersionCompare).toHaveBeenCalledWith({ + fromId: headA.id, + source: githubSource, + toId: headB.id, + }); + }); + let unitButton: HTMLButtonElement | null = null; + await waitFor(() => { + unitButton = app.container.querySelector( + '.version-commit-unit.introduced', + ); + expect(unitButton).not.toBeNull(); + }); + await act(async () => { + unitButton?.click(); + }); + await waitFor(() => { + expect(getReviewVersionUnitDiff).toHaveBeenCalledWith({ + source: githubSource, + unit, + }); + }); + } finally { + await app.cleanup(); + } +}); + +test('history warnings remain baseline status instead of activating comparison mode', async () => { + window.codiff = createCodiffMock({ + getReviewVersions: vi.fn(async () => ({ + versions: [], + warning: 'Force-push timeline unavailable. Showing current head only.', + })), + }); + const app = await renderReact( + {}} state={repositoryState} />, + ); + + try { + await waitFor(() => { + expect(app.container.textContent).toContain('Force-push timeline unavailable'); + }); + const wholeButton = Array.from(app.container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Whole PR'), + ); + expect(wholeButton?.getAttribute('aria-pressed')).toBe('true'); + expect(app.container.querySelector('#version-comparison-body')).toBeNull(); + } finally { + await app.cleanup(); + } +}); + +test('manual refresh reloads review data and updates the freshness label', async () => { + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const refreshedAt = Date.now(); + const getRepositoryState = vi.fn(async () => ({ + ...repositoryState, + generatedAt: refreshedAt, + })); + window.codiff = createCodiffMock({ getRepositoryState }); + const app = await renderReact( + {}} + state={{ ...repositoryState, generatedAt: sevenDaysAgo }} + />, + ); + + try { + let refreshButton: HTMLButtonElement | undefined; + await waitFor(() => { + refreshButton = Array.from(app.container.querySelectorAll('button')).find((candidate) => + candidate.textContent?.includes('Refresh PR · updated 7d ago'), + ); + expect(refreshButton).not.toBeUndefined(); + }); + await act(async () => { + refreshButton?.click(); + }); + await waitFor(() => { + expect(getRepositoryState).toHaveBeenCalledWith(pullRequestSource); + expect(app.container.textContent).toContain('Refresh PR · updated just now'); + }); + } finally { + await app.cleanup(); + } +}); diff --git a/core/__tests__/ReviewCodeView-scroll.test.tsx b/core/__tests__/ReviewCodeView-scroll.test.tsx index 4c14d526..eccfb272 100644 --- a/core/__tests__/ReviewCodeView-scroll.test.tsx +++ b/core/__tests__/ReviewCodeView-scroll.test.tsx @@ -244,7 +244,7 @@ test('switching edited Markdown back to a diff flushes and refreshes it first', ({ textContent }) => textContent === 'View as Diff', ); expect(diffButton).not.toBeUndefined(); - expect(diffButton?.classList.contains('codiff-button')).toBe(true); + expect(diffButton?.classList.contains('codiff-markdown-button')).toBe(true); await act(async () => { diffButton?.click(); @@ -398,7 +398,7 @@ test('combined branch-only Markdown sections remain read-only', async () => { }); await waitFor(() => { - expect(container.querySelector('[aria-label="Preview plan.md"]')).not.toBeNull(); + expect(container.querySelector('.codiff-markdown-preview:not(.editable)')).not.toBeNull(); }); expect(container.querySelector('[aria-label="Edit plan.md"]')).toBeNull(); } finally { @@ -1360,6 +1360,7 @@ test('failed pull request comments keep their draft and can be retried', async ( const onUpdateComment = vi.fn(); const view = await renderReact( , ); @@ -1471,7 +1472,7 @@ test('file comments can be created for GitLab merge requests but not GitHub pull '.codiff-file-comment-button', ); expect(fileCommentButton).not.toBeNull(); - expect(fileCommentButton?.classList.contains('codiff-button')).toBe(true); + expect(fileCommentButton?.classList.contains('codiff-file-comment-button')).toBe(true); await act(async () => fileCommentButton?.click()); expect(onCreateComment).toHaveBeenCalledWith({ @@ -1632,7 +1633,7 @@ test('Enter on a focused review control is not converted into a hunk comment', a render(1); }); - const openButton = container.querySelector('.codiff-button'); + const openButton = container.querySelector('.codiff-open-button'); if (!openButton) { throw new Error('Expected the open file button.'); } diff --git a/core/__tests__/generate-review-walkthrough.test.ts b/core/__tests__/generate-review-walkthrough.test.ts index 1c72068a..96f89a6b 100644 --- a/core/__tests__/generate-review-walkthrough.test.ts +++ b/core/__tests__/generate-review-walkthrough.test.ts @@ -177,6 +177,48 @@ test('generateReviewWalkthrough units path fans out and composes', async () => { ); }); +test('generateReviewWalkthrough authors ordinary commit units with commit context', async () => { + const commitUnit = { + commit: { + authoredAt: '2026-01-01T00:00:00.000Z', + authorName: 'Ada', + parentIds: ['0'.repeat(40)], + sha: 'a'.repeat(40), + shortSha: 'aaaaaaa', + subject: 'Add the request path', + }, + id: `commit:${'a'.repeat(40)}`, + kind: 'commit' as const, + order: 0, + reviewable: true as const, + }; + const prompts: Array = []; + const result = await generateReviewWalkthrough({ + agent: 'codex', + plan: { structure: 'units', units: [commitUnit] }, + runModel: async ({ prompt }) => { + prompts.push(prompt); + return { draft }; + }, + states: { + byUnitId: { [commitUnit.id]: baseState }, + whole: baseState, + }, + }); + + expect(result.status).toBe('ready'); + if (result.status !== 'ready') { + return; + } + expect(prompts[0]).toContain('This is an independent walkthrough for commit'); + expect(prompts[0]).not.toContain('version comparison'); + expect(result.walkthrough.chapters[0]?.commit).toMatchObject({ + gitSha: commitUnit.commit.sha, + sha: commitUnit.commit.sha, + }); + expect(result.walkthrough.commitFiles).toEqual(baseState.files); +}); + test('generateReviewWalkthrough fails clearly without whole state', async () => { const result = await generateReviewWalkthrough({ agent: 'codex', diff --git a/core/__tests__/narrative-walkthrough-view.test.ts b/core/__tests__/narrative-walkthrough-view.test.ts index e2655470..750303e5 100644 --- a/core/__tests__/narrative-walkthrough-view.test.ts +++ b/core/__tests__/narrative-walkthrough-view.test.ts @@ -8,12 +8,14 @@ import { buildCommitModel, buildGenericCommitModel, buildWalkthroughView, + combineWalkthroughCommitFiles, focusChangedFileForHunks, formatWalkthroughFileLineRows, formatWalkthroughFileList, getCommitSelectionPaths, getUncoveredWalkthroughFileLineItems, getUncoveredWalkthroughFiles, + getWalkthroughCommitDiffShas, getWalkthroughRunNote, isWalkthroughCommittable, resolveWalkthroughHunkFile, @@ -664,6 +666,52 @@ test('resolveWalkthroughHunkFile requires exact anchor section', () => { expect(resolveWalkthroughHunkFile(testHunk, files)).toBeNull(); }); +test('commit walkthrough files preserve commit order and duplicate paths', () => { + const firstFile: ChangedFile = { + fingerprint: 'first', + path: 'src/shared.ts', + sections: [{ binary: false, id: 'first:section', kind: 'commit', patch: '+first' }], + status: 'modified', + }; + const secondFile: ChangedFile = { + fingerprint: 'second', + path: 'src/shared.ts', + sections: [{ binary: false, id: 'second:section', kind: 'commit', patch: '+second' }], + status: 'modified', + }; + const commitWalkthrough: NarrativeWalkthrough = { + ...walkthrough(), + chapters: [ + { + ...walkthrough().chapters[0], + commit: { gitSha: 'git-first', sha: 'first', shortSha: 'first', subject: 'First' }, + }, + { + ...walkthrough().chapters[1], + commit: { gitSha: 'git-second', sha: 'second', shortSha: 'second', subject: 'Second' }, + }, + ], + }; + + expect(getWalkthroughCommitDiffShas(commitWalkthrough)).toEqual(['git-first', 'git-second']); + expect( + combineWalkthroughCommitFiles(['git-first', 'git-second'], { + 'git-first': [firstFile], + 'git-second': [secondFile], + }), + ).toEqual([firstFile, secondFile]); + expect( + resolveWalkthroughHunkFile( + { + ...appHunk, + anchor: { ...appHunk.anchor, sectionId: 'second:section' }, + path: 'src/shared.ts', + }, + [firstFile, secondFile], + )?.file, + ).toBe(secondFile); +}); + const multiHunkFile = (): ChangedFile => ({ fingerprint: 'database-search', path: 'database_search.py', diff --git a/core/__tests__/walkthrough-authoring.test.ts b/core/__tests__/walkthrough-authoring.test.ts index e6ca889e..e745bef6 100644 --- a/core/__tests__/walkthrough-authoring.test.ts +++ b/core/__tests__/walkthrough-authoring.test.ts @@ -215,7 +215,7 @@ test('includes version-commit guidance and composes unit walkthroughs', () => { versionCommitContext: { after: { shortSha: 'bbbbbbb', subject: 'Later' }, before: { shortSha: 'aaaaaaa', subject: 'Earlier' }, - evolutionKind: 'revised', + evolutionKind: 'likely-revised', kind: 'version-commit', range: { fromLabel: 'v1', toLabel: 'v2' }, unitId: 'unit-1', @@ -287,7 +287,7 @@ test('scopes version-comparison Review focus to changes since the earlier versio entries: [ { context: { - after: { shortSha: 'bbbbbbb', subject: 'Later' }, + after: { sha: 'b'.repeat(40), shortSha: 'bbbbbbb', subject: 'Later' }, evolutionKind: 'added', kind: 'version-commit', range: { fromLabel: 'v1', toLabel: 'v2' }, diff --git a/core/app/LocalMergeRequestReviewHost.tsx b/core/app/LocalMergeRequestReviewHost.tsx index beeb4faf..0c332de3 100644 --- a/core/app/LocalMergeRequestReviewHost.tsx +++ b/core/app/LocalMergeRequestReviewHost.tsx @@ -1,17 +1,27 @@ +import { ArrowsClockwiseIcon as ArrowsClockwise } from '@phosphor-icons/react/ArrowsClockwise'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createDefaultConfig } from '../config/defaults.ts'; import type { CodiffConfig } from '../config/types.ts'; import { sortFiles } from '../lib/files.ts'; +import { + classifyReviewCommit, + classifyReviewStrategy, + orderCommitsTopologically, +} from '../lib/review-strategy.ts'; import { getSourceLabel } from '../lib/source.ts'; import { MergeRequestReviewApp, type MergeRequestCommitListEntry, type MergeRequestReviewMode, + type MergeRequestVersionCommitEvolution, + type MergeRequestVersionCompareView, + type MergeRequestVersionOption, type MergeRequestWalkthroughStatus, } from '../SharedWalkthroughApp.tsx'; import type { ChangedFile, CodiffPreferences, + GenerateLocalReviewWalkthroughRequest, GitIdentity, HistoryEntry, NarrativeWalkthrough, @@ -19,8 +29,12 @@ import type { PullRequestReviewComment, PullRequestReviewEvent, RepositoryState, + WalkthroughGenerationProgress, + ReviewEvolutionUnit, + ReviewStrategySummary, ReviewSource, } from '../types.ts'; +import { Button } from './components/Button.tsx'; const getPreferencesFromConfig = ({ settings }: CodiffConfig): CodiffPreferences => ({ ...settings, @@ -31,15 +45,45 @@ const defaultPreferences = getPreferencesFromConfig(createDefaultConfig()); const toCommitListEntries = ( entries: ReadonlyArray, ): ReadonlyArray => - entries - .filter((entry) => entry.scope !== 'base') - .map((entry) => ({ - authoredAt: new Date(entry.committedAt).toISOString(), - authorName: entry.author, - sha: entry.ref, - shortSha: entry.ref.slice(0, 7), - subject: entry.subject, - })); + orderCommitsTopologically( + entries + .filter((entry) => entry.scope !== 'base') + .map((entry) => + classifyReviewCommit({ + authoredDate: new Date(entry.committedAt).toISOString(), + authorName: entry.author, + message: entry.subject, + parentIds: entry.parents, + sha: entry.ref, + shortSha: entry.ref.slice(0, 7), + title: entry.subject, + }), + ), + ).map((entry) => ({ + authoredAt: entry.authoredAt, + authorName: entry.authorName, + role: entry.role, + sha: entry.sha, + shortSha: entry.shortSha, + subject: entry.subject, + webUrl: entry.webUrl, + })); + +const shortUpdatedAge = (timestamp: number, now: number) => { + const seconds = Math.max(0, Math.floor((now - timestamp) / 1000)); + if (seconds < 60) { + return 'just now'; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h ago`; + } + return `${Math.floor(hours / 24)}d ago`; +}; const getPullRequestTitle = (state: RepositoryState) => { if (state.source.type !== 'pull-request') { @@ -99,15 +143,92 @@ function LocalMergeRequestReviewSession({ const state = liveState ?? sortRepositoryState(initialState); const stateRef = useRef(state); const [commits, setCommits] = useState>([]); + const [reviewStrategy, setReviewStrategy] = useState(null); const [walkthrough, setWalkthrough] = useState(null); - const [walkthroughStatus, setWalkthroughStatus] = - useState('idle'); + const [walkthroughStatus, setWalkthroughStatus] = useState('idle'); const [walkthroughError, setWalkthroughError] = useState(null); + const [walkthroughProgress, setWalkthroughProgress] = + useState(null); const [configPreferences, setConfigPreferences] = useState(defaultPreferences); const [mode, setMode] = useState(initialMode); const [selectedCommitSha, setSelectedCommitSha] = useState(null); + const [versions, setVersions] = useState>([]); + const [historyWarning, setHistoryWarning] = useState(null); + const [versionHistoryLoading, setVersionHistoryLoading] = useState(() => { + if (initialState.source.type !== 'pull-request') { + return false; + } + const provider = initialState.source.provider; + return provider === 'gitlab' || provider === 'github' || provider == null; + }); + const [versionCompare, setVersionCompare] = useState(null); + const [versionCompareLoading, setVersionCompareLoading] = useState(false); + const [versionCompareError, setVersionCompareError] = useState(null); + const [versionCompareFromId, setVersionCompareFromId] = useState(null); + const [versionCompareToId, setVersionCompareToId] = useState(null); + const [versionCommitEvolution, setVersionCommitEvolution] = + useState(null); + const [versionCommitEvolutionLoading, setVersionCommitEvolutionLoading] = useState(false); + const [versionCommitEvolutionError, setVersionCommitEvolutionError] = useState( + null, + ); + const [versionWalkthroughStructure, setVersionWalkthroughStructure] = useState< + 'commit-by-commit' | 'whole-diff' | undefined + >(undefined); + const [lastRefreshAt, setLastRefreshAt] = useState(initialState.generatedAt); + const [refreshNow, setRefreshNow] = useState(null); + const [refreshError, setRefreshError] = useState(null); + const [refreshing, setRefreshing] = useState(false); const commitDiffCacheRef = useRef>>(new Map()); const walkthroughRequestRef = useRef(0); + const versionCompareRequestRef = useRef(0); + const compareCacheRef = useRef< + Map< + string, + { + versionCommitEvolution: MergeRequestVersionCommitEvolution | null; + versionCommitEvolutionError: string | null; + versionCompare: MergeRequestVersionCompareView; + warning?: string | null; + } + > + >(new Map()); + + useEffect(() => { + const update = () => setRefreshNow(Date.now()); + const timeout = window.setTimeout(update, 0); + const interval = window.setInterval(update, 60_000); + return () => { + window.clearInterval(interval); + window.clearTimeout(timeout); + }; + }, []); + + const applyHistory = useCallback((history: { entries: ReadonlyArray }) => { + const nextCommits = toCommitListEntries(history.entries); + setCommits(nextCommits); + const source = stateRef.current.source; + const strategy = classifyReviewStrategy({ + commits: history.entries + .filter((entry) => entry.scope !== 'base') + .map((entry) => ({ + authoredDate: new Date(entry.committedAt).toISOString(), + authorName: entry.author, + message: entry.subject, + parentIds: entry.parents, + sha: entry.ref, + shortSha: entry.ref.slice(0, 7), + title: entry.subject, + })), + description: source.type === 'pull-request' ? source.description : undefined, + title: source.type === 'pull-request' ? source.title : undefined, + }); + setReviewStrategy({ + confidence: strategy.confidence, + mode: strategy.mode === 'whole-mr' ? 'whole-diff' : 'commit-by-commit', + reason: strategy.reason, + }); + }, []); useEffect(() => { stateRef.current = state; @@ -132,6 +253,16 @@ function LocalMergeRequestReviewSession({ }; }, []); + useEffect( + () => + window.codiff.onWalkthroughProgress((event) => { + if (event.generation) { + setWalkthroughProgress(event.generation); + } + }), + [], + ); + useEffect(() => { if (state.source.type !== 'pull-request') { return; @@ -145,7 +276,7 @@ function LocalMergeRequestReviewSession({ if (canceled) { return; } - setCommits(toCommitListEntries(history.entries)); + applyHistory(history); }; loadCommits().catch(() => { @@ -154,6 +285,42 @@ function LocalMergeRequestReviewSession({ } }); + return () => { + canceled = true; + }; + }, [applyHistory, state.source]); + + useEffect(() => { + if (state.source.type !== 'pull-request') { + return; + } + const provider = state.source.provider; + if (provider !== 'gitlab' && provider !== 'github' && provider != null) { + return; + } + + let canceled = false; + const source = state.source; + + window.codiff + .getReviewVersions({ source }) + .then((result) => { + if (canceled) { + return; + } + setVersions(result.versions); + setHistoryWarning(result.warning ?? null); + setVersionHistoryLoading(false); + }) + .catch((error: unknown) => { + if (canceled) { + return; + } + setVersions([]); + setHistoryWarning(error instanceof Error ? error.message : String(error)); + setVersionHistoryLoading(false); + }); + return () => { canceled = true; }; @@ -170,8 +337,72 @@ function LocalMergeRequestReviewSession({ setLiveState(ordered); commitDiffCacheRef.current.clear(); setSelectedCommitSha(null); + setLastRefreshAt(ordered.generatedAt); + setRefreshNow(Date.now()); }, []); + const refreshRemoteReview = useCallback(async () => { + const current = stateRef.current; + if (current.source.type !== 'pull-request' || refreshing) { + return; + } + setRefreshing(true); + setRefreshError(null); + try { + const [nextState, history, versionResult] = await Promise.all([ + window.codiff.getRepositoryState(current.source), + window.codiff.getRepositoryHistory(200, current.source), + window.codiff.getReviewVersions({ source: current.source }), + ]); + const ordered = sortRepositoryState(nextState); + let comparison: Awaited> | null = + null; + if ( + versionCompareFromId && + versionCompareToId && + versionResult.versions.some((version) => version.id === versionCompareFromId) && + versionResult.versions.some((version) => version.id === versionCompareToId) + ) { + comparison = await window.codiff.getReviewVersionCompare({ + fromId: versionCompareFromId, + source: ordered.source as Extract, + toId: versionCompareToId, + }); + } + + stateRef.current = ordered; + setLiveState(ordered); + applyHistory(history); + setVersions(versionResult.versions); + setHistoryWarning(versionResult.warning ?? null); + compareCacheRef.current.clear(); + commitDiffCacheRef.current.clear(); + setSelectedCommitSha(null); + if (comparison) { + setVersionCompare(comparison.versionCompare); + setVersionCommitEvolution(comparison.versionCommitEvolution); + setVersionCommitEvolutionError(comparison.versionCommitEvolutionError); + } else if (versionCompareFromId || versionCompareToId) { + versionCompareRequestRef.current += 1; + setVersionCompare(null); + setVersionCompareFromId(null); + setVersionCompareToId(null); + setVersionCompareError(null); + setVersionCompareLoading(false); + setVersionCommitEvolution(null); + setVersionCommitEvolutionError(null); + setVersionCommitEvolutionLoading(false); + setVersionWalkthroughStructure(undefined); + } + setLastRefreshAt(ordered.generatedAt); + setRefreshNow(Date.now()); + } catch (error: unknown) { + setRefreshError(error instanceof Error ? error.message : String(error)); + } finally { + setRefreshing(false); + } + }, [applyHistory, refreshing, versionCompareFromId, versionCompareToId]); + useEffect(() => window.codiff.onRefreshRequest(() => void refreshState()), [refreshState]); const onSubmitComment = useCallback( @@ -239,6 +470,188 @@ function LocalMergeRequestReviewSession({ return files; }, []); + const onLoadVersionCommitDiff = useCallback( + async (unitId: string) => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + return []; + } + const unit = versionCommitEvolution?.units.find((candidate) => candidate.id === unitId); + if (!unit) { + throw new Error(`Unknown version commit unit: ${unitId}`); + } + return window.codiff.getReviewVersionUnitDiff({ + source: current.source, + unit: unit as ReviewEvolutionUnit, + }); + }, + [versionCommitEvolution], + ); + + const loadVersionCompare = useCallback( + async ( + fromId: string, + toId: string, + endpoints?: { + from?: Parameters[0]['from']; + to?: Parameters[0]['to']; + }, + ) => { + const current = stateRef.current; + if (current.source.type !== 'pull-request') { + return; + } + if (!fromId || !toId || fromId === toId) { + return; + } + + const requestId = ++versionCompareRequestRef.current; + setVersionCompareFromId(fromId); + setVersionCompareToId(toId); + setVersionCompareError(null); + setVersionCommitEvolutionError(null); + + const cacheKey = `${current.source.url}:${fromId}:${toId}:${JSON.stringify(endpoints ?? null)}`; + const cached = compareCacheRef.current.get(cacheKey); + if (cached) { + setVersionCompare(cached.versionCompare); + setVersionCommitEvolution(cached.versionCommitEvolution); + setVersionCommitEvolutionError(cached.versionCommitEvolutionError); + setVersionCompareLoading(false); + setVersionCommitEvolutionLoading(false); + if (cached.versionCommitEvolution?.recommendation.suggestedStructure) { + setVersionWalkthroughStructure( + cached.versionCommitEvolution.recommendation.suggestedStructure, + ); + } + if (cached.warning) { + setHistoryWarning(cached.warning); + } + setWalkthrough(null); + setWalkthroughStatus('idle'); + setWalkthroughError(null); + return; + } + + setVersionCompareLoading(true); + setVersionCommitEvolutionLoading(true); + + try { + const result = await window.codiff.getReviewVersionCompare({ + ...(endpoints?.from ? { from: endpoints.from } : { fromId }), + source: current.source, + ...(endpoints?.to ? { to: endpoints.to } : { toId }), + }); + if (requestId !== versionCompareRequestRef.current) { + return; + } + compareCacheRef.current.set(cacheKey, result); + setVersionCompareFromId(result.versionCompare.from.id); + setVersionCompareToId(result.versionCompare.to.id); + setVersionCompare(result.versionCompare); + setVersionCommitEvolution(result.versionCommitEvolution); + setVersionCommitEvolutionError(result.versionCommitEvolutionError); + setVersionCompareLoading(false); + setVersionCommitEvolutionLoading(false); + if (result.warning) { + setHistoryWarning(result.warning); + } + if (result.versionCommitEvolution?.recommendation.suggestedStructure) { + setVersionWalkthroughStructure( + result.versionCommitEvolution.recommendation.suggestedStructure, + ); + } + // Clear baseline walkthrough so compare mode can regenerate for the range. + setWalkthrough(null); + setWalkthroughStatus('idle'); + setWalkthroughError(null); + } catch (error: unknown) { + if (requestId !== versionCompareRequestRef.current) { + return; + } + setVersionCompare(null); + setVersionCommitEvolution(null); + setVersionCompareError(error instanceof Error ? error.message : String(error)); + setVersionCompareLoading(false); + setVersionCommitEvolutionLoading(false); + } + }, + [], + ); + + const onOpenVersionCompare = useCallback( + (options?: { commentId?: string }) => { + if (versions.length < 2) { + return; + } + const comment = options?.commentId + ? stateRef.current.reviewComments?.find((candidate) => candidate.id === options.commentId) + : null; + const currentVersion = versions.at(-1); + const commentVersion = comment + ? versions.find( + (version) => + version.id === comment.versionId || + version.range.head.commitId === + (comment.positionIdentity?.headSha ?? comment.versionHeadSha), + ) + : null; + if (comment?.positionIdentity && currentVersion) { + const fallbackFrom = + commentVersion?.id === currentVersion.id + ? (versions.at(-2)?.id ?? commentVersion.id) + : (commentVersion?.id ?? comment.positionIdentity.headSha); + if (fallbackFrom && fallbackFrom !== currentVersion.id) { + void loadVersionCompare(fallbackFrom, currentVersion.id, { + from: { + ...comment.positionIdentity, + commentId: comment.id, + kind: 'comment-position', + }, + to: { id: currentVersion.id, kind: 'version' }, + }); + return; + } + } + if (commentVersion && currentVersion && commentVersion.id !== currentVersion.id) { + void loadVersionCompare(commentVersion.id, currentVersion.id); + return; + } + // Default: previous version → current head (newest last in our list). + const toId = versions.at(-1)?.id; + const fromId = versions.at(-2)?.id; + if (!fromId || !toId) { + return; + } + void loadVersionCompare(fromId, toId); + }, + [loadVersionCompare, versions], + ); + + const onExitVersionCompare = useCallback(() => { + versionCompareRequestRef.current += 1; + setVersionCompare(null); + setVersionCompareFromId(null); + setVersionCompareToId(null); + setVersionCompareError(null); + setVersionCompareLoading(false); + setVersionCommitEvolution(null); + setVersionCommitEvolutionError(null); + setVersionCommitEvolutionLoading(false); + setVersionWalkthroughStructure(undefined); + setWalkthrough(null); + setWalkthroughStatus('idle'); + setWalkthroughError(null); + setWalkthroughProgress(null); + }, []); + + const onVersionCompareRangeChange = useCallback( + (fromId: string, toId: string) => { + void loadVersionCompare(fromId, toId); + }, + [loadVersionCompare], + ); + const onGenerateWalkthrough = useCallback( async (options?: { force?: boolean; @@ -254,40 +667,76 @@ function LocalMergeRequestReviewSession({ if (current.source.type !== 'pull-request') { return; } - // Version-scoped / unit generation lands with history wiring; baseline whole-diff only. - if (options?.versionCompare || options?.unitId) { - setWalkthroughStatus('failed'); - setWalkthroughError( - 'Version compare and unit walkthroughs are not wired in local Codiff yet.', - ); - return; - } - if (current.files.length === 0) { - setWalkthrough(null); - setWalkthroughStatus('idle'); - setWalkthroughError(null); - return; - } const requestId = ++walkthroughRequestRef.current; - setWalkthroughStatus('generating'); setWalkthroughError(null); try { - const result = await window.codiff.getNarrativeWalkthrough( - current.source, - options?.force ? { force: true } : {}, - ); + const structure: NonNullable = + options?.unitId || options?.reviewStructure === 'commit-by-commit' + ? 'units' + : options?.reviewStructure === 'whole-diff' + ? 'whole-diff' + : options?.versionCompare?.walkthroughStructure === 'commit-by-commit' + ? 'units' + : options?.versionCompare?.walkthroughStructure === 'whole-diff' + ? 'whole-diff' + : reviewStrategy?.mode === 'commit-by-commit' + ? 'units' + : reviewStrategy?.mode === 'whole-diff' + ? 'whole-diff' + : 'auto'; + const versionCompareRequest = options?.versionCompare + ? { + fromId: options.versionCompare.fromId, + toId: options.versionCompare.toId, + } + : versionCompareFromId && versionCompareToId + ? { + fromId: versionCompareFromId, + toId: versionCompareToId, + } + : undefined; + const request = { + source: current.source, + structure, + ...(versionCompareRequest ? { versionCompare: versionCompareRequest } : {}), + } satisfies Omit; + if (!options?.force && !options?.unitId) { + const stored = await window.codiff.getStoredReviewWalkthrough(request); + if (requestId !== walkthroughRequestRef.current) { + return; + } + if (stored.status === 'ready') { + setWalkthrough(stored.walkthrough); + setWalkthroughStatus('ready'); + setWalkthroughProgress(null); + return; + } + } + + setWalkthroughStatus('generating'); + setWalkthroughProgress({ + phase: 'preparing', + summary: 'Starting generation.', + }); + const result = await window.codiff.generateReviewWalkthrough({ + ...request, + ...(options?.force ? { force: true } : {}), + ...(options?.unitId ? { unitId: options.unitId } : {}), + }); if (requestId !== walkthroughRequestRef.current) { return; } if (result.status === 'ready') { setWalkthrough(result.walkthrough); setWalkthroughStatus('ready'); + setWalkthroughProgress(null); setWalkthroughError(null); return; } setWalkthrough(null); setWalkthroughStatus('failed'); + setWalkthroughProgress(null); setWalkthroughError(result.reason); } catch (error: unknown) { if (requestId !== walkthroughRequestRef.current) { @@ -295,14 +744,32 @@ function LocalMergeRequestReviewSession({ } setWalkthrough(null); setWalkthroughStatus('failed'); + setWalkthroughProgress(null); setWalkthroughError(error instanceof Error ? error.message : String(error)); } }, - [], + [reviewStrategy, versionCompareFromId, versionCompareToId], ); const title = useMemo(() => getPullRequestTitle(state), [state]); const externalUrl = state.source.type === 'pull-request' ? state.source.url : ''; + const supportsReviewHistory = + state.source.type === 'pull-request' && + (state.source.provider === 'gitlab' || + state.source.provider === 'github' || + state.source.provider == null); + const providerLabel = + state.source.type === 'pull-request' && state.source.provider === 'gitlab' + ? 'GitLab' + : 'GitHub'; + const versionHistoryTitle = + state.source.type === 'pull-request' && state.source.provider === 'gitlab' + ? 'Versions' + : 'Head history'; + const wholeDiffLabel = + state.source.type === 'pull-request' && state.source.provider === 'gitlab' + ? 'Whole MR' + : 'Whole PR'; const resolvedPreferences = useMemo( () => ({ @@ -326,33 +793,65 @@ function LocalMergeRequestReviewSession({ externalUrl={externalUrl} gitIdentity={gitIdentity} initialMode={mode} - onExitVersionCompare={() => { - // History wiring lands in a later plan step. - }} + onExitVersionCompare={supportsReviewHistory ? onExitVersionCompare : undefined} onGenerateWalkthrough={onGenerateWalkthrough} onHome={onHome} onLoadCommitDiff={onLoadCommitDiff} + onLoadVersionCommitDiff={supportsReviewHistory ? onLoadVersionCommitDiff : undefined} onModeChange={setMode} - onOpenVersionCompare={() => { - // History wiring lands in a later plan step. - }} + onOpenVersionCompare={supportsReviewHistory ? onOpenVersionCompare : undefined} onSubmitComment={onSubmitComment} onSubmitGeneralComment={onSubmitGeneralComment} onSubmitReview={onSubmitReview} onUpdateComment={onUpdateComment} onUpdateGeneralComment={onUpdateGeneralComment} - onVersionCompareRangeChange={() => { - // History wiring lands in a later plan step. - }} + onVersionCompareRangeChange={supportsReviewHistory ? onVersionCompareRangeChange : undefined} + onVersionWalkthroughStructureChange={setVersionWalkthroughStructure} preferences={resolvedPreferences} + providerLabel={providerLabel} + reviewStrategy={reviewStrategy} selectedCommitSha={selectedCommitSha} + settingsBar={ + + } state={state} title={title} - versionHistoryLoading={false} - versions={[]} + versionCommitEvolution={versionCommitEvolution} + versionCommitEvolutionError={versionCommitEvolutionError} + versionCommitEvolutionLoading={versionCommitEvolutionLoading} + versionCompare={versionCompare} + versionCompareEnabled={Boolean( + versionCompareFromId || versionCompareToId || versionCompareLoading || versionCompare, + )} + versionCompareError={versionCompareError} + versionCompareFromId={versionCompareFromId} + versionCompareLoading={versionCompareLoading} + versionCompareToId={versionCompareToId} + versionHistoryLabel={versionHistoryTitle} + versionHistoryLoading={versionHistoryLoading} + versionHistoryWarning={historyWarning} + versions={versions} + versionWalkthroughStructure={versionWalkthroughStructure} walkthrough={walkthrough} walkthroughError={walkthroughError} + walkthroughProgress={walkthroughProgress} walkthroughStatus={walkthroughStatus} + wholeDiffLabel={wholeDiffLabel} /> ); } @@ -360,7 +859,7 @@ function LocalMergeRequestReviewSession({ /** * Desktop host adapter for pull-request / merge-request sources. * Feeds Core `MergeRequestReviewApp` with local IPC-backed data and actions. - * Version compare / evolution props stay empty until later plan steps. + * GitLab sources also load package-backed version history / compare / evolution. */ export function LocalMergeRequestReviewHost(props: LocalMergeRequestReviewHostProps) { const sourceKey = getSourceIdentityKey(props.state.source); diff --git a/core/app/components/CommitScopePanel.tsx b/core/app/components/CommitScopePanel.tsx index 80913391..835c3e8b 100644 --- a/core/app/components/CommitScopePanel.tsx +++ b/core/app/components/CommitScopePanel.tsx @@ -8,6 +8,13 @@ import type { } from '../../SharedWalkthroughApp.tsx'; import { CommitRefTooltip } from './CommitRefTooltip.tsx'; +const clickedLink = (target: EventTarget | null): boolean => + typeof target === 'object' && + target != null && + 'closest' in target && + typeof target.closest === 'function' && + target.closest('a') != null; + export type CommitScopePanelProps = { commits: ReadonlyArray; mode: 'merge-request' | 'version-compare'; @@ -81,7 +88,7 @@ export function CommitScopePanel({