authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 12:22:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 12:22:11-07:00
logce98d4107758ed7a492f94a93b3685a828caeba1
treeaf4b0ca5130b373cd40e1cd68497d87f84c60476
parent1a81ea8c153214ddc74b7046062a8957c2612e8d
signature Signed by PGP key B5690EEEBB952194

refactor(web): mark the current menu value with MenuRadioGroup (#13022)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

4 files changed, 129 insertions(+), 111 deletions(-)

apps/web/src/components/DiffPanel.tsx+55-53
...@@ -72,7 +72,8 @@ import {...@@ -72,7 +72,8 @@ import {
72import {72import {
73 DropdownMenu,73 DropdownMenu,
74 DropdownMenuContent,74 DropdownMenuContent,
75 DropdownMenuItem,75 DropdownMenuRadioGroup,
76 DropdownMenuRadioItem,
76 DropdownMenuSub,77 DropdownMenuSub,
77 DropdownMenuSubContent,78 DropdownMenuSubContent,
78 DropdownMenuSubTrigger,79 DropdownMenuSubTrigger,
...@@ -645,6 +646,26 @@ export default function DiffPanel({...@@ -645,6 +646,26 @@ export default function DiffPanel({
645 if (!routeThreadRef) return;646 if (!routeThreadRef) return;
646 useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef);647 useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef);
647 };648 };
649 // The scope menu has two radio groups: the top-level one treats the latest
650 // turn as "latest", while the turn sub-menu keys every turn by id so the
651 // latest turn is also marked there.
652 const selectedTurnValue = selectedTurn ? `turn:${selectedTurn.turnId}` : "";
653 const selectedScopeValue =
654 selectedTurnId === null
655 ? selectedGitScope
656 : selectedTurn?.turnId === latestTurn?.turnId
657 ? "latest"
658 : selectedTurnValue;
659 const selectScopeValue = (value: string) => {
660 if (value === "unstaged" || value === "branch") {
661 selectGitScope(value);
662 } else if (value === "latest") {
663 if (latestTurn) selectTurn(latestTurn.turnId);
664 } else {
665 const turn = orderedTurnDiffSummaries.find((summary) => `turn:${summary.turnId}` === value);
666 if (turn) selectTurn(turn.turnId);
667 }
668 };
648669
649 const headerRow = (670 const headerRow = (
650 <>671 <>
...@@ -658,61 +679,42 @@ export default function DiffPanel({...@@ -658,61 +679,42 @@ export default function DiffPanel({
658 <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />679 <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />
659 </DropdownMenuTrigger>680 </DropdownMenuTrigger>
660 <DropdownMenuContent align="start" className="w-60">681 <DropdownMenuContent align="start" className="w-60">
661 <DropdownMenuItem682 <DropdownMenuRadioGroup value={selectedScopeValue} onValueChange={selectScopeValue}>
662 className={683 <DropdownMenuRadioItem value="unstaged" closeOnClick>
663 selectedTurnId === null && selectedGitScope === "unstaged"684 <span>Working tree</span>
664 ? "bg-foreground/[0.08]"685 </DropdownMenuRadioItem>
665 : undefined686 <DropdownMenuRadioItem value="branch" closeOnClick>
666 }687 <span>Branch changes</span>
667 onClick={() => selectGitScope("unstaged")}688 </DropdownMenuRadioItem>
668 >689 <DropdownMenuRadioItem value="latest" closeOnClick>
669 <span>Working tree</span>690 <span>Latest turn</span>
670 </DropdownMenuItem>691 </DropdownMenuRadioItem>
671 <DropdownMenuItem692 </DropdownMenuRadioGroup>
672 className={
673 selectedTurnId === null && selectedGitScope === "branch"
674 ? "bg-foreground/[0.08]"
675 : undefined
676 }
677 onClick={() => selectGitScope("branch")}
678 >
679 <span>Branch changes</span>
680 </DropdownMenuItem>
681 <DropdownMenuItem
682 className={
683 selectedTurnId !== null && selectedTurn?.turnId === latestTurn?.turnId
684 ? "bg-foreground/[0.08]"
685 : undefined
686 }
687 onClick={() => {
688 if (latestTurn) selectTurn(latestTurn.turnId);
689 }}
690 >
691 <span>Latest turn</span>
692 </DropdownMenuItem>
693 <DropdownMenuSub>693 <DropdownMenuSub>
694 <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger>694 <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger>
695 <DropdownMenuSubContent className="w-64">695 <DropdownMenuSubContent className="w-64">
696 {orderedTurnDiffSummaries.map((summary) => {696 <DropdownMenuRadioGroup value={selectedTurnValue} onValueChange={selectScopeValue}>
697 const turnCount =697 {orderedTurnDiffSummaries.map((summary) => {
698 summary.checkpointTurnCount ??698 const turnCount =
699 inferredCheckpointTurnCountByTurnId[summary.turnId] ??699 summary.checkpointTurnCount ??
700 "?";700 inferredCheckpointTurnCountByTurnId[summary.turnId] ??
701 return (701 "?";
702 <DropdownMenuItem702 return (
703 key={summary.turnId}703 <DropdownMenuRadioItem
704 className={704 key={summary.turnId}
705 summary.turnId === selectedTurn?.turnId ? "bg-foreground/[0.08]" : undefined705 value={`turn:${summary.turnId}`}
706 }706 closeOnClick
707 onClick={() => selectTurn(summary.turnId)}707 >
708 >708 <span className="flex items-center gap-2">
709 <span>Turn {turnCount}</span>709 <span>Turn {turnCount}</span>
710 <span className="ml-auto text-xs tabular-nums text-muted-foreground">710 <span className="ml-auto text-xs tabular-nums text-muted-foreground">
711 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}711 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
712 </span>712 </span>
713 </DropdownMenuItem>713 </span>
714 );714 </DropdownMenuRadioItem>
715 })}715 );
716 })}
717 </DropdownMenuRadioGroup>
716 </DropdownMenuSubContent>718 </DropdownMenuSubContent>
717 </DropdownMenuSub>719 </DropdownMenuSub>
718 </DropdownMenuContent>720 </DropdownMenuContent>
apps/web/src/components/files/FileBreadcrumbs.tsx+47-33
...@@ -10,6 +10,8 @@ import {...@@ -10,6 +10,8 @@ import {
10 MenuGroup,10 MenuGroup,
11 MenuItem,11 MenuItem,
12 MenuPopup,12 MenuPopup,
13 MenuRadioGroup,
14 MenuRadioItem,
13 MenuSeparator,15 MenuSeparator,
14 MenuTrigger,16 MenuTrigger,
15} from "~/components/ui/menu";17} from "~/components/ui/menu";
...@@ -141,39 +143,51 @@ function BreadcrumbMenuContent(props: {...@@ -141,39 +143,51 @@ function BreadcrumbMenuContent(props: {
141 : "This folder is empty."}143 : "This folder is empty."}
142 </MenuItem>144 </MenuItem>
143 ) : (145 ) : (
144 children.map((entry) => {146 // Files form a radio group keyed by path so the open file is marked as checked;
145 const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath;147 // directories only navigate the menu, so they stay plain items.
146 return (148 <MenuRadioGroup
147 <MenuItem149 value={props.currentFilePath}
148 key={entry.path}150 onValueChange={(path) => {
149 closeOnClick={entry.kind === "file"}151 props.onOpenChange(false);
150 aria-current={isCurrentFile ? "page" : undefined}152 props.onOpenFile(path);
151 className={cn(153 }}
152 isCurrentFile && "bg-foreground/[0.08]",154 >
153 entry.ignored && "text-muted-foreground",155 {children.map((entry) => {
154 )}156 const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath;
155 onClick={() => {157 const row = (
156 if (entry.kind === "directory") {158 <>
157 props.onDirectoryChange(entry.path);159 <PierreEntryIcon pathValue={entry.path} kind={entry.kind} theme={resolvedTheme} />
158 return;160 <Tooltip>
159 }161 <TooltipTrigger render={<span className="min-w-0 flex-1 truncate" />}>
160 props.onOpenChange(false);162 {entry.label}
161 props.onOpenFile(entry.path);163 </TooltipTrigger>
162 }}164 <TooltipPopup side="right">{entry.path}</TooltipPopup>
163 >165 </Tooltip>
164 <PierreEntryIcon pathValue={entry.path} kind={entry.kind} theme={resolvedTheme} />166 </>
165 <Tooltip>167 );
166 <TooltipTrigger render={<span className="min-w-0 flex-1 truncate" />}>168 return entry.kind === "directory" ? (
167 {entry.label}169 <MenuItem
168 </TooltipTrigger>170 key={entry.path}
169 <TooltipPopup side="right" className="max-w-80">171 closeOnClick={false}
170 {entry.path}172 className={entry.ignored ? "text-muted-foreground" : undefined}
171 </TooltipPopup>173 onClick={() => props.onDirectoryChange(entry.path)}
172 </Tooltip>174 >
173 {entry.kind === "directory" ? <ChevronRightIcon /> : null}175 {row}
174 </MenuItem>176 <ChevronRightIcon />
175 );177 </MenuItem>
176 })178 ) : (
179 <MenuRadioItem
180 key={entry.path}
181 value={entry.path}
182 closeOnClick
183 aria-current={isCurrentFile ? "page" : undefined}
184 className={entry.ignored ? "text-muted-foreground" : undefined}
185 >
186 <span className="flex min-w-0 items-center gap-2">{row}</span>
187 </MenuRadioItem>
188 );
189 })}
190 </MenuRadioGroup>
177 )}191 )}
178 </MenuGroup>192 </MenuGroup>
179 {entriesQuery.error && entriesQuery.data !== null ? (193 {entriesQuery.error && entriesQuery.data !== null ? (
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+26-24
...@@ -70,6 +70,8 @@ import {...@@ -70,6 +70,8 @@ import {
70 DropdownMenu,70 DropdownMenu,
71 DropdownMenuContent,71 DropdownMenuContent,
72 DropdownMenuItem,72 DropdownMenuItem,
73 DropdownMenuRadioGroup,
74 DropdownMenuRadioItem,
73 DropdownMenuTrigger,75 DropdownMenuTrigger,
74} from "../ui/menu";76} from "../ui/menu";
75import { toastManager } from "../ui/toast";77import { toastManager } from "../ui/toast";
...@@ -1076,31 +1078,31 @@ function PullRequestCodeTab({...@@ -1076,31 +1078,31 @@ function PullRequestCodeTab({
1076 <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />1078 <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />
1077 </DropdownMenuTrigger>1079 </DropdownMenuTrigger>
1078 <DropdownMenuContent align="start" className="w-80">1080 <DropdownMenuContent align="start" className="w-80">
1079 <DropdownMenuItem1081 <DropdownMenuRadioGroup
1080 className={commit === null ? "bg-foreground/[0.08]" : undefined}1082 value={commit ?? "all"}
1081 onClick={() => onSelectedCommitChange(null)}1083 onValueChange={(value) => onSelectedCommitChange(value === "all" ? null : value)}
1082 >1084 >
1083 <span>All commits</span>1085 <DropdownMenuRadioItem value="all" closeOnClick>
1084 </DropdownMenuItem>1086 <span>All commits</span>
1085 {orderedCommits.slice(0, visibleCommitCount).map((entry) => (1087 </DropdownMenuRadioItem>
1086 <DropdownMenuItem1088 {orderedCommits.slice(0, visibleCommitCount).map((entry) => (
1087 key={entry.oid}1089 <DropdownMenuRadioItem key={entry.oid} value={entry.oid} closeOnClick>
1088 className={entry.oid === commit ? "bg-foreground/[0.08]" : undefined}1090 {/* Headlines run long, and the abbreviated oid after one is what a reader
1089 onClick={() => onSelectedCommitChange(entry.oid)}1091 matches against the commit list on the host. */}
1090 >1092 <span className="flex items-center gap-2">
1091 {/* Headlines run long, and the abbreviated oid after one is what a reader1093 <Tooltip>
1092 matches against the commit list on the host. */}1094 <TooltipTrigger
1093 <Tooltip>1095 render={<span className="min-w-0 truncate">{entry.messageHeadline}</span>}
1094 <TooltipTrigger1096 />
1095 render={<span className="min-w-0 truncate">{entry.messageHeadline}</span>}1097 <TooltipPopup side="top">{entry.messageHeadline}</TooltipPopup>
1096 />1098 </Tooltip>
1097 <TooltipPopup side="top">{entry.messageHeadline}</TooltipPopup>1099 <span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground">
1098 </Tooltip>1100 {entry.oid.slice(0, 7)}
1099 <span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground">1101 </span>
1100 {entry.oid.slice(0, 7)}1102 </span>
1101 </span>1103 </DropdownMenuRadioItem>
1102 </DropdownMenuItem>1104 ))}
1103 ))}1105 </DropdownMenuRadioGroup>
1104 {orderedCommits.length > visibleCommitCount ? (1106 {orderedCommits.length > visibleCommitCount ? (
1105 // Kept out of the radio group: it changes how much of the list is on screen1107 // Kept out of the radio group: it changes how much of the list is on screen
1106 // rather than what the diff is scoped to.1108 // rather than what the diff is scoped to.
scripts/lint-restyle-ceiling.ts+1-1
...@@ -8,7 +8,7 @@ import * as NodeURL from "node:url";...@@ -8,7 +8,7 @@ import * as NodeURL from "node:url";
8// components/ui exports are migrated to variants (see vite.config.ts). This gate keeps the8// components/ui exports are migrated to variants (see vite.config.ts). This gate keeps the
9// count from growing: CI fails when findings exceed the ceiling. Lower the ceiling when you9// count from growing: CI fails when findings exceed the ceiling. Lower the ceiling when you
10// migrate a file, and delete this script when the rule becomes an error.10// migrate a file, and delete this script when the rule becomes an error.
11export const RESTYLE_CEILING = 1165;11export const RESTYLE_CEILING = 1159;
1212
13const RULE = "shadcn(no-restyle)";13const RULE = "shadcn(no-restyle)";
1414