Skip to content

Commit 4c5abbd

Browse files
authored
Merge pull request #2 from OpenKnots/okcode/dropdown-instead-of-pills
Replace turn chips with dropdown and add conflict file submenu
2 parents 78507dd + e33bb20 commit 4c5abbd

2 files changed

Lines changed: 115 additions & 168 deletions

File tree

apps/web/src/components/DiffPanel.tsx

Lines changed: 38 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,11 @@ import { ThreadId, type TurnId } from "@okcode/contracts";
66
import {
77
CheckIcon,
88
ChevronDownIcon,
9-
ChevronLeftIcon,
10-
ChevronRightIcon,
119
Columns2Icon,
1210
Rows3Icon,
1311
TextWrapIcon,
1412
} from "lucide-react";
15-
import {
16-
type WheelEvent as ReactWheelEvent,
17-
useCallback,
18-
useEffect,
19-
useMemo,
20-
useRef,
21-
useState,
22-
} from "react";
13+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2314
import { openInPreferredEditor } from "../editorPreferences";
2415
import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
2516
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
@@ -44,6 +35,7 @@ import { formatShortTimestamp } from "../timestampFormat";
4435
import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
4536
import { DiffStatLabel, hasNonZeroStat } from "./chat/DiffStatLabel";
4637
import { Button } from "./ui/button";
38+
import { Select, SelectButton, SelectItem, SelectPopup } from "./ui/select";
4739
import { ToggleGroup, Toggle } from "./ui/toggle-group";
4840

4941
type DiffRenderMode = "stacked" | "split";
@@ -288,10 +280,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
288280
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
289281
const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
290282
const patchViewportRef = useRef<HTMLDivElement>(null);
291-
const turnStripRef = useRef<HTMLDivElement>(null);
292283
const previousDiffOpenRef = useRef(false);
293-
const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
294-
const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
295284
const [reviewStateBySelectionKey, setReviewStateBySelectionKey] = useState<
296285
Record<string, DiffFileReviewStateByPath>
297286
>({});
@@ -553,153 +542,39 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
553542
},
554543
});
555544
};
556-
const updateTurnStripScrollState = useCallback(() => {
557-
const element = turnStripRef.current;
558-
if (!element) {
559-
setCanScrollTurnStripLeft(false);
560-
setCanScrollTurnStripRight(false);
561-
return;
562-
}
563-
564-
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
565-
setCanScrollTurnStripLeft(element.scrollLeft > 4);
566-
setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4);
567-
}, []);
568-
const scrollTurnStripBy = useCallback((offset: number) => {
569-
const element = turnStripRef.current;
570-
if (!element) return;
571-
element.scrollBy({ left: offset, behavior: "smooth" });
572-
}, []);
573-
const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
574-
const element = turnStripRef.current;
575-
if (!element) return;
576-
if (element.scrollWidth <= element.clientWidth + 1) return;
577-
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
578-
579-
event.preventDefault();
580-
element.scrollBy({ left: event.deltaY, behavior: "auto" });
581-
}, []);
582-
583-
useEffect(() => {
584-
const element = turnStripRef.current;
585-
if (!element) return;
586-
587-
const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
588-
const onScroll = () => updateTurnStripScrollState();
589-
590-
element.addEventListener("scroll", onScroll, { passive: true });
591-
592-
const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
593-
resizeObserver.observe(element);
594-
595-
return () => {
596-
window.cancelAnimationFrame(frameId);
597-
element.removeEventListener("scroll", onScroll);
598-
resizeObserver.disconnect();
599-
};
600-
}, [updateTurnStripScrollState]);
601-
602-
useEffect(() => {
603-
const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState());
604-
return () => {
605-
window.cancelAnimationFrame(frameId);
606-
};
607-
}, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]);
608-
609-
useEffect(() => {
610-
const element = turnStripRef.current;
611-
if (!element) return;
612-
613-
const selectedChip = element.querySelector<HTMLElement>("[data-turn-chip-selected='true']");
614-
selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
615-
}, [selectedTurn?.turnId, selectedTurnId]);
545+
const turnSelectValue = selectedTurnId ?? "all";
546+
const handleTurnSelectChange = useCallback(
547+
(value: string | null) => {
548+
if (value === "all" || value === null) {
549+
selectWholeConversation();
550+
} else {
551+
selectTurn(value as TurnId);
552+
}
553+
},
554+
[selectTurn, selectWholeConversation],
555+
);
616556

