Skip to content

Commit 654345d

Browse files
committed
feat(PLA-121): align chapter side panel closer to hosted
Closes the visible gap between stage-cli and the hosted reviewer's chapter detail page: - Three-dots menu in the navigator with "Copy chapter summary" backed by a vendored `formatChapterAsMarkdown` (mirrors the hosted output). - `v` keyboard shortcut to toggle the current chapter's viewed state, with a `ShortcutTooltip` on the navigator's circle button. - `j`/`k` actually advance through chapter files now — was wired with a hardcoded `undefined` activeFilePath so it always selected the first file. Switched to `useActiveFileOnScroll` like the files page. - Side-panel inner padding is now `pl-6 lg:pl-8 pr-4` so content lines up with the topbar / Run header / tab nav (`px-6 lg:px-8`) instead of hugging the viewport edge. - Dropdown viewed indicator moved from the row right edge to the lower- right corner of the chapter-number circle via a vendored `StatusBadge`. Vendors: `StatusBadge`, `ShortcutTooltip`, `useShortcut`, `formatChapterAsMarkdown` (with regression tests).
1 parent 4bb7056 commit 654345d

11 files changed

Lines changed: 335 additions & 49 deletions

packages/web/src/components/chapter/chapter-file-list.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function ChapterFileList({
1919
onSelectFile,
2020
}: ChapterFileListProps) {
2121
return (
22-
<div className="px-4 py-3">
22+
<div className="py-3 pl-6 pr-4 lg:pl-8">
2323
<h2 className="mb-2 font-medium text-[11px] text-muted-foreground uppercase tracking-wider">
2424
Files <span className="text-muted-foreground/60">({entries.length})</span>
2525
</h2>

packages/web/src/components/chapter/chapter-navigator.tsx

Lines changed: 71 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
import type { Chapter } from "@stage-cli/types/chapters";
22
import { Link } from "@tanstack/react-router";
3-
import { ChevronDown, ChevronLeft, ChevronRight, Circle, CircleCheck } from "lucide-react";
3+
import {
4+
Check,
5+
ChevronDown,
6+
ChevronLeft,
7+
ChevronRight,
8+
Circle,
9+
CircleCheck,
10+
Copy,
11+
MoreHorizontal,
12+
} from "lucide-react";
13+
import { ShortcutTooltip } from "@/components/shared/shortcut-tooltip";
14+
import { StatusBadge } from "@/components/shared/status-badge";
415
import { Button } from "@/components/ui/button";
516
import {
617
DropdownMenu,
@@ -9,6 +20,7 @@ import {
920
DropdownMenuTrigger,
1021
} from "@/components/ui/dropdown-menu";
1122
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
23+
import { SHORTCUT_KEY } from "@/lib/keyboard-shortcuts";
1224
import { cn } from "@/lib/utils";
1325

1426
interface ChapterNavigatorProps {
@@ -18,6 +30,7 @@ interface ChapterNavigatorProps {
1830
allChapters: Chapter[];
1931
viewedChapterIds: ReadonlySet<string>;
2032
onToggleViewed: (externalId: string) => void;
33+
onCopyChapter: () => void;
2134
}
2235

2336
export function ChapterNavigator({
@@ -27,6 +40,7 @@ export function ChapterNavigator({
2740
allChapters,
2841
viewedChapterIds,
2942
onToggleViewed,
43+
onCopyChapter,
3044
}: ChapterNavigatorProps) {
3145
const isViewed = viewedChapterIds.has(chapter.externalId);
3246
const canPrev = chapterIndex > 0;
@@ -35,27 +49,26 @@ export function ChapterNavigator({
3549
const nextChapter = canNext ? allChapters[chapterIndex + 1] : null;
3650

3751
return (
38-
<div className="px-4 py-3">
52+
<div className="pl-6 pr-4 py-3 lg:pl-8">
3953
<div className="flex items-center gap-1">
40-
<Tooltip>
41-
<TooltipTrigger asChild>
42-
<Button
43-
variant="ghost"
44-
size="icon"
45-
className={cn(
46-
"-ml-1.5 size-7 shrink-0 cursor-pointer",
47-
isViewed
48-
? "text-green-600 hover:text-green-700 dark:text-green-500 dark:hover:text-green-400"
49-
: "text-muted-foreground hover:text-foreground",
50-
)}
51-
onClick={() => onToggleViewed(chapter.externalId)}
52-
aria-label={isViewed ? "Unmark as viewed" : "Mark as viewed"}
53-
>
54-
{isViewed ? <CircleCheck className="size-4" /> : <Circle className="size-4" />}
55-
</Button>
56-
</TooltipTrigger>
57-
<TooltipContent>{isViewed ? "Unmark as viewed" : "Mark as viewed"}</TooltipContent>
58-
</Tooltip>
54+
<ShortcutTooltip
55+
shortcutKey={SHORTCUT_KEY.MARK_CHAPTER_AS_VIEWED}
56+
label={isViewed ? "Unmark as viewed" : "Mark as viewed"}
57+
>
58+
<Button
59+
variant="ghost"
60+
size="icon"
61+
className={cn(
62+
"-ml-1.5 size-7 shrink-0 cursor-pointer",
63+
isViewed
64+
? "text-green-600 hover:text-green-700 dark:text-green-500 dark:hover:text-green-400"
65+
: "text-muted-foreground hover:text-foreground",
66+
)}
67+
onClick={() => onToggleViewed(chapter.externalId)}
68+
>
69+
{isViewed ? <CircleCheck className="size-4" /> : <Circle className="size-4" />}
70+
</Button>
71+
</ShortcutTooltip>
5972

6073
<Tooltip>
6174
<TooltipTrigger asChild>
@@ -99,23 +112,26 @@ export function ChapterNavigator({
99112
params={{ runId, chapterNumber: String(ch.order) }}
100113
className={cn("cursor-pointer", isActive && "bg-accent")}
101114
>
102-
<div
103-
className={cn(
104-
"flex size-6 shrink-0 items-center justify-center rounded-full font-bold text-[10px]",
105-
isActive
106-
? "bg-primary text-primary-foreground"
107-
: "bg-muted text-muted-foreground",
108-
)}
115+
<StatusBadge
116+
size="sm"
117+
badge={
118+
isChViewed ? (
119+
<Check className="size-2 text-green-600" strokeWidth={3} />
120+
) : undefined
121+
}
109122
>
110-
{ch.order}
111-
</div>
123+
<div
124+
className={cn(
125+
"flex size-6 shrink-0 items-center justify-center rounded-full font-bold text-[10px]",
126+
isActive
127+
? "bg-primary text-primary-foreground"
128+
: "bg-muted text-muted-foreground",
129+
)}
130+
>
131+
{ch.order}
132+
</div>
133+
</StatusBadge>
112134
<span className="min-w-0 flex-1 truncate text-sm">{ch.title}</span>
113-
{isChViewed && (
114-
<CircleCheck
115-
className="size-3.5 shrink-0 text-green-600 dark:text-green-500"
116-
aria-hidden="true"
117-
/>
118-
)}
119135
</Link>
120136
</DropdownMenuItem>
121137
);
@@ -140,6 +156,25 @@ export function ChapterNavigator({
140156
</TooltipTrigger>
141157
<TooltipContent>Next chapter</TooltipContent>
142158
</Tooltip>
159+
160+
<DropdownMenu>
161+
<DropdownMenuTrigger asChild>
162+
<Button
163+
variant="ghost"
164+
size="icon"
165+
className="size-7 shrink-0 cursor-pointer"
166+
aria-label="Chapter actions"
167+
>
168+
<MoreHorizontal className="size-4" />
169+
</Button>
170+
</DropdownMenuTrigger>
171+
<DropdownMenuContent align="end">
172+
<DropdownMenuItem onClick={onCopyChapter}>
173+
<Copy className="size-4" />
174+
Copy chapter summary
175+
</DropdownMenuItem>
176+
</DropdownMenuContent>
177+
</DropdownMenu>
143178
</div>
144179
</div>
145180
);

packages/web/src/components/chapter/chapter-side-panel.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface ChapterSidePanelProps {
2626
onToggleFileViewed: (filePath: string) => void;
2727
onFocusKeyChange: (keyChangeId: string | null) => void;
2828
onSelectFile: (filePath: string) => void;
29+
onCopyChapter: () => void;
2930
}
3031

3132
export function ChapterSidePanel({
@@ -43,6 +44,7 @@ export function ChapterSidePanel({
4344
onToggleFileViewed,
4445
onFocusKeyChange,
4546
onSelectFile,
47+
onCopyChapter,
4648
}: ChapterSidePanelProps) {
4749
const [width, setWidth] = useState(SSR_FALLBACK_WIDTH);
4850
const cleanupRef = useRef<(() => void) | null>(null);
@@ -101,11 +103,12 @@ export function ChapterSidePanel({
101103
allChapters={allChapters}
102104
viewedChapterIds={viewedChapterIds}
103105
onToggleViewed={onToggleChapterViewed}
106+
onCopyChapter={onCopyChapter}
104107
/>
105108
<Markdown
106109
content={chapter.title}
107110
inheritSize
108-
className="px-4 pb-3 font-semibold text-base leading-snug [&_.md-p]:my-0"
111+
className="pb-3 pl-6 pr-4 font-semibold text-base leading-snug [&_.md-p]:my-0 lg:pl-8"
109112
/>
110113
</div>
111114
<div className="flex-1 overflow-y-auto">

packages/web/src/components/chapter/chapter-summary.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function ChapterSummary({
2020
onFocusKeyChange,
2121
}: ChapterSummaryProps) {
2222
return (
23-
<div className="space-y-4 px-4 py-3">
23+
<div className="space-y-4 py-3 pl-6 pr-4 lg:pl-8">
2424
{chapter.summary && (
2525
<Markdown content={chapter.summary} className="text-muted-foreground text-sm" />
2626
)}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { cloneElement, type ReactElement } from "react";
2+
import { ShortcutLabel } from "@/components/keyboard/shortcut-label";
3+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
4+
import type { ShortcutKey } from "@/lib/keyboard-shortcuts";
5+
import { useShortcut } from "@/lib/use-shortcut";
6+
7+
interface ShortcutTooltipProps {
8+
shortcutKey: ShortcutKey;
9+
label: string;
10+
side?: "top" | "bottom" | "left" | "right";
11+
children: ReactElement<{ "aria-label"?: string; "aria-keyshortcuts"?: string }>;
12+
}
13+
14+
export function ShortcutTooltip({ shortcutKey, label, side, children }: ShortcutTooltipProps) {
15+
const { label: shortcutLabel, ariaKeyshortcuts } = useShortcut(shortcutKey);
16+
17+
return (
18+
<Tooltip>
19+
<TooltipTrigger asChild>
20+
{cloneElement(children, {
21+
"aria-label": label,
22+
"aria-keyshortcuts": ariaKeyshortcuts,
23+
})}
24+
</TooltipTrigger>
25+
<TooltipContent side={side} className="flex items-center gap-0.5">
26+
<span className="mr-1">{label}</span>
27+
<ShortcutLabel label={shortcutLabel} />
28+
</TooltipContent>
29+
</Tooltip>
30+
);
31+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { CSSProperties, ReactNode } from "react";
2+
import { cn } from "@/lib/utils";
3+
4+
const BADGE_SIZE = {
5+
sm: "size-2.5",
6+
md: "size-3",
7+
} as const;
8+
9+
type BadgeSize = keyof typeof BADGE_SIZE;
10+
11+
export function StatusBadge({
12+
children,
13+
badge,
14+
size = "sm",
15+
className,
16+
style,
17+
}: {
18+
children: ReactNode;
19+
badge?: ReactNode;
20+
size?: BadgeSize;
21+
className?: string;
22+
style?: CSSProperties;
23+
}) {
24+
return (
25+
<span className={cn("relative inline-flex shrink-0", className)} style={style}>
26+
{children}
27+
{badge && (
28+
<span
29+
className={cn(
30+
"-right-0.5 -bottom-0.5 absolute flex items-center justify-center rounded-full bg-background",
31+
BADGE_SIZE[size],
32+
)}
33+
>
34+
{badge}
35+
</span>
36+
)}
37+
</span>
38+
);
39+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from "vitest";
2+
import { FILE_STATUS, type PullRequestFile } from "../diff-types";
3+
import { formatChapterAsMarkdown } from "../format-chapter-markdown";
4+
5+
const baseFile: PullRequestFile = {
6+
path: "src/foo.ts",
7+
filename: "foo.ts",
8+
status: FILE_STATUS.MODIFIED,
9+
additions: 5,
10+
deletions: 2,
11+
hunks: [],
12+
};
13+
14+
describe("formatChapterAsMarkdown", () => {
15+
it("renders title, summary, key changes, and files in order", () => {
16+
const md = formatChapterAsMarkdown(
17+
{
18+
id: "c1",
19+
externalId: "ext-c1",
20+
order: 1,
21+
title: "Wire org ID",
22+
summary: "Threads orgId through.",
23+
hunkRefs: [],
24+
keyChanges: [
25+
{ id: "k1", externalId: "ext-k1", content: "Check the auth path", lineRefs: [] },
26+
{ id: "k2", externalId: "ext-k2", content: "Verify the SQL query", lineRefs: [] },
27+
],
28+
},
29+
0,
30+
[{ file: baseFile }],
31+
);
32+
expect(md).toContain("# Chapter 1: Wire org ID");
33+
expect(md).toContain("Threads orgId through.");
34+
expect(md).toContain("## What to Review\n- Check the auth path\n- Verify the SQL query");
35+
expect(md).toContain("## Files\n- src/foo.ts (modified, +5 -2)");
36+
});
37+
38+
it("renders rename arrows when oldPath differs from path", () => {
39+
const md = formatChapterAsMarkdown(
40+
{
41+
id: "c1",
42+
externalId: "ext-c1",
43+
order: 1,
44+
title: "Move it",
45+
summary: "Renamed.",
46+
hunkRefs: [],
47+
keyChanges: [],
48+
},
49+
0,
50+
[
51+
{
52+
file: {
53+
...baseFile,
54+
status: FILE_STATUS.RENAMED,
55+
oldPath: "src/old.ts",
56+
path: "src/new.ts",
57+
additions: 0,
58+
deletions: 0,
59+
},
60+
},
61+
],
62+
);
63+
expect(md).toContain("- src/old.ts → src/new.ts (renamed)");
64+
});
65+
66+
it("omits sections when their content is empty", () => {
67+
const md = formatChapterAsMarkdown(
68+
{
69+
id: "c1",
70+
externalId: "ext-c1",
71+
order: 1,
72+
title: "Empty",
73+
summary: "",
74+
hunkRefs: [],
75+
keyChanges: [],
76+
},
77+
0,
78+
[],
79+
);
80+
expect(md).toBe("# Chapter 1: Empty");
81+
});
82+
});

0 commit comments

Comments
 (0)