From eca5f58263b9cb834471a65526cddff81ba92b58 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Mon, 4 May 2026 10:19:53 -0700 Subject: [PATCH 1/8] refactor: extract LineCounts shared component DRY up the repeated green/red +N/-N line count pattern used in file-picker and file-header into a reusable LineCounts component. --- .../web/src/components/chapter/file-header.tsx | 14 ++++++-------- .../web/src/components/files/file-picker.tsx | 10 ++-------- .../web/src/components/shared/line-counts.tsx | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 16 deletions(-) create mode 100644 packages/web/src/components/shared/line-counts.tsx diff --git a/packages/web/src/components/chapter/file-header.tsx b/packages/web/src/components/chapter/file-header.tsx index f090dc6..532aff1 100644 --- a/packages/web/src/components/chapter/file-header.tsx +++ b/packages/web/src/components/chapter/file-header.tsx @@ -8,6 +8,7 @@ import { } from "lucide-react"; import type { MouseEvent } from "react"; import { useCallback } from "react"; +import { LineCounts } from "@/components/shared/line-counts"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { FILE_STATUS, type PullRequestFile } from "@/lib/diff-types"; import { FILE_STATUS_ICONS, FILE_STATUS_LABELS, FILE_STATUS_TEXT_COLORS } from "@/lib/file-status"; @@ -206,14 +207,11 @@ export function FileHeader({ )}
-
- {file.additions > 0 && ( - +{file.additions} - )} - {file.deletions > 0 && ( - -{file.deletions} - )} -
+ {onToggleViewed && ( diff --git a/packages/web/src/components/files/file-picker.tsx b/packages/web/src/components/files/file-picker.tsx index 1d5f88c..6d35dba 100644 --- a/packages/web/src/components/files/file-picker.tsx +++ b/packages/web/src/components/files/file-picker.tsx @@ -1,5 +1,6 @@ import { ChevronRight, CircleCheck, FileText, Folder, Search } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { LineCounts } from "@/components/shared/line-counts"; import { FILE_STATUS, type PullRequestFile } from "@/lib/diff-types"; import { FILE_STATUS_ICONS, FILE_STATUS_TEXT_COLORS } from "@/lib/file-status"; import { buildFileTree, collapseEmptyFolders, type FileNode, sortFileTree } from "@/lib/file-tree"; @@ -182,14 +183,7 @@ function FilePickerTreeItem({ > {node.name} -
- {file.additions > 0 && ( - +{file.additions} - )} - {file.deletions > 0 && ( - -{file.deletions} - )} -
+ {isViewed && ( + {additions > 0 && +{additions}} + {deletions > 0 && -{deletions}} +
+ ); +} From 0540ca0f743b7ac5fdda1f6cc6360bdfafbd9018 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Mon, 4 May 2026 10:21:27 -0700 Subject: [PATCH 2/8] feat: add ChapterContext with provider and line counts Introduce ChapterContext that centralizes chapter data and per-chapter line counts (linesAdded/linesDeleted). The provider lives in PullRequestLayout, making chapter data available to both the index and detail pages without prop drilling. --- packages/web/src/lib/chapter-context.tsx | 67 +++++++++++++++++++ .../web/src/routes/pull-request-layout.tsx | 5 +- 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/lib/chapter-context.tsx diff --git a/packages/web/src/lib/chapter-context.tsx b/packages/web/src/lib/chapter-context.tsx new file mode 100644 index 0000000..e51e8a6 --- /dev/null +++ b/packages/web/src/lib/chapter-context.tsx @@ -0,0 +1,67 @@ +import type { Chapter } from "@stage-cli/types/chapters"; +import { createContext, type ReactNode, use, useMemo } from "react"; +import { filterFilesForChapter } from "./filter-files-for-chapter"; +import { useChapters } from "./use-chapters"; +import { useDiffPatch } from "./use-diff-patch"; + +export interface ChapterLineCounts { + linesAdded: number; + linesDeleted: number; +} + +interface ChapterContextValue { + runId: string; + chapters: readonly Chapter[]; + chapterLineCountsMap: ReadonlyMap; +} + +const ChapterContext = createContext(null); + +function buildChapterLineCountsMap( + chapters: readonly Chapter[], + patch: string | undefined, +): ReadonlyMap { + const map = new Map(); + if (!patch) return map; + for (const chapter of chapters) { + const entries = filterFilesForChapter(patch, chapter.hunkRefs); + let linesAdded = 0; + let linesDeleted = 0; + for (const entry of entries) { + linesAdded += entry.file.additions; + linesDeleted += entry.file.deletions; + } + map.set(chapter.id, { linesAdded, linesDeleted }); + } + return map; +} + +export function ChapterProvider({ runId, children }: { runId: string; children: ReactNode }) { + const { data: chaptersData } = useChapters(runId); + const { data: patch } = useDiffPatch(runId); + + const chapters = useMemo(() => { + if (!chaptersData?.chapters) return []; + return [...chaptersData.chapters].sort((a, b) => a.order - b.order); + }, [chaptersData?.chapters]); + + const chapterLineCountsMap = useMemo( + () => buildChapterLineCountsMap(chapters, patch), + [chapters, patch], + ); + + const value = useMemo( + () => ({ runId, chapters, chapterLineCountsMap }), + [runId, chapters, chapterLineCountsMap], + ); + + return {children}; +} + +export function useChapterContext(): ChapterContextValue { + const ctx = use(ChapterContext); + if (!ctx) { + throw new Error("useChapterContext must be used within a ChapterProvider"); + } + return ctx; +} diff --git a/packages/web/src/routes/pull-request-layout.tsx b/packages/web/src/routes/pull-request-layout.tsx index 5676e38..35873e1 100644 --- a/packages/web/src/routes/pull-request-layout.tsx +++ b/packages/web/src/routes/pull-request-layout.tsx @@ -6,6 +6,7 @@ import { SectionLabel } from "@/components/pull-request/section-label"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ChapterProvider } from "@/lib/chapter-context"; import { useFileDiffEntries } from "@/lib/parse-diff"; import { useChapters } from "@/lib/use-chapters"; import { useDiffPatch } from "@/lib/use-diff-patch"; @@ -187,7 +188,9 @@ export function PullRequestLayout({ runId }: { runId: string }) { - + + + ); From a6bb855cb43876c5e0e7e4073aa5992c15f77b84 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Mon, 4 May 2026 10:23:13 -0700 Subject: [PATCH 3/8] feat: add line counts to chapter navigator dropdown ChapterNavigator now reads runId, chapters, and line counts from ChapterContext instead of receiving them as props. Each chapter in the dropdown shows its +N/-N line change counts. Removes runId and allChapters props from ChapterSidePanel. --- .../src/components/chapter/chapter-navigator.tsx | 15 +++++++++++---- .../src/components/chapter/chapter-side-panel.tsx | 6 ------ packages/web/src/routes/chapter-detail-page.tsx | 2 -- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/web/src/components/chapter/chapter-navigator.tsx b/packages/web/src/components/chapter/chapter-navigator.tsx index 738f3fa..3ef928e 100644 --- a/packages/web/src/components/chapter/chapter-navigator.tsx +++ b/packages/web/src/components/chapter/chapter-navigator.tsx @@ -10,6 +10,7 @@ import { Copy, MoreHorizontal, } from "lucide-react"; +import { LineCounts } from "@/components/shared/line-counts"; import { ShortcutTooltip } from "@/components/shared/shortcut-tooltip"; import { StatusBadge } from "@/components/shared/status-badge"; import { Button } from "@/components/ui/button"; @@ -19,28 +20,26 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useChapterContext } from "@/lib/chapter-context"; import { SHORTCUT_KEY } from "@/lib/keyboard-shortcuts"; import { cn } from "@/lib/utils"; interface ChapterNavigatorProps { - runId: string; chapter: Chapter; chapterIndex: number; - allChapters: Chapter[]; viewedChapterIds: ReadonlySet; onToggleViewed: (externalId: string) => void; onCopyChapter: () => void; } export function ChapterNavigator({ - runId, chapter, chapterIndex, - allChapters, viewedChapterIds, onToggleViewed, onCopyChapter, }: ChapterNavigatorProps) { + const { runId, chapters: allChapters, chapterLineCountsMap } = useChapterContext(); const isViewed = viewedChapterIds.has(chapter.externalId); const canPrev = chapterIndex > 0; const canNext = chapterIndex < allChapters.length - 1; @@ -100,6 +99,7 @@ export function ChapterNavigator({ {allChapters.map((ch, index) => { const isActive = index === chapterIndex; const isChViewed = viewedChapterIds.has(ch.externalId); + const counts = chapterLineCountsMap.get(ch.id); return ( {ch.title} + {counts && ( + + )} ); diff --git a/packages/web/src/components/chapter/chapter-side-panel.tsx b/packages/web/src/components/chapter/chapter-side-panel.tsx index 69050da..c023a32 100644 --- a/packages/web/src/components/chapter/chapter-side-panel.tsx +++ b/packages/web/src/components/chapter/chapter-side-panel.tsx @@ -12,10 +12,8 @@ const MAX_WIDTH_FRACTION = 0.5; const SSR_FALLBACK_WIDTH = Math.round(1440 * DEFAULT_WIDTH_FRACTION); interface ChapterSidePanelProps { - runId: string; chapter: Chapter; chapterIndex: number; - allChapters: Chapter[]; chapterEntries: FileDiffEntry[]; viewedChapterIds: ReadonlySet; checkedKeyChangeIds: ReadonlySet; @@ -30,10 +28,8 @@ interface ChapterSidePanelProps { } export function ChapterSidePanel({ - runId, chapter, chapterIndex, - allChapters, chapterEntries, viewedChapterIds, checkedKeyChangeIds, @@ -97,10 +93,8 @@ export function ChapterSidePanel({ >
Date: Mon, 4 May 2026 10:24:46 -0700 Subject: [PATCH 4/8] feat: add line counts to chapter side panel header Display +N/-N line change counts below the chapter title in the side panel, sourced from ChapterContext. --- .../src/components/chapter/chapter-side-panel.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/chapter/chapter-side-panel.tsx b/packages/web/src/components/chapter/chapter-side-panel.tsx index c023a32..2f23870 100644 --- a/packages/web/src/components/chapter/chapter-side-panel.tsx +++ b/packages/web/src/components/chapter/chapter-side-panel.tsx @@ -1,6 +1,8 @@ import type { Chapter } from "@stage-cli/types/chapters"; import { useCallback, useEffect, useRef, useState } from "react"; +import { LineCounts } from "@/components/shared/line-counts"; import { Markdown } from "@/components/ui/markdown"; +import { useChapterContext } from "@/lib/chapter-context"; import type { FileDiffEntry } from "@/lib/parse-diff"; import { ChapterFileList } from "./chapter-file-list"; import { ChapterNavigator } from "./chapter-navigator"; @@ -42,6 +44,8 @@ export function ChapterSidePanel({ onSelectFile, onCopyChapter, }: ChapterSidePanelProps) { + const { chapterLineCountsMap } = useChapterContext(); + const lineCounts = chapterLineCountsMap.get(chapter.id); const [width, setWidth] = useState(SSR_FALLBACK_WIDTH); const cleanupRef = useRef<(() => void) | null>(null); @@ -102,8 +106,17 @@ export function ChapterSidePanel({ + {lineCounts ? ( + + ) : ( +
+ )}
Date: Mon, 4 May 2026 10:26:18 -0700 Subject: [PATCH 5/8] feat: add line counts to chapters index page Each chapter entry in the index now shows +N/-N line change counts floated to the right of the chapter title, sourced from ChapterContext. --- packages/web/src/routes/chapters-index-page.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/web/src/routes/chapters-index-page.tsx b/packages/web/src/routes/chapters-index-page.tsx index 34bb7cf..41d2923 100644 --- a/packages/web/src/routes/chapters-index-page.tsx +++ b/packages/web/src/routes/chapters-index-page.tsx @@ -3,10 +3,12 @@ import { Link } from "@tanstack/react-router"; import { ChevronRight, Circle, CircleCheck } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { FileViewRow } from "@/components/chapter"; +import { LineCounts } from "@/components/shared/line-counts"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { type ChapterLineCounts, useChapterContext } from "@/lib/chapter-context"; import { useViewState } from "@/lib/use-view-state"; import { cn } from "@/lib/utils"; @@ -65,6 +67,7 @@ interface ChapterEntryProps { filePaths: string[]; isFilesOpen: boolean; runId: string; + lineCounts: ChapterLineCounts | undefined; onToggleViewed: () => void; onToggleFiles: () => void; onToggleAllFiles: () => void; @@ -76,6 +79,7 @@ function ChapterEntry({ filePaths, isFilesOpen, runId, + lineCounts, onToggleViewed, onToggleFiles, onToggleAllFiles, @@ -115,6 +119,13 @@ function ChapterEntry({ backgroundPosition: "0 calc(100% - 0.35em)", }} > + {lineCounts && ( + + )} >(() => new Set()); const [mounted, setMounted] = useState(false); @@ -221,6 +233,7 @@ function ChaptersList({ chapters, runId, viewedCount }: ChaptersListProps) { filePaths={filePathsByChapter.get(c.id) ?? []} isFilesOpen={openFiles.has(c.id)} runId={runId} + lineCounts={chapterLineCountsMap.get(c.id)} onToggleViewed={() => isViewed ? view.unmarkChapterViewed(externalId) : view.markChapterViewed(externalId) } From 12820503043d49f831e801a3e5c820c3d5d36b10 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Mon, 4 May 2026 10:28:47 -0700 Subject: [PATCH 6/8] refactor: simplify ChapterDetailContent props via context ChapterDetailContent now reads runId and allChapters from ChapterContext instead of receiving them as props, reducing its interface from 5 props to 3. --- .../web/src/routes/chapter-detail-page.tsx | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/web/src/routes/chapter-detail-page.tsx b/packages/web/src/routes/chapter-detail-page.tsx index daa6296..f827a67 100644 --- a/packages/web/src/routes/chapter-detail-page.tsx +++ b/packages/web/src/routes/chapter-detail-page.tsx @@ -11,6 +11,7 @@ import { } from "@/components/files"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { useChapterContext } from "@/lib/chapter-context"; import { FILE_STATUS } from "@/lib/diff-types"; import { filterFilesForChapter } from "@/lib/filter-files-for-chapter"; import { formatChapterAsMarkdown } from "@/lib/format-chapter-markdown"; @@ -67,32 +68,17 @@ export function ChapterDetailPage({ runId, chapterNumber }: ChapterDetailPagePro return ; } - return ( - - ); + return ; } interface ChapterDetailContentProps { - runId: string; chapter: Chapter; chapterIndex: number; - allChapters: Chapter[]; patch: string; } -function ChapterDetailContent({ - runId, - chapter, - chapterIndex, - allChapters, - patch, -}: ChapterDetailContentProps) { +function ChapterDetailContent({ chapter, chapterIndex, patch }: ChapterDetailContentProps) { + const { runId, chapters: allChapters } = useChapterContext(); const view = useViewState(runId); const [focusedKeyChangeId, setFocusedKeyChangeId] = useState(null); From d619d79f66125a41a6f796159f57a1aaf87c5c14 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Mon, 4 May 2026 11:02:06 -0700 Subject: [PATCH 7/8] fix: handle zero line counts spacing in side panel header When a chapter has zero additions and deletions, LineCounts returns null but the fallback spacer was unreachable because the counts object is always truthy. Check for non-zero counts before rendering. --- packages/web/src/components/chapter/chapter-side-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/chapter/chapter-side-panel.tsx b/packages/web/src/components/chapter/chapter-side-panel.tsx index 2f23870..d6dcb88 100644 --- a/packages/web/src/components/chapter/chapter-side-panel.tsx +++ b/packages/web/src/components/chapter/chapter-side-panel.tsx @@ -108,7 +108,7 @@ export function ChapterSidePanel({ inheritSize className="pb-1 pl-6 pr-4 font-semibold text-base leading-snug [&_.md-p]:my-0 lg:pl-8" /> - {lineCounts ? ( + {lineCounts && (lineCounts.linesAdded > 0 || lineCounts.linesDeleted > 0) ? ( Date: Mon, 4 May 2026 11:02:11 -0700 Subject: [PATCH 8/8] fix: use context chapters instead of duplicated local sort ChapterDetailPage now reads the sorted chapters array from ChapterContext instead of maintaining its own duplicate sort, eliminating the fragile coupling between two independent sorts. --- .../web/src/routes/chapter-detail-page.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/web/src/routes/chapter-detail-page.tsx b/packages/web/src/routes/chapter-detail-page.tsx index f827a67..a25d5f4 100644 --- a/packages/web/src/routes/chapter-detail-page.tsx +++ b/packages/web/src/routes/chapter-detail-page.tsx @@ -36,23 +36,13 @@ interface ChapterDetailPageProps { } export function ChapterDetailPage({ runId, chapterNumber }: ChapterDetailPageProps) { - const { - data: chaptersData, - isLoading: chaptersLoading, - error: chaptersError, - } = useChapters(runId); + const { chapters } = useChapterContext(); + const { isLoading: chaptersLoading, error: chaptersError } = useChapters(runId); const { data: patch, isLoading: patchLoading, error: patchError } = useDiffPatch(runId); - const allChapters = useMemo(() => { - if (!chaptersData?.chapters) return []; - return [...chaptersData.chapters].sort((a, b) => a.order - b.order); - }, [chaptersData?.chapters]); - // Look up by `order` rather than indexing — the schema only requires - // positive ints, so chapters can have gaps (1, 3, 5) and array position - // won't match the URL chapter number. const chapter = - chapterNumber === null ? undefined : allChapters.find((c) => c.order === chapterNumber); - const chapterIndex = chapter ? allChapters.indexOf(chapter) : -1; + chapterNumber === null ? undefined : chapters.find((c) => c.order === chapterNumber); + const chapterIndex = chapter ? chapters.indexOf(chapter) : -1; const isLoading = chaptersLoading || patchLoading; const error = chaptersError ?? patchError;