617557
const headerRow = (
618558
<>
619-
<div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
620-
{canScrollTurnStripLeft && (
621-
<div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-linear-to-r from-card to-transparent" />
622-
)}
623-
{canScrollTurnStripRight && (
624-
<div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-linear-to-l from-card to-transparent" />
625-
)}
626-
<button
627-
type="button"
628-
className={cn(
629-
"absolute left-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",
630-
canScrollTurnStripLeft
631-
? "border-border/70 hover:border-border hover:text-foreground"
632-
: "cursor-not-allowed border-border/40 text-muted-foreground/40",
633-
)}
634-
onClick={() => scrollTurnStripBy(-180)}
635-
disabled={!canScrollTurnStripLeft}
636-
aria-label="Scroll change list left"
637-
>
638-
<ChevronLeftIcon className="size-3.5" />
639-
</button>
640-
<button
641-
type="button"
642-
className={cn(
643-
"absolute right-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",
644-
canScrollTurnStripRight
645-
? "border-border/70 hover:border-border hover:text-foreground"
646-
: "cursor-not-allowed border-border/40 text-muted-foreground/40",
647-
)}
648-
onClick={() => scrollTurnStripBy(180)}
649-
disabled={!canScrollTurnStripRight}
650-
aria-label="Scroll change list right"
651-
>
652-
<ChevronRightIcon className="size-3.5" />
653-
</button>
654-
<div
655-
ref={turnStripRef}
656-
className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
657-
onWheel={onTurnStripWheel}
658-
>
659-
<button
660-
type="button"
661-
className="shrink-0 rounded-md"
662-
onClick={selectWholeConversation}
663-
data-turn-chip-selected={selectedTurnId === null}
664-
>
665-
<div
666-
className={cn(
667-
"rounded-md border px-2 py-1 text-left transition-colors",
668-
selectedTurnId === null
669-
? "border-border bg-accent text-accent-foreground"
670-
: "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
671-
)}
672-
>
673-
<div className="text-[10px] leading-tight font-medium">All changes</div>
674-
</div>
675-
</button>
676-
{orderedTurnDiffSummaries.map((summary) => (
677-
<button
678-
key={summary.turnId}
679-
type="button"
680-
className="shrink-0 rounded-md"
681-
onClick={() => selectTurn(summary.turnId)}
682-
title={`${
683-
summary.turnId === latestSelectedTurnId
684-
? "Latest change"
685-
: `Change ${
686-
summary.checkpointTurnCount ??
687-
inferredCheckpointTurnCountByTurnId[summary.turnId] ??
688-
"?"
689-
}`
690-
}${formatShortTimestamp(summary.completedAt, settings.timestampFormat)}`}
691-
data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}
692-
>
693-
<div
694-
className={cn(
695-
"rounded-md border px-2 py-1 text-left transition-colors",
696-
summary.turnId === selectedTurn?.turnId
697-
? "border-border bg-accent text-accent-foreground"
698-
: "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
699-
)}
700-
>
701-
<div className="flex flex-col gap-0.5">
702-
<span className="text-[10px] leading-tight font-medium">
559+
<div className="min-w-0 flex-1 [-webkit-app-region:no-drag]">
560+
<Select value={turnSelectValue} onValueChange={handleTurnSelectChange}>
561+
<SelectButton size="xs" variant="ghost">
562+
{selectedTurnId === null
563+
? "All changes"
564+
: selectedTurn?.turnId === latestSelectedTurnId
565+
? `Latest • ${formatShortTimestamp(selectedTurn.completedAt, settings.timestampFormat)}`
566+
: `Change ${
567+
selectedTurn?.checkpointTurnCount ??
568+
(selectedTurn ? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId] : null) ??
569+
"?"
570+
}${selectedTurn ? formatShortTimestamp(selectedTurn.completedAt, settings.timestampFormat) : ""}`}
571+
</SelectButton>
572+
<SelectPopup>
573+
<SelectItem value="all">All changes</SelectItem>
574+
{orderedTurnDiffSummaries.map((summary) => (
575+
<SelectItem key={summary.turnId} value={summary.turnId}>
576+
<span className="flex items-center justify-between gap-3">
577+
<span>
703578
{summary.turnId === latestSelectedTurnId
704579
? "Latest"
705580
: `Change ${
@@ -708,14 +583,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
708583
"?"
709584
}`}
710585
</span>
711-
<span className="text-[9px] leading-tight opacity-60">
586+
<span className="text-muted-foreground text-xs">
712587
{formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
713588
</span>
714-
</div>
715-
</div>
716-
</button>
717-
))}
718-
</div>
589+
</span>
590+
</SelectItem>
591+
))}
592+
</SelectPopup>
593+
</Select>
719594
</div>
720595
<div className="flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">
721596
<ToggleGroup

apps/web/src/components/GitActionsControl.tsx

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ChevronDownIcon,
1111
CircleAlertIcon,
1212
CloudUploadIcon,
13+
ExternalLinkIcon,
1314
GitCommitIcon,
1415
InfoIcon,
1516
} from "lucide-react";
@@ -39,7 +40,16 @@ import {
3940
DialogTitle,
4041
} from "~/components/ui/dialog";
4142
import { Group, GroupSeparator } from "~/components/ui/group";
42-
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
43+
import {
44+
Menu,
45+
MenuItem,
46+
MenuPopup,
47+
MenuSeparator,
48+
MenuSub,
49+
MenuSubPopup,
50+
MenuSubTrigger,
51+
MenuTrigger,
52+
} from "~/components/ui/menu";
4353
import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
4454
import { ScrollArea } from "~/components/ui/scroll-area";
4555
import { Textarea } from "~/components/ui/textarea";
@@ -656,6 +666,42 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
656666
[gitStatusForActions?.conflictedFiles],
657667
);
658668

669+
const openConflictedFileInEditor = useCallback(
670+
(filePath: string) => {
671+
if (!gitCwd) return;
672+
673+
const api = readNativeApi();
674+
if (!api) {
675+
toastManager.add({
676+
type: "error",
677+
title: "Editor opening is unavailable.",
678+
data: threadToastData,
679+
});
680+
return;
681+
}
682+
683+
const target = resolvePathLinkTarget(filePath, gitCwd);
684+
const openPromise = openInPreferredEditor(api, target);
685+
686+
toastManager.promise(openPromise, {
687+
loading: { title: "Opening file...", data: threadToastData },
688+
success: () => ({
689+
title: "Opened conflicted file",
690+
description: filePath,
691+
data: threadToastData,
692+
}),
693+
error: (error) => ({
694+
title: "Unable to open file",
695+
description: error instanceof Error ? error.message : "An error occurred.",
696+
data: threadToastData,
697+
}),
698+
});
699+
700+
void openPromise.catch(() => undefined);
701+
},
702+
[gitCwd, threadToastData],
703+
);
704+
659705
const openConflictedFilesInEditor = useCallback(() => {
660706
if (!gitCwd || conflictedFiles.length === 0) {
661707
toastManager.add({
@@ -925,14 +971,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
925971
</p>
926972
)}
927973
{gitStatusForActions?.hasConflicts && (
928-
<div className="space-y-2 px-2 py-2">
974+
<div className="space-y-1 px-2 py-2">
929975
<p className="text-warning text-xs">
930976
Resolve merge conflicts before committing, pulling, pushing, or opening a PR.
931977
</p>
932978
{gitStatusForActions.conflictedFiles.length > 0 ? (
933-
<Button size="xs" variant="outline" onClick={openConflictedFilesInEditor}>
934-
Open conflicted files
935-
</Button>
979+
<MenuSub>
980+
<MenuSubTrigger className="text-xs">
981+
<CircleAlertIcon className="size-3.5 text-warning" />
982+
Conflicted files ({gitStatusForActions.conflictedFiles.length})
983+
</MenuSubTrigger>
984+
<MenuSubPopup>
985+
{gitStatusForActions.conflictedFiles.map((filePath) => (
986+
<MenuItem
987+
key={filePath}
988+
className="font-mono text-xs"
989+
onClick={() => openConflictedFileInEditor(filePath)}
990+
>
991+
{filePath.split("/").pop()}
992+
</MenuItem>
993+
))}
994+
{gitStatusForActions.conflictedFiles.length > 1 && (
995+
<>
996+
<MenuSeparator />
997+
<MenuItem
998+
className="text-xs"
999+
onClick={openConflictedFilesInEditor}
1000+
>
1001+
<ExternalLinkIcon className="size-3.5" />
1002+
Open all
1003+
</MenuItem>
1004+
</>
1005+
)}
1006+
</MenuSubPopup>
1007+
</MenuSub>
9361008
) : null}
9371009
</div>
9381010
)}

0 commit comments

Comments
 (0)