-
- {branch.name}
-
-
-
- {branch.source === "pull-request"
- ? "PR"
- : "Branch"}
-
-
+
+
-
-
- {branch.hasWorktree ? null : (
-
- {actionError ? (
-
+ {pullRequest ? (
+
+ }
+ size="sm"
+ variant="outline"
+ >
+
+ Open on GitHub
+
+ ) : null}
+ {branch.hasWorktree ? null : (
+
+ )}
+
- ) : null}
-
+
+ Refresh
+
+
+
+
+ {actionError ? (
+
+ {actionError}
+
+ ) : null}
);
}
diff --git a/src/lib/components/templates/main-panel/branch-page-tabs.tsx b/src/lib/components/templates/main-panel/branch-page-tabs.tsx
index 53b8fdc..62f7e42 100644
--- a/src/lib/components/templates/main-panel/branch-page-tabs.tsx
+++ b/src/lib/components/templates/main-panel/branch-page-tabs.tsx
@@ -1,7 +1,15 @@
import { BusyIcon } from "@/lib/components/atoms/busy-icon";
-import { TabShell } from "@/lib/components/atoms/tab-shell";
+import { cn } from "@/lib/utils/cn";
+import {
+ Container,
+ FileKey2,
+ Files,
+ MessageSquareText,
+ Play,
+ type LucideIcon,
+} from "lucide-react";
-type BranchPageTab = "general" | "run" | "diff" | "docker" | "env";
+type BranchPageTab = "activity" | "run" | "changes" | "docker" | "env";
type BranchPageTabsProps = {
activeTab: BranchPageTab;
@@ -9,12 +17,12 @@ type BranchPageTabsProps = {
onSelectTab: (tab: BranchPageTab) => void;
};
-const tabs: { label: string; value: BranchPageTab }[] = [
- { label: "General", value: "general" },
- { label: "Run", value: "run" },
- { label: "Diff", value: "diff" },
- { label: "Docker", value: "docker" },
- { label: "Env", value: "env" },
+const tabs: { icon: LucideIcon; label: string; value: BranchPageTab }[] = [
+ { icon: MessageSquareText, label: "Activity", value: "activity" },
+ { icon: Play, label: "Run", value: "run" },
+ { icon: Files, label: "Changes", value: "changes" },
+ { icon: Container, label: "Docker", value: "docker" },
+ { icon: FileKey2, label: "Environment", value: "env" },
];
export function BranchPageTabs({
@@ -23,33 +31,43 @@ export function BranchPageTabs({
onSelectTab,
}: BranchPageTabsProps) {
return (
-
- {tabs.map((tab) => (
-
+ {tabs.map((tab) => {
+ const Icon = tab.icon;
+
+ return (
-
- ))}
-
+ );
+ })}
+
);
}
diff --git a/src/lib/components/templates/main-panel/changes/changes-toolbar.tsx b/src/lib/components/templates/main-panel/changes/changes-toolbar.tsx
new file mode 100644
index 0000000..7a2ca8a
--- /dev/null
+++ b/src/lib/components/templates/main-panel/changes/changes-toolbar.tsx
@@ -0,0 +1,155 @@
+import {
+ ChevronsUpDown,
+ Columns2,
+ Files,
+ List,
+ MessageSquareText,
+ Rows3,
+ WrapText,
+} from "lucide-react";
+
+import { Button } from "@/lib/components/ui/button";
+import { cn } from "@/lib/utils/cn";
+
+export type DiffReviewMode = "continuous" | "focused";
+
+type ChangesToolbarProps = {
+ additions: number;
+ deletions: number;
+ fileCount: number;
+ isUnified: boolean;
+ mode: DiffReviewMode;
+ pendingCommentCount: number;
+ selectedPath?: string;
+ shouldWrap: boolean;
+ onChangeMode: (mode: DiffReviewMode) => void;
+ onOpenFiles: () => void;
+ onOpenReview: () => void;
+ onToggleUnified: () => void;
+ onToggleWrap: () => void;
+};
+
+export function ChangesToolbar({
+ additions,
+ deletions,
+ fileCount,
+ isUnified,
+ mode,
+ pendingCommentCount,
+ selectedPath,
+ shouldWrap,
+ onChangeMode,
+ onOpenFiles,
+ onOpenReview,
+ onToggleUnified,
+ onToggleWrap,
+}: ChangesToolbarProps) {
+ return (
+
+
+
+
+ +{additions}{" "}
+ −{deletions}
+
+
+
+
+
+ onChangeMode("continuous")}
+ />
+ onChangeMode("focused")}
+ />
+
+
+
+
+
+
+ );
+}
+
+function ModeButton({
+ active,
+ icon: Icon,
+ label,
+ onClick,
+}: {
+ active: boolean;
+ icon: typeof Rows3;
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/src/lib/components/templates/main-panel/changes/continuous-diff.tsx b/src/lib/components/templates/main-panel/changes/continuous-diff.tsx
new file mode 100644
index 0000000..ac1241c
--- /dev/null
+++ b/src/lib/components/templates/main-panel/changes/continuous-diff.tsx
@@ -0,0 +1,204 @@
+import {
+ CodeView,
+ type CodeViewHandle,
+ type CodeViewItem,
+ type DiffLineAnnotation,
+ type FileDiffMetadata,
+} from "@pierre/diffs/react";
+import { MessageSquarePlus } from "lucide-react";
+import { useEffect, useMemo, useRef } from "react";
+
+import type {
+ DiffCommentDraft,
+ DiffReviewAnnotation,
+} from "@/lib/components/templates/main-panel/changes/diff-review-types";
+import { ReviewAnnotation } from "@/lib/components/templates/main-panel/changes/review-annotation";
+import type { BranchDiffFile, PullRequestReviewComment } from "@/types/pr-run";
+
+type ContinuousDiffProps = {
+ baseBranchName?: string;
+ branchName: string;
+ comments: PullRequestReviewComment[];
+ draft?: DiffCommentDraft;
+ files: BranchDiffFile[];
+ fileDiffs: FileDiffMetadata[];
+ isUnified: boolean;
+ onChangeDraft: (draft?: DiffCommentDraft) => void;
+ projectId: string;
+ pullRequestNumber?: number;
+ shouldWrap: boolean;
+ targetPath?: string;
+};
+
+export function ContinuousDiff({
+ baseBranchName,
+ branchName,
+ comments,
+ draft,
+ files,
+ fileDiffs,
+ isUnified,
+ onChangeDraft,
+ projectId,
+ pullRequestNumber,
+ shouldWrap,
+ targetPath,
+}: ContinuousDiffProps) {
+ const codeViewRef = useRef
>(null);
+ const fileByPath = useMemo(
+ () => new Map(files.map((file) => [file.path, file])),
+ [files],
+ );
+ const items = useMemo[]>(
+ () =>
+ fileDiffs.map((fileDiff) => ({
+ annotations: buildDiffAnnotations(
+ fileDiff.name,
+ comments,
+ draft,
+ ),
+ fileDiff,
+ id: fileDiff.name,
+ type: "diff",
+ version:
+ comments.length + (draft?.path === fileDiff.name ? 1 : 0),
+ })),
+ [comments, draft, fileDiffs],
+ );
+
+ useEffect(() => {
+ if (!targetPath) {
+ return;
+ }
+
+ codeViewRef.current?.scrollTo({
+ align: "start",
+ behavior: "smooth",
+ id: targetPath,
+ offset: 8,
+ type: "item",
+ });
+ }, [targetPath]);
+
+ return (
+
+ ref={codeViewRef}
+ className="h-full min-h-0"
+ disableWorkerPool
+ items={items}
+ options={{
+ diffIndicators: "bars",
+ diffStyle: isUnified ? "unified" : "split",
+ enableGutterUtility: Boolean(pullRequestNumber),
+ enableLineSelection: Boolean(pullRequestNumber),
+ hunkSeparators: "line-info-basic",
+ layout: { gap: 10, paddingBottom: 12, paddingTop: 8 },
+ lineDiffType: "word",
+ lineHoverHighlight: "both",
+ overflow: shouldWrap ? "wrap" : "scroll",
+ stickyHeaders: true,
+ themeType: "system",
+ }}
+ renderAnnotation={(annotation) =>
+ annotation.metadata && pullRequestNumber ? (
+ onChangeDraft(undefined)}
+ />
+ ) : null
+ }
+ renderGutterUtility={(getHoveredLine, item) => (
+
+ )}
+ renderHeaderMetadata={(item) => {
+ if (item.type !== "diff") {
+ return null;
+ }
+
+ const file = fileByPath.get(item.id);
+ return file ? (
+
+ +{file.additions}{" "}
+ −{file.deletions}
+
+ ) : null;
+ }}
+ onSelectedLinesChange={(selection) => {
+ if (!selection || !pullRequestNumber) {
+ return;
+ }
+
+ onChangeDraft({
+ endLine: selection.range.end,
+ endSide:
+ selection.range.endSide ??
+ selection.range.side ??
+ "additions",
+ path: selection.id,
+ startLine: selection.range.start,
+ startSide: selection.range.side ?? "additions",
+ });
+ }}
+ />
+ );
+}
+
+export function buildDiffAnnotations(
+ path: string,
+ comments: PullRequestReviewComment[],
+ draft?: DiffCommentDraft,
+): DiffLineAnnotation[] {
+ const annotations: DiffLineAnnotation[] = comments
+ .filter(
+ (comment) =>
+ comment.path === path &&
+ !comment.isOutdated &&
+ comment.line !== undefined,
+ )
+ .map((comment) => ({
+ lineNumber: comment.line!,
+ metadata: { comment, kind: "comment" },
+ side: comment.side === "LEFT" ? "deletions" : "additions",
+ }));
+
+ if (draft?.path === path) {
+ annotations.push({
+ lineNumber: draft.endLine,
+ metadata: { draft, kind: "draft" },
+ side: draft.endSide,
+ });
+ }
+
+ return annotations;
+}
diff --git a/src/lib/components/templates/main-panel/changes/diff-review-types.ts b/src/lib/components/templates/main-panel/changes/diff-review-types.ts
new file mode 100644
index 0000000..684d19b
--- /dev/null
+++ b/src/lib/components/templates/main-panel/changes/diff-review-types.ts
@@ -0,0 +1,13 @@
+import type { PullRequestReviewComment } from "@/types/pr-run";
+
+export type DiffCommentDraft = {
+ endLine: number;
+ endSide: "additions" | "deletions";
+ path: string;
+ startLine: number;
+ startSide: "additions" | "deletions";
+};
+
+export type DiffReviewAnnotation =
+ | { comment: PullRequestReviewComment; kind: "comment" }
+ | { draft: DiffCommentDraft; kind: "draft" };
diff --git a/src/lib/components/templates/main-panel/changes/file-picker.tsx b/src/lib/components/templates/main-panel/changes/file-picker.tsx
new file mode 100644
index 0000000..c8d90d6
--- /dev/null
+++ b/src/lib/components/templates/main-panel/changes/file-picker.tsx
@@ -0,0 +1,130 @@
+import { File, Search } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import {
+ Dialog,
+ DialogDescription,
+ DialogHeader,
+ DialogPanel,
+ DialogPopup,
+ DialogTitle,
+} from "@/lib/components/ui/dialog";
+import { Input } from "@/lib/components/ui/input";
+import { cn } from "@/lib/utils/cn";
+import type { BranchDiffFile } from "@/types/pr-run";
+
+type FilePickerProps = {
+ files: BranchDiffFile[];
+ onOpenChange: (open: boolean) => void;
+ onSelect: (path: string) => void;
+ open: boolean;
+ selectedPath?: string;
+};
+
+export function FilePicker({
+ files,
+ onOpenChange,
+ onSelect,
+ open,
+ selectedPath,
+}: FilePickerProps) {
+ const [search, setSearch] = useState("");
+ const filteredFiles = useMemo(() => {
+ const query = search.trim().toLowerCase();
+ return query
+ ? files.filter((file) => file.path.toLowerCase().includes(query))
+ : files;
+ }, [files, search]);
+
+ return (
+
+ );
+}
diff --git a/src/lib/components/templates/main-panel/changes/focused-diff.tsx b/src/lib/components/templates/main-panel/changes/focused-diff.tsx
new file mode 100644
index 0000000..3058669
--- /dev/null
+++ b/src/lib/components/templates/main-panel/changes/focused-diff.tsx
@@ -0,0 +1,171 @@
+import {
+ FileDiff,
+ type FileDiffMetadata,
+ type SelectedLineRange,
+} from "@pierre/diffs/react";
+import { ChevronLeft, ChevronRight, MessageSquarePlus } from "lucide-react";
+
+import type {
+ DiffCommentDraft,
+ DiffReviewAnnotation,
+} from "@/lib/components/templates/main-panel/changes/diff-review-types";
+import { buildDiffAnnotations } from "@/lib/components/templates/main-panel/changes/continuous-diff";
+import { ReviewAnnotation } from "@/lib/components/templates/main-panel/changes/review-annotation";
+import { Button } from "@/lib/components/ui/button";
+import type { PullRequestReviewComment } from "@/types/pr-run";
+
+type FocusedDiffProps = {
+ baseBranchName?: string;
+ branchName: string;
+ comments: PullRequestReviewComment[];
+ draft?: DiffCommentDraft;
+ fileDiff: FileDiffMetadata;
+ hasNext: boolean;
+ hasPrevious: boolean;
+ isUnified: boolean;
+ onChangeDraft: (draft?: DiffCommentDraft) => void;
+ onNext: () => void;
+ onPrevious: () => void;
+ projectId: string;
+ pullRequestNumber?: number;
+ shouldWrap: boolean;
+};
+
+export function FocusedDiff({
+ baseBranchName,
+ branchName,
+ comments,
+ draft,
+ fileDiff,
+ hasNext,
+ hasPrevious,
+ isUnified,
+ onChangeDraft,
+ onNext,
+ onPrevious,
+ projectId,
+ pullRequestNumber,
+ shouldWrap,
+}: FocusedDiffProps) {
+ const selectedLines: SelectedLineRange | null =
+ draft && draft.path === fileDiff.name
+ ? {
+ end: draft.endLine,
+ endSide: draft.endSide,
+ side: draft.startSide,
+ start: draft.startLine,
+ }
+ : null;
+
+ function selectRange(range: SelectedLineRange | null) {
+ if (!range || !pullRequestNumber) {
+ return;
+ }
+
+ onChangeDraft({
+ endLine: range.end,
+ endSide: range.endSide ?? range.side ?? "additions",
+ path: fileDiff.name,
+ startLine: range.start,
+ startSide: range.side ?? "additions",
+ });
+ }
+
+ return (
+
+
+
+ {fileDiff.name}
+
+
+
+
+
+
+
+
+ disableWorkerPool
+ fileDiff={fileDiff}
+ lineAnnotations={buildDiffAnnotations(
+ fileDiff.name,
+ comments,
+ draft,
+ )}
+ options={{
+ diffIndicators: "bars",
+ diffStyle: isUnified ? "unified" : "split",
+ enableGutterUtility: Boolean(pullRequestNumber),
+ enableLineSelection: Boolean(pullRequestNumber),
+ hunkSeparators: "line-info-basic",
+ lineDiffType: "word",
+ lineHoverHighlight: "both",
+ onLineSelected: selectRange,
+ overflow: shouldWrap ? "wrap" : "scroll",
+ stickyHeader: true,
+ themeType: "system",
+ }}
+ renderAnnotation={(annotation) =>
+ annotation.metadata && pullRequestNumber ? (
+ onChangeDraft(undefined)}
+ />
+ ) : null
+ }
+ renderGutterUtility={(getHoveredLine) => (
+
+ )}
+ selectedLines={selectedLines}
+ />
+
+
+ );
+}
diff --git a/src/lib/components/templates/main-panel/changes/index.tsx b/src/lib/components/templates/main-panel/changes/index.tsx
new file mode 100644
index 0000000..812a70e
--- /dev/null
+++ b/src/lib/components/templates/main-panel/changes/index.tsx
@@ -0,0 +1,286 @@
+import { parsePatchFiles } from "@pierre/diffs";
+import { useEffect, useMemo, useState } from "react";
+
+import { isHandledSshPromptError } from "@/lib/api";
+import { EmptyState } from "@/lib/components/atoms/empty-state";
+import { Skeleton } from "@/lib/components/atoms/skeleton";
+import { Surface } from "@/lib/components/atoms/surface";
+import { ReviewComposer } from "@/lib/components/templates/main-panel/activity/review-composer";
+import {
+ ChangesToolbar,
+ type DiffReviewMode,
+} from "@/lib/components/templates/main-panel/changes/changes-toolbar";
+import { ContinuousDiff } from "@/lib/components/templates/main-panel/changes/continuous-diff";
+import type { DiffCommentDraft } from "@/lib/components/templates/main-panel/changes/diff-review-types";
+import { FilePicker } from "@/lib/components/templates/main-panel/changes/file-picker";
+import { FocusedDiff } from "@/lib/components/templates/main-panel/changes/focused-diff";
+import { useBranchDiffQuery } from "@/lib/hooks/query/use-branch-diff-query";
+import { useSshPassphraseStore } from "@/lib/hooks/store/use-ssh-passphrase-store";
+import { getErrorMessage } from "@/lib/utils/get-error-message";
+import type { WorktreeActivityResult } from "@/types/pr-run";
+
+type WorktreeChangesProps = {
+ activity?: WorktreeActivityResult;
+ activityError?: string;
+ baseBranchName?: string;
+ branchName: string;
+ projectId: string;
+ pullRequestNumber?: number;
+};
+
+const REVIEW_MODE_KEY = "pr-run.diff.review-mode";
+const WRAP_LINES_KEY = "pr-run.diff.wrap-lines";
+const VIEW_MODE_KEY = "pr-run.diff.view-mode";
+
+export function WorktreeChanges({
+ activity,
+ activityError,
+ baseBranchName,
+ branchName,
+ projectId,
+ pullRequestNumber,
+}: WorktreeChangesProps) {
+ const [mode, setMode] = useState(() =>
+ localStorage.getItem(REVIEW_MODE_KEY) === "focused"
+ ? "focused"
+ : "continuous",
+ );
+ const [isUnified, setIsUnified] = useState(
+ () => localStorage.getItem(VIEW_MODE_KEY) !== "split",
+ );
+ const [shouldWrap, setShouldWrap] = useState(
+ () => localStorage.getItem(WRAP_LINES_KEY) === "true",
+ );
+ const [selectedPath, setSelectedPath] = useState();
+ const [continuousTargetPath, setContinuousTargetPath] = useState();
+ const [draft, setDraft] = useState();
+ const [isFilePickerOpen, setIsFilePickerOpen] = useState(false);
+ const [isReviewOpen, setIsReviewOpen] = useState(false);
+ const diffQuery = useBranchDiffQuery(projectId, branchName, baseBranchName);
+ const isAwaitingSshPassphrase = isHandledSshPromptError(diffQuery.error);
+ const fileDiffs = useMemo(
+ () => parseFileDiffs(diffQuery.data?.patch ?? "", branchName),
+ [branchName, diffQuery.data?.patch],
+ );
+ const selectedIndex = Math.max(
+ 0,
+ fileDiffs.findIndex((file) => file.name === selectedPath),
+ );
+ const selectedFileDiff = fileDiffs[selectedIndex];
+
+ useEffect(() => {
+ setSelectedPath(undefined);
+ setDraft(undefined);
+ }, [branchName, projectId]);
+
+ useEffect(() => {
+ const firstPath = fileDiffs[0]?.name;
+ const selectionExists = fileDiffs.some(
+ (file) => file.name === selectedPath,
+ );
+
+ if (firstPath && (!selectedPath || !selectionExists)) {
+ setSelectedPath(firstPath);
+ setDraft(undefined);
+ }
+ }, [fileDiffs, selectedPath]);
+
+ useEffect(() => {
+ localStorage.setItem(REVIEW_MODE_KEY, mode);
+ }, [mode]);
+
+ useEffect(() => {
+ localStorage.setItem(VIEW_MODE_KEY, isUnified ? "unified" : "split");
+ }, [isUnified]);
+
+ useEffect(() => {
+ localStorage.setItem(WRAP_LINES_KEY, String(shouldWrap));
+ }, [shouldWrap]);
+
+ useEffect(() => {
+ if (!isAwaitingSshPassphrase) {
+ return;
+ }
+
+ useSshPassphraseStore
+ .getState()
+ .setRetryAction("main-panel:diff", () =>
+ diffQuery.refetch().then(() => undefined),
+ );
+ return () =>
+ useSshPassphraseStore
+ .getState()
+ .setRetryAction("main-panel:diff", null);
+ }, [diffQuery, isAwaitingSshPassphrase]);
+
+ if (diffQuery.isPending) {
+ return ;
+ }
+
+ if (isAwaitingSshPassphrase) {
+ return (
+
+ Waiting for SSH passphrase…
+
+ );
+ }
+
+ if (diffQuery.error) {
+ return (
+
+ {getErrorMessage(diffQuery.error)}
+
+ );
+ }
+
+ if (!diffQuery.data || diffQuery.data.files.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ function selectFile(path: string) {
+ setSelectedPath(path);
+ setDraft(undefined);
+
+ if (mode === "continuous") {
+ setContinuousTargetPath(undefined);
+ requestAnimationFrame(() => setContinuousTargetPath(path));
+ }
+ }
+
+ const reviewComments = activity?.reviewComments ?? [];
+ const pendingCommentCount = activity?.pendingReview?.comments.length ?? 0;
+
+ return (
+
+ {
+ setMode(nextMode);
+ setDraft(undefined);
+ }}
+ onOpenFiles={() => setIsFilePickerOpen(true)}
+ onOpenReview={() => setIsReviewOpen((open) => !open)}
+ onToggleUnified={() => setIsUnified((value) => !value)}
+ onToggleWrap={() => setShouldWrap((value) => !value)}
+ />
+
+ {activityError ? (
+
+ {activityError} Review comments and actions are unavailable.
+
+ ) : null}
+
+
+ {mode === "continuous" ? (
+
+ ) : selectedFileDiff ? (
+ 0}
+ isUnified={isUnified}
+ projectId={projectId}
+ pullRequestNumber={pullRequestNumber}
+ shouldWrap={shouldWrap}
+ onChangeDraft={setDraft}
+ onNext={() => {
+ const path = fileDiffs[selectedIndex + 1]?.name;
+ if (path) selectFile(path);
+ }}
+ onPrevious={() => {
+ const path = fileDiffs[selectedIndex - 1]?.name;
+ if (path) selectFile(path);
+ }}
+ />
+ ) : null}
+
+
+ {isReviewOpen &&
+ pullRequestNumber &&
+ activity?.integration.status === "available" ? (
+
+
+
+ ) : null}
+
+
+
+ );
+}
+
+function parseFileDiffs(patch: string, branchName: string) {
+ if (!patch.trim()) {
+ return [];
+ }
+
+ return parsePatchFiles(patch, branchName).flatMap((item) => item.files);
+}
+
+function ChangesSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/lib/components/templates/main-panel/changes/review-annotation.tsx b/src/lib/components/templates/main-panel/changes/review-annotation.tsx
new file mode 100644
index 0000000..7204240
--- /dev/null
+++ b/src/lib/components/templates/main-panel/changes/review-annotation.tsx
@@ -0,0 +1,182 @@
+import { MessageSquareText, Send, X } from "lucide-react";
+import { useState } from "react";
+
+import { ActivityAvatar } from "@/lib/components/templates/main-panel/activity/activity-avatar";
+import type {
+ DiffCommentDraft,
+ DiffReviewAnnotation,
+} from "@/lib/components/templates/main-panel/changes/diff-review-types";
+import { Button } from "@/lib/components/ui/button";
+import { Textarea } from "@/lib/components/ui/textarea";
+import { toast } from "@/lib/components/ui/toast";
+import { tryPromise } from "@/lib/error";
+import { usePullRequestReviewMutations } from "@/lib/hooks/query/use-pull-request-review-mutations";
+import { getErrorMessage } from "@/lib/utils/get-error-message";
+import type { ReviewCommentMode } from "@/types/pr-run";
+
+type ReviewAnnotationProps = {
+ annotation: DiffReviewAnnotation;
+ baseBranchName?: string;
+ branchName: string;
+ onCloseDraft: () => void;
+ projectId: string;
+ pullRequestNumber: number;
+};
+
+export function ReviewAnnotation({
+ annotation,
+ baseBranchName,
+ branchName,
+ onCloseDraft,
+ projectId,
+ pullRequestNumber,
+}: ReviewAnnotationProps) {
+ if (annotation.kind === "comment") {
+ const { comment } = annotation;
+
+ return (
+
+
+
+
{comment.author.login}
+
+ {comment.body}
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function InlineReviewComposer({
+ baseBranchName,
+ branchName,
+ draft,
+ projectId,
+ pullRequestNumber,
+ onClose,
+}: {
+ baseBranchName?: string;
+ branchName: string;
+ draft: DiffCommentDraft;
+ projectId: string;
+ pullRequestNumber: number;
+ onClose: () => void;
+}) {
+ const [body, setBody] = useState("");
+ const { reviewCommentMutation } = usePullRequestReviewMutations({
+ baseBranchName,
+ branchName,
+ projectId,
+ pullRequestNumber,
+ });
+
+ async function submit(mode: ReviewCommentMode) {
+ if (!body.trim()) {
+ return;
+ }
+
+ const isRange =
+ draft.startLine !== draft.endLine ||
+ draft.startSide !== draft.endSide;
+ const [error] = await tryPromise(
+ reviewCommentMutation.mutateAsync({
+ body: body.trim(),
+ line: draft.endLine,
+ mode,
+ path: draft.path,
+ side: toGitHubSide(draft.endSide),
+ startLine: isRange ? draft.startLine : undefined,
+ startSide: isRange ? toGitHubSide(draft.startSide) : undefined,
+ }),
+ );
+
+ if (error) {
+ toast.error(getErrorMessage(error), { timeout: 3200 });
+ return;
+ }
+
+ toast.success(
+ mode === "pending" ? "Added to pending review." : "Comment added.",
+ { timeout: 2200 },
+ );
+ onClose();
+ }
+
+ return (
+
+
+
+
+ {draft.startLine === draft.endLine
+ ? `Comment on line ${draft.endLine}`
+ : `Comment on lines ${draft.startLine}–${draft.endLine}`}
+
+
+
+
+ );
+}
+
+function toGitHubSide(side: "additions" | "deletions") {
+ return side === "additions" ? ("RIGHT" as const) : ("LEFT" as const);
+}
diff --git a/src/lib/components/templates/main-panel/index.tsx b/src/lib/components/templates/main-panel/index.tsx
index 3548c9c..2513c02 100644
--- a/src/lib/components/templates/main-panel/index.tsx
+++ b/src/lib/components/templates/main-panel/index.tsx
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
import { isHandledSshPromptError } from "@/lib/api";
import { EmptyState } from "@/lib/components/atoms/empty-state";
@@ -15,9 +16,11 @@ import {
MainPanelLoadingState,
MainPanelState,
} from "@/lib/components/templates/main-panel/main-panel-state";
-import { CommitHistory } from "@/lib/components/molecules/commit-history";
-import { useCommitHistoryQuery } from "@/lib/hooks/query/use-commit-history-query";
+import { ScrollArea } from "@/lib/components/ui/scroll-area";
+import { WorktreeActivity } from "@/lib/components/templates/main-panel/activity";
+import { useWorktreeActivityQuery } from "@/lib/hooks/query/use-worktree-activity-query";
import { useProjectBranchesQuery } from "@/lib/hooks/query/use-project-branches-query";
+import { prRunQueryKeys } from "@/lib/hooks/query/query-keys";
import { useSshPassphraseStore } from "@/lib/hooks/store/use-ssh-passphrase-store";
import {
getWorktreeOwnerKey,
@@ -25,17 +28,18 @@ import {
} from "@/lib/hooks/store/use-worktree-terminal-store";
import { cn } from "@/lib/utils/cn";
import { getErrorMessage } from "@/lib/utils/get-error-message";
+import { tryPromise } from "@/lib/error";
import type { ProjectConfig } from "@/types/pr-run";
-const BranchScriptsSection = lazy(() =>
- import("@/lib/components/templates/branch-scripts-section").then(
- (module) => ({ default: module.BranchScriptsSection }),
- ),
+const WorktreeRun = lazy(() =>
+ import("@/lib/components/templates/main-panel/run").then((module) => ({
+ default: module.WorktreeRun,
+ })),
);
-const BranchDiffPanel = lazy(() =>
- import("@/lib/components/templates/branch-diff-panel").then((module) => ({
- default: module.BranchDiffPanel,
+const WorktreeChanges = lazy(() =>
+ import("@/lib/components/templates/main-panel/changes").then((module) => ({
+ default: module.WorktreeChanges,
})),
);
@@ -79,7 +83,9 @@ export function MainPanel({
onCreateScript,
onRunTerminalContextChange,
}: MainPanelProps) {
- const [activeTab, setActiveTab] = useState("general");
+ const [activeTab, setActiveTab] = useState("changes");
+ const [isRefreshingActiveTab, setIsRefreshingActiveTab] = useState(false);
+ const queryClient = useQueryClient();
const selectedKey =
project && branchName ? `${project.id}:${branchName}` : "";
const selectedTerminalOwner = useWorktreeTerminalStore((state) =>
@@ -96,17 +102,23 @@ export function MainPanel({
),
[branchName, branchesQuery.data],
);
- const commitsQuery = useCommitHistoryQuery(
+ const activityQuery = useWorktreeActivityQuery(
project?.id,
branchName ?? undefined,
selectedBranch?.compareBranchName,
- Boolean(project && branchName && selectedBranch),
+ selectedBranch?.pullRequest?.number,
+ Boolean(
+ project &&
+ branchName &&
+ selectedBranch &&
+ (activeTab === "activity" || activeTab === "changes"),
+ ),
);
const isAwaitingBranchPassphrase = isHandledSshPromptError(
branchesQuery.error,
);
- const isAwaitingCommitPassphrase = isHandledSshPromptError(
- commitsQuery.error,
+ const isAwaitingActivityPassphrase = isHandledSshPromptError(
+ activityQuery.error,
);
const isRunTabBusy = Boolean(
selectedTerminalOwner?.tabs.some(
@@ -143,7 +155,7 @@ export function MainPanel({
});
useEffect(() => {
- setActiveTab("general");
+ setActiveTab("changes");
}, [selectedKey]);
useEffect(() => {
@@ -161,20 +173,30 @@ export function MainPanel({
useSshPassphraseStore
.getState()
- .setRetryAction(() =>
+ .setRetryAction("main-panel:branches", () =>
branchesQuery.refetch().then(() => undefined),
);
+ return () =>
+ useSshPassphraseStore
+ .getState()
+ .setRetryAction("main-panel:branches", null);
}, [branchesQuery, isAwaitingBranchPassphrase]);
useEffect(() => {
- if (!isAwaitingCommitPassphrase) {
+ if (!isAwaitingActivityPassphrase) {
return;
}
useSshPassphraseStore
.getState()
- .setRetryAction(() => commitsQuery.refetch().then(() => undefined));
- }, [commitsQuery, isAwaitingCommitPassphrase]);
+ .setRetryAction("main-panel:activity", () =>
+ activityQuery.refetch().then(() => undefined),
+ );
+ return () =>
+ useSshPassphraseStore
+ .getState()
+ .setRetryAction("main-panel:activity", null);
+ }, [activityQuery, isAwaitingActivityPassphrase]);
if (!project || !branchName) {
return ;
@@ -200,11 +222,12 @@ export function MainPanel({
);
}
- const commitsError =
- commitsQuery.error && !isAwaitingCommitPassphrase
- ? getErrorMessage(commitsQuery.error)
+ const activityError =
+ activityQuery.error && !isAwaitingActivityPassphrase
+ ? getErrorMessage(activityQuery.error)
: undefined;
const currentBranch = selectedBranch;
+ const currentProjectId = project.id;
const worktreeOwnerKey = getWorktreeOwnerKey(
project.id,
currentBranch.name,
@@ -224,6 +247,66 @@ export function MainPanel({
});
}
+ async function refreshActiveTab() {
+ setIsRefreshingActiveTab(true);
+ const requests: Promise[] = [branchesQuery.refetch()];
+
+ if (activeTab === "activity" || activeTab === "changes") {
+ requests.push(activityQuery.refetch());
+ }
+
+ if (activeTab === "changes") {
+ requests.push(
+ queryClient.invalidateQueries({
+ queryKey: prRunQueryKeys.diff(
+ currentProjectId,
+ currentBranch.name,
+ currentBranch.compareBranchName ?? "default",
+ ),
+ }),
+ );
+ } else if (activeTab === "run") {
+ requests.push(
+ queryClient.invalidateQueries({
+ queryKey: prRunQueryKeys.packageScripts(
+ currentProjectId,
+ currentBranch.name,
+ ),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: prRunQueryKeys.scripts,
+ }),
+ );
+ } else if (activeTab === "docker") {
+ requests.push(
+ queryClient.invalidateQueries({
+ queryKey: prRunQueryKeys.docker(
+ currentProjectId,
+ currentBranch.name,
+ ),
+ }),
+ );
+ } else if (activeTab === "env") {
+ requests.push(
+ queryClient.invalidateQueries({
+ queryKey: prRunQueryKeys.env(
+ currentProjectId,
+ currentBranch.name,
+ ),
+ }),
+ );
+ }
+
+ await tryPromise(Promise.all(requests));
+ setIsRefreshingActiveTab(false);
+ }
+
+ const isRefreshing =
+ isRefreshingActiveTab ||
+ branchesQuery.isFetching ||
+ ((activeTab === "activity" || activeTab === "changes") &&
+ activityQuery.isFetching);
+
return (
-
void commitsQuery.refetch()}
+ onRefresh={refreshActiveTab}
+ />
+
- {activeTab === "general" ? (
+ {activeTab === "activity" ? (
) : activeTab === "run" ? (
selectedBranch.hasWorktree ? (
@@ -282,7 +380,7 @@ export function MainPanel({
}
>
-
)
- ) : activeTab === "diff" ? (
+ ) : activeTab === "changes" ? (
}
>
-
) : activeTab === "docker" ? (
diff --git a/src/lib/components/templates/main-panel/run/custom-actions.tsx b/src/lib/components/templates/main-panel/run/custom-actions.tsx
new file mode 100644
index 0000000..d110843
--- /dev/null
+++ b/src/lib/components/templates/main-panel/run/custom-actions.tsx
@@ -0,0 +1,100 @@
+import { Pencil, Play, Plus, Trash2 } from "lucide-react";
+
+import { Button } from "@/lib/components/ui/button";
+import type { ScriptInfo } from "@/types/pr-run";
+
+type CustomActionsProps = {
+ deletingId?: string;
+ onCreate: () => void;
+ onDelete: (script: ScriptInfo) => void;
+ onEdit: (script: ScriptInfo) => void;
+ onRun: (script: ScriptInfo) => void;
+ preparingId?: string;
+ scripts: ScriptInfo[];
+};
+
+export function CustomActions({
+ deletingId,
+ onCreate,
+ onDelete,
+ onEdit,
+ onRun,
+ preparingId,
+ scripts,
+}: CustomActionsProps) {
+ return (
+
+
+
+
Custom actions
+
+ Reusable PR-run actions enabled for the Run page.
+
+
+
+
+ {scripts.length > 0 ? (
+
+ {scripts.map((script) => (
+
+
+
+
+
+
+
+ ))}
+
+ ) : (
+
+ No custom actions are enabled for this page.
+
+ )}
+
+ );
+}
diff --git a/src/lib/components/templates/main-panel/run/index.tsx b/src/lib/components/templates/main-panel/run/index.tsx
new file mode 100644
index 0000000..b6806ea
--- /dev/null
+++ b/src/lib/components/templates/main-panel/run/index.tsx
@@ -0,0 +1,302 @@
+import { Box, ChevronRight, Package, Play, Trash2 } from "lucide-react";
+import { useState } from "react";
+
+import { Skeleton } from "@/lib/components/atoms/skeleton";
+import { Surface } from "@/lib/components/atoms/surface";
+import { CustomActions } from "@/lib/components/templates/main-panel/run/custom-actions";
+import { PackageScriptPicker } from "@/lib/components/templates/main-panel/run/package-script-picker";
+import { Button } from "@/lib/components/ui/button";
+import {
+ Dialog,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogPopup,
+ DialogTitle,
+} from "@/lib/components/ui/dialog";
+import { toast } from "@/lib/components/ui/toast";
+import { tryPromise } from "@/lib/error";
+import { useDeleteScriptMutation } from "@/lib/hooks/query/use-delete-script-mutation";
+import { useOpenScriptMutation } from "@/lib/hooks/query/use-open-script-mutation";
+import { usePackageScriptsQuery } from "@/lib/hooks/query/use-package-scripts-query";
+import { usePreparePackageScriptCommandMutation } from "@/lib/hooks/query/use-prepare-package-script-command-mutation";
+import { useRunScriptMutation } from "@/lib/hooks/query/use-run-script-mutation";
+import { useScriptsQuery } from "@/lib/hooks/query/use-scripts-query";
+import { getErrorMessage } from "@/lib/utils/get-error-message";
+import type { PackageScriptInfo, ScriptInfo } from "@/types/pr-run";
+
+type WorktreeRunProps = {
+ branchName: string;
+ onCreateScript: () => void;
+ onRunScriptCommand: (payload: {
+ command: string;
+ scriptTitle: string;
+ }) => Promise
;
+ projectId: string;
+};
+
+export function WorktreeRun({
+ branchName,
+ onCreateScript,
+ onRunScriptCommand,
+ projectId,
+}: WorktreeRunProps) {
+ const packageScriptsQuery = usePackageScriptsQuery(projectId, branchName);
+ const scriptsQuery = useScriptsQuery();
+ const preparePackageMutation = usePreparePackageScriptCommandMutation();
+ const runScriptMutation = useRunScriptMutation();
+ const deleteScriptMutation = useDeleteScriptMutation();
+ const openScriptMutation = useOpenScriptMutation();
+ const [isPickerOpen, setIsPickerOpen] = useState(false);
+ const [pendingDelete, setPendingDelete] = useState(null);
+ const customActions = (scriptsQuery.data ?? []).filter(
+ (script) => script.button,
+ );
+
+ async function runPackageScript(script: PackageScriptInfo) {
+ const [prepareError, prepared] = await tryPromise(
+ preparePackageMutation.mutateAsync({
+ branchName,
+ packagePath: script.packagePath,
+ projectId,
+ scriptName: script.name,
+ }),
+ );
+
+ if (prepareError) {
+ toast.error(getErrorMessage(prepareError), { timeout: 3200 });
+ return;
+ }
+
+ await sendCommand(prepared.command, prepared.title);
+ }
+
+ async function runCustomScript(script: ScriptInfo) {
+ const [prepareError, prepared] = await tryPromise(
+ runScriptMutation.mutateAsync({
+ branchName,
+ projectId,
+ scriptId: script.id,
+ }),
+ );
+
+ if (prepareError) {
+ toast.error(getErrorMessage(prepareError), { timeout: 3200 });
+ return;
+ }
+
+ await sendCommand(prepared.command, script.title);
+ }
+
+ async function sendCommand(command: string, title: string) {
+ const [error] = await tryPromise(
+ onRunScriptCommand({ command, scriptTitle: title }),
+ );
+
+ if (error) {
+ toast.error(getErrorMessage(error), { timeout: 3200 });
+ }
+ }
+
+ async function editScript(script: ScriptInfo) {
+ const [error] = await tryPromise(
+ openScriptMutation.mutateAsync(script.id),
+ );
+
+ if (error) {
+ toast.error(getErrorMessage(error), { timeout: 3200 });
+ }
+ }
+
+ async function deleteScript() {
+ if (!pendingDelete) {
+ return;
+ }
+
+ const [error] = await tryPromise(
+ deleteScriptMutation.mutateAsync(pendingDelete.id),
+ );
+
+ if (error) {
+ toast.error(getErrorMessage(error), { timeout: 3200 });
+ return;
+ }
+
+ toast.success(`${pendingDelete.title} deleted.`, { timeout: 2200 });
+ setPendingDelete(null);
+ }
+
+ const catalog = packageScriptsQuery.data;
+ const scriptCount =
+ catalog?.packages.reduce(
+ (total, group) => total + group.scripts.length,
+ 0,
+ ) ?? 0;
+
+ return (
+
+
+
+
+
+
+
+ Package scripts
+
+
+
+ {catalog
+ ? `${scriptCount} scripts · ${catalog.manager}`
+ : "Detecting scripts from this worktree…"}
+
+
+ {catalog && scriptCount > 0 ? (
+
+ ) : null}
+
+
+ {packageScriptsQuery.isPending ? (
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+ ) : packageScriptsQuery.error ? (
+
+ {getErrorMessage(packageScriptsQuery.error)} Custom
+ actions and the terminal are still available.
+
+ ) : catalog?.quickScripts.length ? (
+
+ {catalog.quickScripts.map((script) => {
+ const isPreparing =
+ preparePackageMutation.isPending &&
+ preparePackageMutation.variables
+ ?.packagePath === script.packagePath &&
+ preparePackageMutation.variables?.scriptName ===
+ script.name;
+
+ return (
+
+ );
+ })}
+
+ ) : (
+
+ No package scripts were found in this worktree.
+
+ )}
+
+
+
+
+ {catalog ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/lib/components/templates/main-panel/run/package-script-picker.tsx b/src/lib/components/templates/main-panel/run/package-script-picker.tsx
new file mode 100644
index 0000000..501ed41
--- /dev/null
+++ b/src/lib/components/templates/main-panel/run/package-script-picker.tsx
@@ -0,0 +1,139 @@
+import { Box, Search } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import {
+ Dialog,
+ DialogDescription,
+ DialogHeader,
+ DialogPanel,
+ DialogPopup,
+ DialogTitle,
+} from "@/lib/components/ui/dialog";
+import { Input } from "@/lib/components/ui/input";
+import type { PackageScriptCatalog, PackageScriptInfo } from "@/types/pr-run";
+
+type PackageScriptPickerProps = {
+ catalog: PackageScriptCatalog;
+ onOpenChange: (open: boolean) => void;
+ onRun: (script: PackageScriptInfo) => void;
+ open: boolean;
+};
+
+export function PackageScriptPicker({
+ catalog,
+ onOpenChange,
+ onRun,
+ open,
+}: PackageScriptPickerProps) {
+ const [search, setSearch] = useState("");
+ const normalizedSearch = search.trim().toLowerCase();
+ const groups = useMemo(
+ () =>
+ catalog.packages
+ .map((group) => ({
+ ...group,
+ scripts: group.scripts.filter((script) =>
+ `${script.name} ${script.command} ${script.packageName}`
+ .toLowerCase()
+ .includes(normalizedSearch),
+ ),
+ }))
+ .filter((group) => group.scripts.length > 0),
+ [catalog.packages, normalizedSearch],
+ );
+
+ return (
+
+ );
+}
diff --git a/src/lib/components/templates/overview/index.tsx b/src/lib/components/templates/overview/index.tsx
new file mode 100644
index 0000000..eb7eaec
--- /dev/null
+++ b/src/lib/components/templates/overview/index.tsx
@@ -0,0 +1,176 @@
+import { RefreshCw, TriangleAlert } from "lucide-react";
+import { useState } from "react";
+
+import { OverviewCharts } from "@/lib/components/templates/overview/overview-charts";
+import { OverviewMetrics } from "@/lib/components/templates/overview/overview-metrics";
+import { OverviewPrList } from "@/lib/components/templates/overview/overview-pr-list";
+import {
+ OverviewEmptyState,
+ OverviewErrorState,
+ OverviewLoadingState,
+} from "@/lib/components/templates/overview/overview-state";
+import { Button } from "@/lib/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectOption,
+ SelectTrigger,
+} from "@/lib/components/ui/select";
+import { useOverviewQuery } from "@/lib/hooks/query/use-overview-query";
+import { getErrorMessage } from "@/lib/utils/get-error-message";
+import type { ProjectConfig } from "@/types/pr-run";
+
+type OverviewProps = {
+ projects: ProjectConfig[];
+};
+
+export function Overview({ projects }: OverviewProps) {
+ const [projectId, setProjectId] = useState();
+ const overviewQuery = useOverviewQuery(projectId);
+ const snapshot = overviewQuery.data;
+
+ return (
+
+
+
+
+ {overviewQuery.isPending ?
: null}
+ {overviewQuery.error && !snapshot ? (
+
+ ) : null}
+ {overviewQuery.error && snapshot ? (
+
+
+
+ Refresh failed. Showing the last successful snapshot
+ from{" "}
+ {new Date(snapshot.generatedAt).toLocaleString()}.
+
+
+ ) : null}
+ {snapshot ? (
+ <>
+ {snapshot.unavailableProjects.length > 0 ? (
+
+
+
+ {snapshot.unavailableProjects.length}{" "}
+ project
+ {snapshot.unavailableProjects.length === 1
+ ? " was"
+ : "s were"}{" "}
+ unavailable and are excluded from these
+ totals.
+
+
+ ) : null}
+ {snapshot.projects.length === 0 ? (
+
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )}
+ >
+ ) : null}
+
+
+ );
+}
diff --git a/src/lib/components/templates/overview/overview-charts.tsx b/src/lib/components/templates/overview/overview-charts.tsx
new file mode 100644
index 0000000..5660912
--- /dev/null
+++ b/src/lib/components/templates/overview/overview-charts.tsx
@@ -0,0 +1,134 @@
+import type {
+ OverviewProjectSummary,
+ OverviewPullRequestChange,
+} from "@/types/overview";
+
+type OverviewChartsProps = {
+ projects: OverviewProjectSummary[];
+ pullRequests: OverviewPullRequestChange[];
+ projectScoped: boolean;
+};
+
+const compactNumberFormatter = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ maximumFractionDigits: 1,
+});
+
+export function OverviewCharts({
+ projects,
+ pullRequests,
+ projectScoped,
+}: OverviewChartsProps) {
+ const rows = projectScoped
+ ? pullRequests.slice(0, 7).map((pullRequest) => ({
+ additions: pullRequest.additions,
+ deletions: pullRequest.deletions,
+ label: `#${pullRequest.number} ${pullRequest.branchName}`,
+ secondary: pullRequest.title,
+ }))
+ : projects.slice(0, 7).map((project) => ({
+ additions: project.additions,
+ deletions: project.deletions,
+ label: project.projectName,
+ secondary: `${project.openPullRequests} open PRs`,
+ }));
+ const maximum = Math.max(
+ 1,
+ ...rows.flatMap((row) => [row.additions, row.deletions]),
+ );
+
+ return (
+
+
+ {rows.length > 0 ? (
+
+ {rows.map((row) => (
+
+
+
+
+ {row.label}
+
+
+ {row.secondary}
+
+
+
+
+ +
+ {compactNumberFormatter.format(
+ row.additions,
+ )}
+
+
+ /
+
+
+ -
+ {compactNumberFormatter.format(
+ row.deletions,
+ )}
+
+
+
+
+
+ ))}
+
+ ) : (
+
+ No open pull requests have diff statistics yet.
+
+ )}
+
+ );
+}
diff --git a/src/lib/components/templates/overview/overview-metrics.tsx b/src/lib/components/templates/overview/overview-metrics.tsx
new file mode 100644
index 0000000..cce688d
--- /dev/null
+++ b/src/lib/components/templates/overview/overview-metrics.tsx
@@ -0,0 +1,79 @@
+import { ArrowDownRight, ArrowUpRight, GitPullRequest } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { WorktreeIndicator } from "@/lib/components/atoms/worktree-indicator";
+import type { OverviewTotals } from "@/types/overview";
+
+type OverviewMetricsProps = {
+ totals: OverviewTotals;
+};
+
+const numberFormatter = new Intl.NumberFormat("en-US");
+
+export function OverviewMetrics({ totals }: OverviewMetricsProps) {
+ return (
+
+ }
+ label="Open pull requests"
+ value={totals.openPullRequests}
+ />
+ }
+ label="Worktrees"
+ value={totals.worktrees}
+ />
+ }
+ label="Additions"
+ tone="positive"
+ value={totals.additions}
+ />
+ }
+ label="Deletions"
+ tone="negative"
+ value={totals.deletions}
+ />
+
+ );
+}
+
+function Metric({
+ icon,
+ label,
+ tone = "neutral",
+ value,
+}: {
+ icon: ReactNode;
+ label: string;
+ tone?: "negative" | "neutral" | "positive";
+ value: number;
+}) {
+ const toneClassName = {
+ negative: "text-destructive",
+ neutral: "text-muted-foreground",
+ positive: "text-success",
+ }[tone];
+
+ return (
+
+
+ {icon}
+
+ {label}
+
+
+
+ {numberFormatter.format(value)}
+
+
+ );
+}
diff --git a/src/lib/components/templates/overview/overview-pr-list.tsx b/src/lib/components/templates/overview/overview-pr-list.tsx
new file mode 100644
index 0000000..4f3e3f4
--- /dev/null
+++ b/src/lib/components/templates/overview/overview-pr-list.tsx
@@ -0,0 +1,88 @@
+import { ExternalLink, FileDiff } from "lucide-react";
+
+import type { OverviewPullRequestChange } from "@/types/overview";
+
+type OverviewPrListProps = {
+ pullRequests: OverviewPullRequestChange[];
+};
+
+export function OverviewPrList({ pullRequests }: OverviewPrListProps) {
+ return (
+
+
+ {pullRequests.length > 0 ? (
+
+ ) : (
+
+ No open pull requests found for this scope.
+
+ )}
+
+ );
+}
diff --git a/src/lib/components/templates/overview/overview-state.tsx b/src/lib/components/templates/overview/overview-state.tsx
new file mode 100644
index 0000000..8c9225a
--- /dev/null
+++ b/src/lib/components/templates/overview/overview-state.tsx
@@ -0,0 +1,48 @@
+import { AlertTriangle, ChartNoAxesCombined } from "lucide-react";
+
+import { EmptyState } from "@/lib/components/atoms/empty-state";
+import { Skeleton } from "@/lib/components/atoms/skeleton";
+import { Surface } from "@/lib/components/atoms/surface";
+
+export function OverviewLoadingState() {
+ return (
+
+
+ {[0, 1, 2, 3].map((item) => (
+
+
+
+
+ ))}
+
+
+
+ );
+}
+
+export function OverviewErrorState({ message }: { message: string }) {
+ return (
+
+ }
+ title="Overview could not load"
+ />
+
+ );
+}
+
+export function OverviewEmptyState() {
+ return (
+
+ }
+ title="No project activity yet"
+ />
+
+ );
+}
diff --git a/src/lib/components/templates/pr-run-app/index.tsx b/src/lib/components/templates/pr-run-app/index.tsx
index b0ba26c..166010f 100644
--- a/src/lib/components/templates/pr-run-app/index.tsx
+++ b/src/lib/components/templates/pr-run-app/index.tsx
@@ -7,16 +7,22 @@ import { Skeleton } from "@/lib/components/atoms/skeleton";
import { Surface } from "@/lib/components/atoms/surface";
import { AddProjectDialog } from "@/lib/components/templates/add-project-dialog";
import { CreateScriptDialog } from "@/lib/components/templates/create-script-dialog";
-import {
- getTerminalKey,
- GlobalTerminalPanel,
-} from "@/lib/components/templates/global-terminal-panel";
+import { GlobalTerminalPanel } from "@/lib/components/templates/global-terminal-panel";
import { MainPanel } from "@/lib/components/templates/main-panel";
+import { Overview } from "@/lib/components/templates/overview";
import type { RunTerminalContext } from "@/lib/components/templates/main-panel";
import { Sidebar } from "@/lib/components/templates/sidebar";
import { SshPassphraseDialog } from "@/lib/components/templates/ssh-passphrase-dialog";
import { StatusBar } from "@/lib/components/templates/status-bar";
+import { WorkspaceTitlebar } from "@/lib/components/templates/workspace-titlebar";
+import { SettingsPage } from "@/lib/components/templates/settings-page";
import { usePrRunAppState } from "@/lib/components/templates/pr-run-app/use-pr-run-app-state";
+import {
+ clamp,
+ getActiveOwnerTerminalKey,
+ getPreferredGlobalTerminalKey,
+} from "@/lib/components/templates/pr-run-app/terminal-state";
+import { useUiPreferencesStore } from "@/lib/hooks/store/use-ui-preferences-store";
import { useWorktreeTerminalStore } from "@/lib/hooks/store/use-worktree-terminal-store";
const TERMINAL_PANEL_DEFAULT_HEIGHT = 320;
@@ -29,16 +35,41 @@ const TERMINAL_RESIZE_BUSY_SYNC_DELAY_MS = 800;
export function PrRunApp() {
const state = usePrRunAppState();
const terminalOwners = useWorktreeTerminalStore((store) => store.owners);
+ const [isDesktopViewport, setIsDesktopViewport] = useState(
+ () => window.matchMedia("(min-width: 64rem)").matches,
+ );
+ const [isDesktopSidebarHidden, setIsDesktopSidebarHidden] = useState(false);
const [isTerminalPanelOpen, setIsTerminalPanelOpen] = useState(false);
+ const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [isTerminalPanelAutoHeight, setIsTerminalPanelAutoHeight] =
useState(false);
const [isTerminalPanelResizing, setIsTerminalPanelResizing] =
useState(false);
- const [terminalPanelHeight, setTerminalPanelHeight] = useState(
- TERMINAL_PANEL_DEFAULT_HEIGHT,
+ const storedTerminalPanelHeight = useUiPreferencesStore(
+ (store) => store.terminalPanelHeight,
+ );
+ const setTerminalPanelHeightPreference = useUiPreferencesStore(
+ (store) => store.setTerminalPanelHeight,
+ );
+ const storedTerminalListWidth = useUiPreferencesStore(
+ (store) => store.terminalListWidth,
+ );
+ const setTerminalListWidthPreference = useUiPreferencesStore(
+ (store) => store.setTerminalListWidth,
+ );
+ const [terminalPanelHeight, setTerminalPanelHeight] = useState(() =>
+ Math.max(
+ storedTerminalPanelHeight ?? TERMINAL_PANEL_DEFAULT_HEIGHT,
+ TERMINAL_PANEL_MIN_HEIGHT,
+ ),
);
const [terminalPanelSidebarWidth, setTerminalPanelSidebarWidth] = useState(
- TERMINAL_PANEL_SIDEBAR_DEFAULT_WIDTH,
+ () =>
+ clamp(
+ storedTerminalListWidth ?? TERMINAL_PANEL_SIDEBAR_DEFAULT_WIDTH,
+ TERMINAL_PANEL_SIDEBAR_MIN_WIDTH,
+ TERMINAL_PANEL_SIDEBAR_MAX_WIDTH,
+ ),
);
const [selectedGlobalTerminalKey, setSelectedGlobalTerminalKey] = useState<
string | null
@@ -60,6 +91,22 @@ export function PrRunApp() {
: null,
[runTerminalContext, terminalOwners],
);
+ const isSidebarOpen = isDesktopViewport
+ ? !isDesktopSidebarHidden
+ : isMobileSidebarOpen;
+ const isBranchWorkspaceVisible =
+ state.workspaceView.type !== "settings" && !state.isOverviewOpen;
+
+ useEffect(() => {
+ const media = window.matchMedia("(min-width: 64rem)");
+
+ function handleChange(event: MediaQueryListEvent) {
+ setIsDesktopViewport(event.matches);
+ }
+
+ media.addEventListener("change", handleChange);
+ return () => media.removeEventListener("change", handleChange);
+ }, []);
useEffect(() => {
if (!runTerminalContext) {
@@ -95,6 +142,14 @@ export function PrRunApp() {
};
}, []);
+ useEffect(() => {
+ setTerminalPanelHeightPreference(terminalPanelHeight);
+ }, [setTerminalPanelHeightPreference, terminalPanelHeight]);
+
+ useEffect(() => {
+ setTerminalListWidthPreference(terminalPanelSidebarWidth);
+ }, [setTerminalListWidthPreference, terminalPanelSidebarWidth]);
+
function beginTerminalUiResize() {
if (resizeSettleTimeoutRef.current !== null) {
window.clearTimeout(resizeSettleTimeoutRef.current);
@@ -123,6 +178,21 @@ export function PrRunApp() {
setIsTerminalPanelOpen(true);
}
+ function toggleSidebar() {
+ if (isDesktopViewport) {
+ setIsDesktopSidebarHidden((hidden) => !hidden);
+ return;
+ }
+
+ setIsMobileSidebarOpen((open) => !open);
+ }
+
+ function closeMobileSidebar() {
+ if (!isDesktopViewport) {
+ setIsMobileSidebarOpen(false);
+ }
+ }
+
function beginTerminalPanelResize(
event: ReactPointerEvent,
startHeightOverride?: number,
@@ -230,74 +300,151 @@ export function PrRunApp() {
return (
-
- state.setTheme((current) =>
- current === "dark" ? "light" : "dark",
- )
- }
- onUpdateProject={state.updateProject}
+ onCloseTab={state.closeWorktreeTab}
+ onSelectTab={(tabId) => {
+ state.selectWorktreeTab(tabId);
+ closeMobileSidebar();
+ }}
+ onToggleSidebar={toggleSidebar}
/>
-
-
-
+ setIsTerminalPanelOpen(false)}
- onSelectTerminal={setSelectedGlobalTerminalKey}
- />
- {
+ state.openOverview();
+ closeMobileSidebar();
+ }}
+ onOpenSettings={() => {
+ state.openSettings();
+ closeMobileSidebar();
+ }}
+ onRemoveWorktree={state.removeWorktree}
+ onSelectBranch={(project, branch) => {
+ state.selectBranch(project, branch);
+ closeMobileSidebar();
+ }}
+ onToggleProject={state.toggleProject}
+ onUpdateProject={state.updateProject}
/>
+
+ {state.workspaceView.type === "settings" ? (
+
+ ) : state.isOverviewOpen ? (
+
group.projects,
+ )}
+ />
+ ) : null}
+ {state.selectedBranchView.project &&
+ state.selectedBranchView.branchName ? (
+
+
+ setIsTerminalPanelOpen(false)}
+ onSelectTerminal={setSelectedGlobalTerminalKey}
+ />
+
+ ) : state.workspaceView.type !== "settings" &&
+ !state.isOverviewOpen ? (
+
+ ) : null}
+
+
);
}
-
-function getPreferredGlobalTerminalKey(
- owners: ReturnType["owners"],
-) {
- const fallback = getFirstTerminalKey(owners);
-
- for (const [ownerKey, owner] of Object.entries(owners)) {
- const busyTab = owner.tabs.find(
- (tab) => tab.status === "alive" && tab.busyState === "busy",
- );
-
- if (busyTab) {
- return getTerminalKey(ownerKey, busyTab.id);
- }
- }
-
- return fallback;
-}
-
-function getFirstTerminalKey(
- owners: ReturnType["owners"],
-) {
- for (const [ownerKey, owner] of Object.entries(owners)) {
- const firstTab = owner.tabs[0];
-
- if (firstTab) {
- return getTerminalKey(ownerKey, firstTab.id);
- }
- }
-
- return null;
-}
-
-function getActiveOwnerTerminalKey(
- owners: ReturnType["owners"],
- ownerKey: string,
-) {
- const owner = owners[ownerKey];
-
- if (!owner) {
- return null;
- }
-
- const activeTab =
- owner.tabs.find((tab) => tab.id === owner.activeTabId) ?? owner.tabs[0];
-
- return activeTab ? getTerminalKey(ownerKey, activeTab.id) : null;
-}
-
-function clamp(value: number, min: number, max: number) {
- return Math.min(Math.max(value, min), max);
-}
diff --git a/src/lib/components/templates/pr-run-app/settings-state.ts b/src/lib/components/templates/pr-run-app/settings-state.ts
new file mode 100644
index 0000000..4bae3f9
--- /dev/null
+++ b/src/lib/components/templates/pr-run-app/settings-state.ts
@@ -0,0 +1,21 @@
+import { useState } from "react";
+
+import type {
+ SettingsSection,
+ WorkspaceView,
+} from "@/lib/components/templates/pr-run-app/types";
+
+export function useSettingsState() {
+ const [workspaceView, setWorkspaceView] = useState({
+ type: "branch",
+ });
+
+ return {
+ closeSettings: () => setWorkspaceView({ type: "branch" }),
+ openSettings: (section: SettingsSection = "general") =>
+ setWorkspaceView({ section, type: "settings" }),
+ setSettingsSection: (section: SettingsSection) =>
+ setWorkspaceView({ section, type: "settings" }),
+ workspaceView,
+ };
+}
diff --git a/src/lib/components/templates/pr-run-app/terminal-state.ts b/src/lib/components/templates/pr-run-app/terminal-state.ts
new file mode 100644
index 0000000..e592e16
--- /dev/null
+++ b/src/lib/components/templates/pr-run-app/terminal-state.ts
@@ -0,0 +1,54 @@
+import { getTerminalKey } from "@/lib/components/templates/global-terminal-panel";
+import type { useWorktreeTerminalStore } from "@/lib/hooks/store/use-worktree-terminal-store";
+
+type TerminalOwners = ReturnType<
+ typeof useWorktreeTerminalStore.getState
+>["owners"];
+
+export function getPreferredGlobalTerminalKey(owners: TerminalOwners) {
+ const fallback = getFirstTerminalKey(owners);
+
+ for (const [ownerKey, owner] of Object.entries(owners)) {
+ const busyTab = owner.tabs.find(
+ (tab) => tab.status === "alive" && tab.busyState === "busy",
+ );
+
+ if (busyTab) {
+ return getTerminalKey(ownerKey, busyTab.id);
+ }
+ }
+
+ return fallback;
+}
+
+function getFirstTerminalKey(owners: TerminalOwners) {
+ for (const [ownerKey, owner] of Object.entries(owners)) {
+ const firstTab = owner.tabs[0];
+
+ if (firstTab) {
+ return getTerminalKey(ownerKey, firstTab.id);
+ }
+ }
+
+ return null;
+}
+
+export function getActiveOwnerTerminalKey(
+ owners: TerminalOwners,
+ ownerKey: string,
+) {
+ const owner = owners[ownerKey];
+
+ if (!owner) {
+ return null;
+ }
+
+ const activeTab =
+ owner.tabs.find((tab) => tab.id === owner.activeTabId) ?? owner.tabs[0];
+
+ return activeTab ? getTerminalKey(ownerKey, activeTab.id) : null;
+}
+
+export function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), max);
+}
diff --git a/src/lib/components/templates/pr-run-app/types.ts b/src/lib/components/templates/pr-run-app/types.ts
index 146b1f6..8735e8f 100644
--- a/src/lib/components/templates/pr-run-app/types.ts
+++ b/src/lib/components/templates/pr-run-app/types.ts
@@ -9,3 +9,15 @@ export type SelectedBranchView = {
branchName: string | null;
project: ProjectConfig | null;
};
+
+export type SettingsSection =
+ | "general"
+ | "appearance"
+ | "projects"
+ | "scripts"
+ | "ssh"
+ | "diagnostics";
+
+export type WorkspaceView =
+ | { type: "branch" }
+ | { section: SettingsSection; type: "settings" };
diff --git a/src/lib/components/templates/pr-run-app/use-app-status-summary.ts b/src/lib/components/templates/pr-run-app/use-app-status-summary.ts
index 8f7b2b0..585560f 100644
--- a/src/lib/components/templates/pr-run-app/use-app-status-summary.ts
+++ b/src/lib/components/templates/pr-run-app/use-app-status-summary.ts
@@ -44,7 +44,7 @@ export function useAppStatusSummary(
branchCount += 1;
}
- if (branch.source === "pull-request") {
+ if (branch.pullRequest?.state === "OPEN") {
openPullRequestCount += 1;
}
diff --git a/src/lib/components/templates/pr-run-app/use-pr-run-app-state.ts b/src/lib/components/templates/pr-run-app/use-pr-run-app-state.ts
index 8215816..faa8d3b 100644
--- a/src/lib/components/templates/pr-run-app/use-pr-run-app-state.ts
+++ b/src/lib/components/templates/pr-run-app/use-pr-run-app-state.ts
@@ -1,58 +1,51 @@
-import { toast } from "@heroui/react";
import { useEffect, useMemo, useState } from "react";
import { isHandledSshPromptError } from "@/lib/api";
+import { toast } from "@/lib/components/ui/toast";
import { useAddProjectMutation } from "@/lib/hooks/query/use-add-project-mutation";
import { useCheckoutBranchMutation } from "@/lib/hooks/query/use-checkout-branch-mutation";
import { useConfigQuery } from "@/lib/hooks/query/use-config-query";
import { useCreateScriptMutation } from "@/lib/hooks/query/use-create-script-mutation";
-import { usePreloadProjects } from "@/lib/hooks/query/use-preload-projects";
import { useRemoveWorktreeMutation } from "@/lib/hooks/query/use-remove-worktree-mutation";
import { useUpdateProjectWorktreesMutation } from "@/lib/hooks/query/use-update-project-worktrees-mutation";
import { useSshPassphraseStore } from "@/lib/hooks/store/use-ssh-passphrase-store";
+import { useUiPreferencesStore } from "@/lib/hooks/store/use-ui-preferences-store";
import {
getWorktreeOwnerKey,
useWorktreeTerminalStore,
} from "@/lib/hooks/store/use-worktree-terminal-store";
import { tryPromise } from "@/lib/error";
+import { assignProjectAvatars } from "@/lib/project-avatar";
import { getErrorMessage } from "@/lib/utils/get-error-message";
-import type { ProjectConfig } from "@/types/pr-run";
+import type { BranchInfo, ProjectConfig } from "@/types/pr-run";
-import type {
- SelectedBranchState,
- SelectedBranchView,
-} from "@/lib/components/templates/pr-run-app/types";
import { useAppStatusSummary } from "@/lib/components/templates/pr-run-app/use-app-status-summary";
+import { useSettingsState } from "@/lib/components/templates/pr-run-app/settings-state";
+import { useWorkspaceState } from "@/lib/components/templates/pr-run-app/workspace-state";
-const SIDEBAR_WIDTH_STORAGE_KEY = "pr-run.sidebar.width";
const SIDEBAR_MIN_WIDTH = 256;
const SIDEBAR_MAX_WIDTH = 560;
const MAIN_CONTENT_MIN_WIDTH = 640;
export function usePrRunAppState() {
- const [selectedBranch, setSelectedBranch] =
- useState(null);
+ const settingsState = useSettingsState();
const [actionError, setActionError] = useState();
- const [expandedGroups, setExpandedGroups] = useState>(
- () => new Set(["default"]),
- );
const [collapsedProjects, setCollapsedProjects] = useState>(
() => new Set(),
);
const [isAddProjectOpen, setIsAddProjectOpen] = useState(false);
const [isCreateScriptOpen, setIsCreateScriptOpen] = useState(false);
- const [theme, setTheme] = useState<"dark" | "light">(() => {
- return localStorage.getItem("pr-run-theme") === "light"
- ? "light"
- : "dark";
- });
- const [sidebarWidth, setSidebarWidth] = useState(() => {
- const stored = Number(localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY));
-
- return Number.isFinite(stored)
- ? clamp(stored, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH)
- : 320;
- });
+ const storedSidebarWidth = useUiPreferencesStore(
+ (store) => store.sidebarWidth,
+ );
+ const setStoredSidebarWidth = useUiPreferencesStore(
+ (store) => store.setSidebarWidth,
+ );
+ const theme = useUiPreferencesStore((store) => store.theme);
+ const setTheme = useUiPreferencesStore((store) => store.setTheme);
+ const [sidebarWidth, setSidebarWidth] = useState(() =>
+ clamp(storedSidebarWidth ?? 320, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH),
+ );
const [isResizingSidebar, setIsResizingSidebar] = useState(false);
const configQuery = useConfigQuery();
const addProjectMutation = useAddProjectMutation();
@@ -65,28 +58,43 @@ export function usePrRunAppState() {
() => groups.flatMap((group) => group.projects),
[groups],
);
- usePreloadProjects(projects);
- const statusSummary = useAppStatusSummary(projects);
- const selectedProject = useMemo(
- () =>
- projects.find(
- (project) => project.id === selectedBranch?.projectId,
- ) ?? null,
- [projects, selectedBranch?.projectId],
+ const projectAvatarUris = useMemo(
+ () => assignProjectAvatars(projects),
+ [projects],
);
- const selectedBranchView: SelectedBranchView = {
- branchName: selectedBranch?.branchName ?? null,
- project: selectedProject,
- };
+ const workspaceState = useWorkspaceState({
+ onLeaveSettings: settingsState.closeSettings,
+ projects,
+ });
+ const { selectedBranch, selectedBranchView } = workspaceState;
+ const statusSummary = useAppStatusSummary(projects);
const configError = configQuery.error
? getErrorMessage(configQuery.error)
: undefined;
useEffect(() => {
- document.documentElement.dataset.theme = theme;
- document.documentElement.classList.toggle("dark", theme === "dark");
- document.documentElement.style.colorScheme = theme;
- localStorage.setItem("pr-run-theme", theme);
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
+ const applyTheme = () => {
+ const resolved =
+ theme === "system" ? (media.matches ? "dark" : "light") : theme;
+ document.documentElement.classList.add("no-transitions");
+ document.documentElement.dataset.theme = resolved;
+ document.documentElement.classList.toggle(
+ "dark",
+ resolved === "dark",
+ );
+ document.documentElement.style.colorScheme = resolved;
+ syncTitleBarTheme(resolved);
+ window.setTimeout(
+ () =>
+ document.documentElement.classList.remove("no-transitions"),
+ 0,
+ );
+ };
+
+ applyTheme();
+ media.addEventListener("change", applyTheme);
+ return () => media.removeEventListener("change", applyTheme);
}, [theme]);
useEffect(() => {
@@ -132,30 +140,8 @@ export function usePrRunAppState() {
}, [isResizingSidebar]);
useEffect(() => {
- localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(sidebarWidth));
- }, [sidebarWidth]);
-
- useEffect(() => {
- if (!selectedBranch || selectedProject) {
- return;
- }
-
- setSelectedBranch(null);
- }, [selectedBranch, selectedProject]);
-
- function toggleGroup(groupId: string) {
- setExpandedGroups((current) => {
- const next = new Set(current);
-
- if (next.has(groupId)) {
- next.delete(groupId);
- } else {
- next.add(groupId);
- }
-
- return next;
- });
- }
+ setStoredSidebarWidth(sidebarWidth);
+ }, [setStoredSidebarWidth, sidebarWidth]);
function toggleProject(projectId: string) {
setCollapsedProjects((current) => {
@@ -171,11 +157,6 @@ export function usePrRunAppState() {
});
}
- function selectBranch(projectId: string, branchName: string) {
- setActionError(undefined);
- setSelectedBranch({ branchName, projectId });
- }
-
async function submitAddProject(projectPath: string) {
const [error] = await tryPromise(
addProjectMutation.mutateAsync(projectPath),
@@ -198,7 +179,7 @@ export function usePrRunAppState() {
if (isHandledSshPromptError(error)) {
useSshPassphraseStore
.getState()
- .setRetryAction(() =>
+ .setRetryAction(`checkout:${projectId}:${branchName}`, () =>
checkoutBranch(projectId, branchName).then(
() => undefined,
),
@@ -213,6 +194,7 @@ export function usePrRunAppState() {
showSuccessToast(
result.status === "ready" ? "Worktree ready" : result.message,
);
+ workspaceState.openCreatedWorktree(projectId, branchName);
}
async function createScript(title: string) {
@@ -223,7 +205,7 @@ export function usePrRunAppState() {
if (error) {
setActionError(getErrorMessage(error));
- toast.danger(getErrorMessage(error), { timeout: 3200 });
+ toast.error(getErrorMessage(error), { timeout: 3200 });
return;
}
@@ -241,7 +223,7 @@ export function usePrRunAppState() {
if (isHandledSshPromptError(error)) {
useSshPassphraseStore
.getState()
- .setRetryAction(() =>
+ .setRetryAction(`remove:${projectId}:${branchName}`, () =>
removeWorktree(projectId, branchName).then(
() => undefined,
),
@@ -254,6 +236,7 @@ export function usePrRunAppState() {
}
showSuccessToast(result.message);
+ workspaceState.closeWorktreeTab(`${projectId}:${branchName}`);
await useWorktreeTerminalStore
.getState()
.disposeOwner(getWorktreeOwnerKey(projectId, branchName));
@@ -269,17 +252,19 @@ export function usePrRunAppState() {
if (isHandledSshPromptError(error)) {
useSshPassphraseStore
.getState()
- .setRetryAction(() =>
+ .setRetryAction(`refresh:${project.id}`, () =>
updateProject(project).then(() => undefined),
);
- return;
+ return false;
}
setActionError(getErrorMessage(error));
- return;
+ toast.error(getErrorMessage(error), { timeout: 3200 });
+ return false;
}
showSuccessToast(result.message);
+ return true;
}
return {
@@ -291,7 +276,6 @@ export function usePrRunAppState() {
? getErrorMessage(createScriptMutation.error)
: undefined,
configError,
- expandedGroups,
collapsedProjects,
groups,
isAddProjectOpen,
@@ -308,6 +292,7 @@ export function usePrRunAppState() {
pendingProjectUpdateId: updateProjectWorktreesMutation.isPending
? updateProjectWorktreesMutation.variables
: undefined,
+ projectAvatarUris,
pendingWorktreeCheckoutKey: checkoutBranchMutation.isPending
? `${checkoutBranchMutation.variables?.projectId}:${checkoutBranchMutation.variables?.branchName}`
: undefined,
@@ -331,17 +316,39 @@ export function usePrRunAppState() {
setIsCreateScriptOpen(true);
},
openSshPassphrase: () => useSshPassphraseStore.getState().open(null),
+ ...settingsState,
removeWorktree,
- selectBranch,
setTheme,
submitAddProject,
- toggleGroup,
toggleProject,
updateProject,
checkoutBranch,
+ closeWorktreeTab: workspaceState.closeWorktreeTab,
+ isOverviewOpen: workspaceState.isOverviewOpen,
+ openOverview: () => {
+ setActionError(undefined);
+ workspaceState.openOverview();
+ },
+ selectBranch: (project: ProjectConfig, branch: BranchInfo) => {
+ setActionError(undefined);
+ workspaceState.selectBranch(project, branch);
+ },
+ selectWorktreeTab: workspaceState.selectWorktreeTab,
};
}
+async function syncTitleBarTheme(theme: "dark" | "light") {
+ if (!window.prRun) {
+ return;
+ }
+
+ const [error] = await tryPromise(window.prRun.setTitleBarTheme(theme));
+
+ if (error) {
+ console.error("Failed to update the title bar theme.", error);
+ }
+}
+
function showSuccessToast(message: string) {
toast.success(message, {
timeout: 2400,
diff --git a/src/lib/components/templates/pr-run-app/workspace-state.ts b/src/lib/components/templates/pr-run-app/workspace-state.ts
new file mode 100644
index 0000000..ffb7914
--- /dev/null
+++ b/src/lib/components/templates/pr-run-app/workspace-state.ts
@@ -0,0 +1,237 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+
+import { usePreloadProjects } from "@/lib/hooks/query/use-preload-projects";
+import {
+ getWorktreeTabId,
+ useWorkspaceTabsStore,
+} from "@/lib/hooks/store/use-workspace-tabs-store";
+import type { BranchInfo, ProjectConfig } from "@/types/pr-run";
+
+import type {
+ SelectedBranchState,
+ SelectedBranchView,
+} from "@/lib/components/templates/pr-run-app/types";
+
+type WorkspaceStateOptions = {
+ onLeaveSettings: () => void;
+ projects: ProjectConfig[];
+};
+
+export function useWorkspaceState({
+ onLeaveSettings,
+ projects,
+}: WorkspaceStateOptions) {
+ const [selectedBranch, setSelectedBranch] =
+ useState(null);
+ const [isOverviewOpen, setIsOverviewOpen] = useState(true);
+ const workspaceTabs = useWorkspaceTabsStore((store) => store.tabs);
+ const activeWorkspaceTabId = useWorkspaceTabsStore(
+ (store) => store.activeTabId,
+ );
+ const hasRestoredWorkspaceRef = useRef(false);
+ const restoredTabIdsRef = useRef(
+ new Set(useWorkspaceTabsStore.getState().tabs.map((tab) => tab.id)),
+ );
+ const restoredProjectBranchesRef = useRef(new Map());
+ const selectedProject = useMemo(
+ () =>
+ projects.find(
+ (project) => project.id === selectedBranch?.projectId,
+ ) ?? null,
+ [projects, selectedBranch?.projectId],
+ );
+ const selectedBranchView: SelectedBranchView = {
+ branchName: selectedBranch?.branchName ?? null,
+ project: selectedProject,
+ };
+
+ usePreloadProjects(projects, (projectId, branches) => {
+ restoredProjectBranchesRef.current.set(projectId, branches);
+ const store = useWorkspaceTabsStore.getState();
+ const validTabIds = new Set(
+ store.tabs
+ .filter((tab) => {
+ if (!restoredTabIdsRef.current.has(tab.id)) {
+ return true;
+ }
+
+ const projectBranches =
+ restoredProjectBranchesRef.current.get(tab.projectId);
+
+ if (!projectBranches) {
+ return true;
+ }
+
+ return projectBranches.some(
+ (branch) =>
+ branch.name === tab.branchName &&
+ branch.hasWorktree,
+ );
+ })
+ .map((tab) => tab.id),
+ );
+ const selectedTabId = selectedBranch
+ ? getWorktreeTabId(
+ selectedBranch.projectId,
+ selectedBranch.branchName,
+ )
+ : null;
+
+ store.pruneTabs(validTabIds);
+
+ if (selectedTabId && !validTabIds.has(selectedTabId)) {
+ setSelectedBranch(null);
+ setIsOverviewOpen(true);
+ }
+ });
+
+ useEffect(() => {
+ if (hasRestoredWorkspaceRef.current || projects.length === 0) {
+ return;
+ }
+
+ hasRestoredWorkspaceRef.current = true;
+ const activeTab = workspaceTabs.find(
+ (tab) => tab.id === activeWorkspaceTabId,
+ );
+
+ if (!activeTab) {
+ return;
+ }
+
+ const project = projects.find(
+ (item) => item.id === activeTab.projectId,
+ );
+
+ if (!project) {
+ useWorkspaceTabsStore
+ .getState()
+ .pruneTabs(
+ new Set(
+ workspaceTabs
+ .filter((tab) =>
+ projects.some(
+ (item) => item.id === tab.projectId,
+ ),
+ )
+ .map((tab) => tab.id),
+ ),
+ );
+ return;
+ }
+
+ setSelectedBranch({
+ branchName: activeTab.branchName,
+ projectId: activeTab.projectId,
+ });
+ setIsOverviewOpen(false);
+ }, [activeWorkspaceTabId, projects, workspaceTabs]);
+
+ useEffect(() => {
+ if (!selectedBranch || selectedProject) {
+ return;
+ }
+
+ setSelectedBranch(null);
+ setIsOverviewOpen(true);
+ }, [selectedBranch, selectedProject]);
+
+ function selectBranch(project: ProjectConfig, branch: BranchInfo) {
+ onLeaveSettings();
+ setSelectedBranch({
+ branchName: branch.name,
+ projectId: project.id,
+ });
+ setIsOverviewOpen(false);
+
+ if (branch.hasWorktree) {
+ useWorkspaceTabsStore.getState().openTab({
+ branchName: branch.name,
+ projectId: project.id,
+ projectName: project.name,
+ });
+ }
+ }
+
+ function openCreatedWorktree(projectId: string, branchName: string) {
+ const project = projects.find((item) => item.id === projectId);
+
+ if (!project) {
+ return;
+ }
+
+ onLeaveSettings();
+ useWorkspaceTabsStore.getState().openTab({
+ branchName,
+ projectId,
+ projectName: project.name,
+ });
+ setSelectedBranch({ branchName, projectId });
+ setIsOverviewOpen(false);
+ }
+
+ function openOverview() {
+ onLeaveSettings();
+ setIsOverviewOpen(true);
+ }
+
+ function selectWorktreeTab(tabId: string) {
+ const tab = useWorkspaceTabsStore
+ .getState()
+ .tabs.find((item) => item.id === tabId);
+
+ if (!tab) {
+ return;
+ }
+
+ useWorkspaceTabsStore.getState().activateTab(tabId);
+ onLeaveSettings();
+ setSelectedBranch({
+ branchName: tab.branchName,
+ projectId: tab.projectId,
+ });
+ setIsOverviewOpen(false);
+ }
+
+ function closeWorktreeTab(tabId: string) {
+ const wasSelected =
+ selectedBranch !== null &&
+ getWorktreeTabId(
+ selectedBranch.projectId,
+ selectedBranch.branchName,
+ ) === tabId;
+ useWorkspaceTabsStore.getState().closeTab(tabId);
+
+ if (!wasSelected) {
+ return;
+ }
+
+ const nextTabId = useWorkspaceTabsStore.getState().activeTabId;
+ const nextTab = useWorkspaceTabsStore
+ .getState()
+ .tabs.find((tab) => tab.id === nextTabId);
+
+ if (!nextTab) {
+ setSelectedBranch(null);
+ setIsOverviewOpen(true);
+ return;
+ }
+
+ setSelectedBranch({
+ branchName: nextTab.branchName,
+ projectId: nextTab.projectId,
+ });
+ setIsOverviewOpen(false);
+ }
+
+ return {
+ closeWorktreeTab,
+ isOverviewOpen,
+ openCreatedWorktree,
+ openOverview,
+ selectBranch,
+ selectedBranch,
+ selectedBranchView,
+ selectWorktreeTab,
+ };
+}
diff --git a/src/lib/components/templates/settings-page/appearance-settings.tsx b/src/lib/components/templates/settings-page/appearance-settings.tsx
new file mode 100644
index 0000000..e5c34fe
--- /dev/null
+++ b/src/lib/components/templates/settings-page/appearance-settings.tsx
@@ -0,0 +1,59 @@
+import {
+ Select,
+ SelectContent,
+ SelectOption,
+ SelectTrigger,
+} from "@/lib/components/ui/select";
+import type { ReactNode } from "react";
+import { useUiPreferencesStore } from "@/lib/hooks/store/use-ui-preferences-store";
+
+export function AppearanceSettings() {
+ const theme = useUiPreferencesStore((store) => store.theme);
+ const setTheme = useUiPreferencesStore((store) => store.setTheme);
+
+ return (
+
+
+
+ );
+}
+
+export function SettingsSection({
+ children,
+ description,
+ title,
+}: {
+ children: ReactNode;
+ description: string;
+ title: string;
+}) {
+ return (
+
+
+
{title}
+
+ {description}
+
+
+ {children}
+
+ );
+}
diff --git a/src/lib/components/templates/settings-page/diagnostics-settings.tsx b/src/lib/components/templates/settings-page/diagnostics-settings.tsx
new file mode 100644
index 0000000..a110e56
--- /dev/null
+++ b/src/lib/components/templates/settings-page/diagnostics-settings.tsx
@@ -0,0 +1,41 @@
+import { SettingsSection } from "@/lib/components/templates/settings-page/appearance-settings";
+import type { AppStatusSummary } from "@/lib/components/templates/pr-run-app/use-app-status-summary";
+
+export function DiagnosticsSettings({
+ projectCount,
+ summary,
+}: {
+ projectCount: number;
+ summary: AppStatusSummary;
+}) {
+ const values = [
+ ["Projects", projectCount],
+ ["Branches", summary.branchCount],
+ ["Open PRs", summary.openPullRequestCount],
+ ["Worktrees", summary.worktreeCount],
+ ["Stale worktrees", summary.staleWorktreeCount],
+ ["Busy terminals", summary.busyTerminalCount],
+ ];
+ return (
+
+
+ {values.map(([label, value]) => (
+
+
-
+ {label}
+
+ -
+ {value}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/lib/components/templates/settings-page/general-settings.tsx b/src/lib/components/templates/settings-page/general-settings.tsx
new file mode 100644
index 0000000..541545b
--- /dev/null
+++ b/src/lib/components/templates/settings-page/general-settings.tsx
@@ -0,0 +1,68 @@
+import { useEffect, useState } from "react";
+
+import { getBackendUrl } from "@/lib/api";
+import { Button } from "@/lib/components/ui/button";
+import { toast } from "@/lib/components/ui/toast";
+import { SettingsSection } from "@/lib/components/templates/settings-page/appearance-settings";
+import { tryPromise } from "@/lib/error";
+import { getErrorMessage } from "@/lib/utils/get-error-message";
+import type { ProjectConfig } from "@/types/pr-run";
+
+export function GeneralSettings({
+ projects,
+ onRefreshProject,
+}: {
+ onRefreshProject: (project: ProjectConfig) => Promise;
+ projects: ProjectConfig[];
+}) {
+ const [backendUrl, setBackendUrl] = useState(
+ "Loading local backend URL...",
+ );
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ useEffect(() => {
+ getBackendUrl().then(setBackendUrl);
+ }, []);
+
+ async function refreshAll() {
+ setIsRefreshing(true);
+ for (const project of projects) {
+ const [error, didRefresh] = await tryPromise(
+ onRefreshProject(project),
+ );
+ if (error || !didRefresh) {
+ if (error) {
+ toast.error(getErrorMessage(error));
+ }
+ break;
+ }
+ }
+ setIsRefreshing(false);
+ }
+
+ return (
+
+
+
+
- Backend URL
+ - {backendUrl}
+
+
+
-
+ Configured projects
+
+ - {projects.length}
+
+
+
+
+ );
+}
diff --git a/src/lib/components/templates/settings-page/index.tsx b/src/lib/components/templates/settings-page/index.tsx
new file mode 100644
index 0000000..bcf778f
--- /dev/null
+++ b/src/lib/components/templates/settings-page/index.tsx
@@ -0,0 +1,45 @@
+import { AppearanceSettings } from "@/lib/components/templates/settings-page/appearance-settings";
+import { DiagnosticsSettings } from "@/lib/components/templates/settings-page/diagnostics-settings";
+import { GeneralSettings } from "@/lib/components/templates/settings-page/general-settings";
+import { ProjectsSettings } from "@/lib/components/templates/settings-page/projects-settings";
+import { ScriptsSettings } from "@/lib/components/templates/settings-page/scripts-settings";
+import { SettingsLayout } from "@/lib/components/templates/settings-page/settings-layout";
+import { SshSettings } from "@/lib/components/templates/settings-page/ssh-settings";
+import type { SettingsPageProps } from "@/lib/components/templates/settings-page/types";
+
+export function SettingsPage(props: SettingsPageProps) {
+ const projects = props.groups.flatMap((group) => group.projects);
+ const content = {
+ appearance: ,
+ diagnostics: (
+
+ ),
+ general: (
+
+ ),
+ projects: (
+
+ ),
+ scripts: ,
+ ssh: ,
+ }[props.section];
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/src/lib/components/templates/settings-page/projects-settings.tsx b/src/lib/components/templates/settings-page/projects-settings.tsx
new file mode 100644
index 0000000..7e42ef1
--- /dev/null
+++ b/src/lib/components/templates/settings-page/projects-settings.tsx
@@ -0,0 +1,58 @@
+import { RefreshCw } from "lucide-react";
+
+import { Button } from "@/lib/components/ui/button";
+import { SettingsSection } from "@/lib/components/templates/settings-page/appearance-settings";
+import type { ProjectConfig, ProjectGroup } from "@/types/pr-run";
+
+export function ProjectsSettings({
+ groups,
+ onRefreshProject,
+}: {
+ groups: ProjectGroup[];
+ onRefreshProject: (project: ProjectConfig) => Promise;
+}) {
+ return (
+
+
+ {groups.flatMap((group) =>
+ group.projects.map((project) => (
+
+
+
+ {project.name}
+
+
+ {project.path}
+
+
+ {group.name}
+
+
+
+
+ )),
+ )}
+
+
+ );
+}
diff --git a/src/lib/components/templates/settings-page/scripts-settings.tsx b/src/lib/components/templates/settings-page/scripts-settings.tsx
new file mode 100644
index 0000000..b7d127c
--- /dev/null
+++ b/src/lib/components/templates/settings-page/scripts-settings.tsx
@@ -0,0 +1,148 @@
+import { Pencil, Plus, Trash2 } from "lucide-react";
+import { useState } from "react";
+
+import { Button } from "@/lib/components/ui/button";
+import {
+ Dialog,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogPopup,
+ DialogTitle,
+} from "@/lib/components/ui/dialog";
+import { toast } from "@/lib/components/ui/toast";
+import { tryPromise } from "@/lib/error";
+import { useDeleteScriptMutation } from "@/lib/hooks/query/use-delete-script-mutation";
+import { useOpenScriptMutation } from "@/lib/hooks/query/use-open-script-mutation";
+import { useScriptsQuery } from "@/lib/hooks/query/use-scripts-query";
+import { getErrorMessage } from "@/lib/utils/get-error-message";
+import { SettingsSection } from "@/lib/components/templates/settings-page/appearance-settings";
+import type { ScriptInfo } from "@/types/pr-run";
+
+export function ScriptsSettings({
+ onCreateScript,
+}: {
+ onCreateScript: () => void;
+}) {
+ const scripts = useScriptsQuery();
+ const open = useOpenScriptMutation();
+ const remove = useDeleteScriptMutation();
+ const [pendingDelete, setPendingDelete] = useState(null);
+
+ async function edit(script: ScriptInfo) {
+ const [error] = await tryPromise(open.mutateAsync(script.id));
+ if (error) toast.error(getErrorMessage(error));
+ }
+ async function deleteScript() {
+ const script = pendingDelete;
+ if (!script) return;
+ const [error] = await tryPromise(remove.mutateAsync(script.id));
+ if (error) {
+ toast.error(getErrorMessage(error));
+ return;
+ }
+ toast.success(`${script.title} deleted.`, { timeout: 2400 });
+ setPendingDelete(null);
+ }
+
+ return (
+ <>
+
+
+ {scripts.isPending ? (
+
+ Loading scripts...
+
+ ) : scripts.error ? (
+
+ {getErrorMessage(scripts.error)}
+
+ ) : (
+
+ {(scripts.data ?? []).map((script) => (
+
+
+
+ {script.title}
+
+
+ {script.fileName}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/src/lib/components/templates/settings-page/settings-layout.tsx b/src/lib/components/templates/settings-page/settings-layout.tsx
new file mode 100644
index 0000000..751f1f5
--- /dev/null
+++ b/src/lib/components/templates/settings-page/settings-layout.tsx
@@ -0,0 +1,45 @@
+import type { ReactNode } from "react";
+
+import { Button } from "@/lib/components/ui/button";
+import { SettingsSidebarNav } from "@/lib/components/templates/settings-page/settings-sidebar-nav";
+import type { SettingsSection } from "@/lib/components/templates/pr-run-app/types";
+
+export function SettingsLayout({
+ children,
+ onClose,
+ onSelectSection,
+ section,
+}: {
+ children: ReactNode;
+ onClose: () => void;
+ onSelectSection: (section: SettingsSection) => void;
+ section: SettingsSection;
+}) {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/lib/components/templates/settings-page/settings-sidebar-nav.tsx b/src/lib/components/templates/settings-page/settings-sidebar-nav.tsx
new file mode 100644
index 0000000..ba0adbf
--- /dev/null
+++ b/src/lib/components/templates/settings-page/settings-sidebar-nav.tsx
@@ -0,0 +1,58 @@
+import {
+ Activity,
+ FolderGit2,
+ KeyRound,
+ Palette,
+ ScrollText,
+ Settings,
+} from "lucide-react";
+import type { ElementType } from "react";
+
+import { Button } from "@/lib/components/ui/button";
+import { cn } from "@/lib/utils/cn";
+import type { SettingsSection } from "@/lib/components/templates/pr-run-app/types";
+
+const sections: Array<{
+ icon: ElementType;
+ id: SettingsSection;
+ label: string;
+}> = [
+ { icon: Settings, id: "general", label: "General" },
+ { icon: Palette, id: "appearance", label: "Appearance" },
+ { icon: FolderGit2, id: "projects", label: "Projects" },
+ { icon: ScrollText, id: "scripts", label: "Scripts" },
+ { icon: KeyRound, id: "ssh", label: "SSH" },
+ { icon: Activity, id: "diagnostics", label: "Diagnostics" },
+];
+
+export function SettingsSidebarNav({
+ section,
+ onSelect,
+}: {
+ onSelect: (section: SettingsSection) => void;
+ section: SettingsSection;
+}) {
+ return (
+
+ );
+}
diff --git a/src/lib/components/templates/settings-page/ssh-settings.tsx b/src/lib/components/templates/settings-page/ssh-settings.tsx
new file mode 100644
index 0000000..6e72c80
--- /dev/null
+++ b/src/lib/components/templates/settings-page/ssh-settings.tsx
@@ -0,0 +1,37 @@
+import { KeyRound, Trash2 } from "lucide-react";
+
+import { prRunApi } from "@/lib/api";
+import { Button } from "@/lib/components/ui/button";
+import { toast } from "@/lib/components/ui/toast";
+import { tryPromise } from "@/lib/error";
+import { getErrorMessage } from "@/lib/utils/get-error-message";
+import { SettingsSection } from "@/lib/components/templates/settings-page/appearance-settings";
+
+export function SshSettings({ onOpen }: { onOpen: () => void }) {
+ async function clear() {
+ const [error] = await tryPromise(prRunApi.clearSshPassphrase());
+ if (error) {
+ toast.error(getErrorMessage(error));
+ return;
+ }
+ toast.success("SSH passphrase cleared.");
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/lib/components/templates/settings-page/types.ts b/src/lib/components/templates/settings-page/types.ts
new file mode 100644
index 0000000..a4d15a1
--- /dev/null
+++ b/src/lib/components/templates/settings-page/types.ts
@@ -0,0 +1,14 @@
+import type { AppStatusSummary } from "@/lib/components/templates/pr-run-app/use-app-status-summary";
+import type { SettingsSection } from "@/lib/components/templates/pr-run-app/types";
+import type { ProjectConfig, ProjectGroup } from "@/types/pr-run";
+
+export type SettingsPageProps = {
+ groups: ProjectGroup[];
+ onClose: () => void;
+ onCreateScript: () => void;
+ onOpenSshPassphrase: () => void;
+ onRefreshProject: (project: ProjectConfig) => Promise;
+ onSelectSection: (section: SettingsSection) => void;
+ section: SettingsSection;
+ summary: AppStatusSummary;
+};
diff --git a/src/lib/components/templates/sidebar/index.tsx b/src/lib/components/templates/sidebar/index.tsx
index 4e68f88..e96e158 100644
--- a/src/lib/components/templates/sidebar/index.tsx
+++ b/src/lib/components/templates/sidebar/index.tsx
@@ -1,6 +1,7 @@
import { SidebarContent } from "@/lib/components/templates/sidebar/sidebar-content";
+import { SidebarFooter } from "@/lib/components/templates/sidebar/sidebar-footer";
import { SidebarGroupSection } from "@/lib/components/templates/sidebar/sidebar-group-section";
-import { SidebarHeader } from "@/lib/components/templates/sidebar/sidebar-header";
+import { SidebarOverviewButton } from "@/lib/components/templates/sidebar/sidebar-overview-button";
import { SidebarRail } from "@/lib/components/templates/sidebar/sidebar-rail";
import { SidebarShell } from "@/lib/components/templates/sidebar/sidebar-shell";
import type { SidebarProps } from "@/lib/components/templates/sidebar/types";
@@ -9,62 +10,65 @@ export function Sidebar({
busyOwnerKeys,
busyProjectIds,
collapsedProjects,
- expandedGroups,
groups,
- isCreatingScript,
+ isDesktopHidden,
+ isMobileOpen,
+ isOverviewActive,
+ isSettingsActive,
pendingProjectUpdateId,
pendingWorktreeCheckoutKey,
pendingWorktreeRemovalKey,
+ projectAvatarUris,
selectedBranchName,
selectedProjectId,
sidebarWidth,
- theme,
- onAddProject,
onBeginResize,
onCheckoutBranch,
- onCreateScript,
- onOpenSshPassphrase,
+ onOpenAddProject,
+ onOpenOverview,
+ onOpenSettings,
onRemoveWorktree,
onSelectBranch,
- onToggleGroup,
onToggleProject,
- onToggleTheme,
onUpdateProject,
}: SidebarProps) {
return (
-
-
-
+
+
{groups.map((group) => (
))}
+
diff --git a/src/lib/components/templates/sidebar/sidebar-branch-item.tsx b/src/lib/components/templates/sidebar/sidebar-branch-item.tsx
index edc9cee..6e13a6a 100644
--- a/src/lib/components/templates/sidebar/sidebar-branch-item.tsx
+++ b/src/lib/components/templates/sidebar/sidebar-branch-item.tsx
@@ -1,10 +1,28 @@
import { FolderPlus, RefreshCw, Trash2 } from "lucide-react";
+import { useEffect, useState } from "react";
import { BusyIcon } from "@/lib/components/atoms/busy-icon";
-import { Button } from "@/lib/components/atoms/button";
+import { WorktreeIndicator } from "@/lib/components/atoms/worktree-indicator";
+import { Button } from "@/lib/components/ui/button";
+import {
+ Dialog,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogPopup,
+ DialogTitle,
+} from "@/lib/components/ui/dialog";
import { StatusPill } from "@/lib/components/atoms/status-pill";
import { formatBranchAge } from "@/lib/format";
-import { SidebarItemIcon } from "@/lib/components/templates/sidebar/sidebar-item-icon";
+import {
+ Tooltip,
+ TooltipPopup,
+ TooltipTrigger,
+} from "@/lib/components/ui/tooltip";
+import {
+ SidebarPrAuthorAvatar,
+ SidebarPrPeopleTooltip,
+} from "@/lib/components/templates/sidebar/sidebar-pr-people-tooltip";
import { getSidebarItemStatus } from "@/lib/components/templates/sidebar/sidebar-item-status";
import { cn } from "@/lib/utils/cn";
import type { BranchInfo } from "@/types/pr-run";
@@ -32,68 +50,150 @@ export function SidebarBranchItem({
onRemoveWorktree,
onSelectBranch,
}: SidebarBranchItemProps) {
+ const [isRemoveConfirmOpen, setIsRemoveConfirmOpen] = useState(false);
+
+ useEffect(() => {
+ if (!branch.hasWorktree) {
+ setIsRemoveConfirmOpen(false);
+ }
+ }, [branch.hasWorktree]);
const status = getSidebarItemStatus(branch);
const isActionPending = isCheckingOutWorktree || isRemovingWorktree;
-
- return (
- onSelectBranch(branch.name)}
>
-
+ ) : null}
+
onSelectBranch(branch.name)}
>
-
-
- {isBusy ? (
-
+
+ {isBusy ? : null}
+ {branch.hasWorktree ? (
+
+
+ }
/>
- ) : null}
-
+ Git worktree is ready.
+
+ ) : null}
+
+
+ {status.label}
+
+ }
+ />
+ {status.description}
+
- {branch.name}
-
-
-
- {status.label}
-
-
- {formatBranchAge(branch.lastCommitTimestamp)}
-
+ {branchAge.endsWith("m") ? (
+ <>
+ {branchAge.slice(0, -1)}
+
+ m
+
+ >
+ ) : (
+ branchAge
+ )}
-
+
+
+ );
+
+ return (
+
+
+ {branch.pullRequest ? (
+
+ {branchButton}
+
+ ) : (
+ branchButton
+ )}
{
- onRemoveWorktree(branch.name);
- }}
+ onClick={() => setIsRemoveConfirmOpen(true)}
>
{isRemovingWorktree ? (
@@ -130,15 +225,12 @@ export function SidebarBranchItem({
) : (
)}
+
);
}
diff --git a/src/lib/components/templates/sidebar/sidebar-content.tsx b/src/lib/components/templates/sidebar/sidebar-content.tsx
index bc496db..0b67291 100644
--- a/src/lib/components/templates/sidebar/sidebar-content.tsx
+++ b/src/lib/components/templates/sidebar/sidebar-content.tsx
@@ -1,17 +1,20 @@
import type { ReactNode } from "react";
+import { ScrollArea } from "@/lib/components/ui/scroll-area";
+
type SidebarContentProps = {
children: ReactNode;
};
export function SidebarContent({ children }: SidebarContentProps) {
return (
-
+ {children}
+
);
}
diff --git a/src/lib/components/templates/sidebar/sidebar-footer.tsx b/src/lib/components/templates/sidebar/sidebar-footer.tsx
new file mode 100644
index 0000000..eeb6b75
--- /dev/null
+++ b/src/lib/components/templates/sidebar/sidebar-footer.tsx
@@ -0,0 +1,35 @@
+import { Settings } from "lucide-react";
+
+import { Button } from "@/lib/components/ui/button";
+import { cn } from "@/lib/utils/cn";
+
+type SidebarFooterProps = {
+ isSettingsActive: boolean;
+ onOpenSettings: () => void;
+};
+
+export function SidebarFooter({
+ isSettingsActive,
+ onOpenSettings,
+}: SidebarFooterProps) {
+ return (
+
+ );
+}
diff --git a/src/lib/components/templates/sidebar/sidebar-group-section.tsx b/src/lib/components/templates/sidebar/sidebar-group-section.tsx
index 367b0e6..ca1e5f9 100644
--- a/src/lib/components/templates/sidebar/sidebar-group-section.tsx
+++ b/src/lib/components/templates/sidebar/sidebar-group-section.tsx
@@ -13,17 +13,17 @@ type SidebarGroupSectionProps = Pick<
| "pendingProjectUpdateId"
| "pendingWorktreeCheckoutKey"
| "pendingWorktreeRemovalKey"
+ | "projectAvatarUris"
| "selectedBranchName"
| "selectedProjectId"
| "onCheckoutBranch"
+ | "onOpenAddProject"
| "onRemoveWorktree"
| "onSelectBranch"
- | "onToggleGroup"
| "onToggleProject"
| "onUpdateProject"
> & {
group: ProjectGroup;
- isExpanded: boolean;
};
export function SidebarGroupSection({
@@ -31,16 +31,16 @@ export function SidebarGroupSection({
busyProjectIds,
collapsedProjects,
group,
- isExpanded,
pendingProjectUpdateId,
pendingWorktreeCheckoutKey,
pendingWorktreeRemovalKey,
+ projectAvatarUris,
selectedBranchName,
selectedProjectId,
onCheckoutBranch,
+ onOpenAddProject,
onRemoveWorktree,
onSelectBranch,
- onToggleGroup,
onToggleProject,
onUpdateProject,
}: SidebarGroupSectionProps) {
@@ -50,54 +50,48 @@ export function SidebarGroupSection({
);
return (
-
+
onToggleGroup(group.id)}
+ onCreateProject={
+ group.id === "default" ? onOpenAddProject : undefined
+ }
>
{group.id === "default" ? "Projects" : group.name}
- {isExpanded ? (
-
- {group.projects.length === 0 ? (
-
- No projects added.
-
- ) : null}
+
+ {group.projects.length === 0 ? (
+ No projects added.
+ ) : null}
- {sortedProjects.map((project) => (
-
- ))}
-
- ) : null}
+ {sortedProjects.map((project) => (
+
+ ))}
+
);
}
diff --git a/src/lib/components/templates/sidebar/sidebar-header.tsx b/src/lib/components/templates/sidebar/sidebar-header.tsx
deleted file mode 100644
index 7a626e7..0000000
--- a/src/lib/components/templates/sidebar/sidebar-header.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { FilePlus2, KeyRound, Moon, Plus, SunMedium } from "lucide-react";
-
-import { Button } from "@/lib/components/atoms/button";
-
-type SidebarHeaderProps = {
- isCreatingScript: boolean;
- theme: "dark" | "light";
- onAddProject: () => void;
- onCreateScript: () => void;
- onOpenSshPassphrase: () => void;
- onToggleTheme: () => void;
-};
-
-export function SidebarHeader({
- isCreatingScript,
- theme,
- onAddProject,
- onCreateScript,
- onOpenSshPassphrase,
- onToggleTheme,
-}: SidebarHeaderProps) {
- return (
-
-
-
- PR Run
-
-
- branches + worktrees
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/lib/components/templates/sidebar/sidebar-item-icon.tsx b/src/lib/components/templates/sidebar/sidebar-item-icon.tsx
deleted file mode 100644
index 139a315..0000000
--- a/src/lib/components/templates/sidebar/sidebar-item-icon.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { GitBranch, GitPullRequest } from "lucide-react";
-
-import { getSidebarItemStatus } from "@/lib/components/templates/sidebar/sidebar-item-status";
-import type { BranchInfo } from "@/types/pr-run";
-
-type SidebarItemIconProps = {
- branch: BranchInfo;
-};
-
-export function SidebarItemIcon({ branch }: SidebarItemIconProps) {
- const status = getSidebarItemStatus(branch);
-
- return (
-
- {branch.source === "pull-request" ? (
-
- ) : (
-
- )}
-
- );
-}
diff --git a/src/lib/components/templates/sidebar/sidebar-item-status.test.ts b/src/lib/components/templates/sidebar/sidebar-item-status.test.ts
new file mode 100644
index 0000000..bbae9d1
--- /dev/null
+++ b/src/lib/components/templates/sidebar/sidebar-item-status.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, test } from "bun:test";
+
+import { getSidebarItemStatus } from "@/lib/components/templates/sidebar/sidebar-item-status";
+import type { BranchInfo, PullRequestState } from "@/types/pr-run";
+
+const branch = (overrides: Partial = {}): BranchInfo => ({
+ hasWorktree: false,
+ isStale: false,
+ lastCommitTimestamp: 0,
+ name: "feature",
+ remoteName: "origin/feature",
+ source: "branch",
+ worktreePath: "/workspace/feature",
+ ...overrides,
+});
+
+const pullRequestBranch = (
+ state: PullRequestState,
+ overrides: Partial = {},
+) =>
+ branch({
+ hasWorktree: true,
+ isStale: true,
+ pullRequest: {
+ assignees: [],
+ baseBranchName: "main",
+ isDraft: false,
+ latestReviews: [],
+ number: 1,
+ reviewRequests: [],
+ state,
+ title: "Feature",
+ url: "https://github.com/example/project/pull/1",
+ },
+ source: "pull-request",
+ ...overrides,
+ });
+
+describe("getSidebarItemStatus", () => {
+ test.each([
+ ["OPEN", "open", "Open"],
+ ["CLOSED", "closed", "Closed"],
+ ["MERGED", "merged", "Merged"],
+ ] as const)(
+ "classifies a %s PR before worktree and stale state",
+ (state, status, label) => {
+ expect(
+ getSidebarItemStatus(pullRequestBranch(state)),
+ ).toMatchObject({
+ label,
+ status,
+ });
+ },
+ );
+
+ test("classifies an open draft PR as Draft", () => {
+ const item = pullRequestBranch("OPEN");
+ item.pullRequest!.isDraft = true;
+
+ expect(getSidebarItemStatus(item)).toMatchObject({
+ label: "Draft",
+ status: "draft",
+ });
+ });
+
+ test("ignores draft metadata after a PR closes", () => {
+ const item = pullRequestBranch("CLOSED");
+ item.pullRequest!.isDraft = true;
+
+ expect(getSidebarItemStatus(item)).toMatchObject({
+ label: "Closed",
+ status: "closed",
+ });
+ });
+
+ test("classifies a normal worktree as Branch", () => {
+ expect(
+ getSidebarItemStatus(branch({ hasWorktree: true })),
+ ).toMatchObject({ label: "Branch", status: "branch" });
+ });
+
+ test("classifies a stale normal worktree as Stale", () => {
+ expect(
+ getSidebarItemStatus(branch({ hasWorktree: true, isStale: true })),
+ ).toMatchObject({ label: "Stale", status: "stale" });
+ });
+});
diff --git a/src/lib/components/templates/sidebar/sidebar-item-status.ts b/src/lib/components/templates/sidebar/sidebar-item-status.ts
index 9cee80f..cce729f 100644
--- a/src/lib/components/templates/sidebar/sidebar-item-status.ts
+++ b/src/lib/components/templates/sidebar/sidebar-item-status.ts
@@ -1,14 +1,15 @@
-import type { BranchInfo } from "@/types/pr-run";
+import type { BranchInfo, PullRequestInfo } from "@/types/pr-run";
export type SidebarItemStatus =
- | "stale-worktree"
- | "worktree"
+ | "draft"
+ | "open"
+ | "closed"
+ | "merged"
| "stale"
- | "pull-request"
| "branch";
type SidebarItemStatusConfig = {
- iconClassName: string;
+ description: string;
label: string;
pillClassName: string;
status: SidebarItemStatus;
@@ -18,35 +19,41 @@ const sidebarItemStatusConfigs: Record<
SidebarItemStatus,
SidebarItemStatusConfig
> = {
- "stale-worktree": {
- iconClassName: "bg-danger/15 text-danger-foreground",
- label: "Stale Worktree",
- pillClassName: "border-danger/25 bg-danger/15 text-danger-foreground",
- status: "stale-worktree",
+ draft: {
+ description: "This pull request is a draft.",
+ label: "Draft",
+ pillClassName: "border-border bg-muted/35 text-muted-foreground",
+ status: "draft",
},
- worktree: {
- iconClassName: "bg-success/15 text-success",
- label: "Worktree",
+ open: {
+ description: "This pull request is open.",
+ label: "Open",
pillClassName:
"border-success/25 bg-success/10 text-success-foreground",
- status: "worktree",
+ status: "open",
+ },
+ closed: {
+ description: "This pull request is closed.",
+ label: "Closed",
+ pillClassName: "border-danger/25 bg-danger/12 text-danger-foreground",
+ status: "closed",
+ },
+ merged: {
+ description: "This pull request was merged.",
+ label: "Merged",
+ pillClassName:
+ "border-violet-500/25 bg-violet-500/10 text-violet-700 dark:text-violet-300",
+ status: "merged",
},
stale: {
- iconClassName: "bg-warning/15 text-warning-foreground",
+ description: "This branch has no recent activity.",
label: "Stale",
pillClassName:
"border-warning/25 bg-warning/10 text-warning-foreground",
status: "stale",
},
- "pull-request": {
- iconClassName: "bg-blue-500/20 text-blue-600 dark:text-blue-300",
- label: "PR",
- pillClassName:
- "border-blue-500/25 bg-blue-500/10 text-blue-700 dark:text-blue-300",
- status: "pull-request",
- },
branch: {
- iconClassName: "bg-muted/45 text-muted-foreground/75",
+ description: "This is a remote branch.",
label: "Branch",
pillClassName: "border-border bg-muted/35 text-muted-foreground",
status: "branch",
@@ -54,21 +61,25 @@ const sidebarItemStatusConfigs: Record<
};
export function getSidebarItemStatus(branch: BranchInfo) {
- if (branch.hasWorktree && branch.isStale) {
- return sidebarItemStatusConfigs["stale-worktree"];
- }
-
- if (branch.hasWorktree) {
- return sidebarItemStatusConfigs.worktree;
+ if (branch.pullRequest) {
+ return getPullRequestSidebarStatus(branch.pullRequest);
}
if (branch.isStale) {
return sidebarItemStatusConfigs.stale;
}
- if (branch.source === "pull-request") {
- return sidebarItemStatusConfigs["pull-request"];
+ return sidebarItemStatusConfigs.branch;
+}
+
+export function getPullRequestSidebarStatus(pullRequest: PullRequestInfo) {
+ if (pullRequest.state === "OPEN") {
+ return pullRequest.isDraft
+ ? sidebarItemStatusConfigs.draft
+ : sidebarItemStatusConfigs.open;
}
- return sidebarItemStatusConfigs.branch;
+ return pullRequest.state === "MERGED"
+ ? sidebarItemStatusConfigs.merged
+ : sidebarItemStatusConfigs.closed;
}
diff --git a/src/lib/components/templates/sidebar/sidebar-overview-button.tsx b/src/lib/components/templates/sidebar/sidebar-overview-button.tsx
new file mode 100644
index 0000000..b5cea16
--- /dev/null
+++ b/src/lib/components/templates/sidebar/sidebar-overview-button.tsx
@@ -0,0 +1,33 @@
+import { ChartNoAxesCombined } from "lucide-react";
+
+import { cn } from "@/lib/utils/cn";
+
+type SidebarOverviewButtonProps = {
+ isActive: boolean;
+ onClick: () => void;
+};
+
+export function SidebarOverviewButton({
+ isActive,
+ onClick,
+}: SidebarOverviewButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/lib/components/templates/sidebar/sidebar-pr-people-tooltip.tsx b/src/lib/components/templates/sidebar/sidebar-pr-people-tooltip.tsx
new file mode 100644
index 0000000..4223ff6
--- /dev/null
+++ b/src/lib/components/templates/sidebar/sidebar-pr-people-tooltip.tsx
@@ -0,0 +1,149 @@
+import { useState, type ReactElement } from "react";
+
+import {
+ Tooltip,
+ TooltipPopup,
+ TooltipTrigger,
+} from "@/lib/components/ui/tooltip";
+import { getSidebarPullRequestPeople } from "@/lib/components/templates/sidebar/sidebar-pr-people";
+import { getPullRequestSidebarStatus } from "@/lib/components/templates/sidebar/sidebar-item-status";
+import { cn } from "@/lib/utils/cn";
+import type { GitHubUserInfo, PullRequestInfo } from "@/types/pr-run";
+
+type SidebarPrPeopleTooltipProps = {
+ children: ReactElement;
+ pullRequest: PullRequestInfo;
+};
+
+type SidebarPrAuthorAvatarProps = {
+ className?: string;
+ user: GitHubUserInfo;
+};
+
+export function SidebarPrPeopleTooltip({
+ children,
+ pullRequest,
+}: SidebarPrPeopleTooltipProps) {
+ const relatedPeople = getSidebarPullRequestPeople(pullRequest);
+ const status = getPullRequestSidebarStatus(pullRequest);
+
+ return (
+
+
+
+
+
+ PR #{pullRequest.number} · {status.label}
+
+
+ {pullRequest.title}
+
+
+
+ {pullRequest.author ? (
+
+ ) : null}
+ {relatedPeople.people.map((person) => (
+
+ ))}
+ {relatedPeople.overflowCount > 0 ? (
+
+ +{relatedPeople.overflowCount} more
+
+ ) : null}
+
+
+
+ );
+}
+
+export function SidebarPrAuthorAvatar({
+ className,
+ user,
+}: SidebarPrAuthorAvatarProps) {
+ return (
+
+ );
+}
+
+function PullRequestPerson({
+ roleLabel,
+ user,
+}: {
+ roleLabel: string;
+ user: GitHubUserInfo;
+}) {
+ return (
+
+
+
+
+ {user.login}
+
+
+ {roleLabel}
+
+
+
+ );
+}
+
+function SidebarUserAvatar({
+ className,
+ user,
+}: {
+ className?: string;
+ user: GitHubUserInfo;
+}) {
+ const [failedImageUrl, setFailedImageUrl] = useState();
+
+ if (failedImageUrl !== user.avatarUrl) {
+ return (
+
setFailedImageUrl(user.avatarUrl)}
+ />
+ );
+ }
+
+ const initials = user.login.slice(0, 2).toUpperCase();
+
+ return (
+
+ {initials || "?"}
+
+ );
+}
diff --git a/src/lib/components/templates/sidebar/sidebar-pr-people.test.ts b/src/lib/components/templates/sidebar/sidebar-pr-people.test.ts
new file mode 100644
index 0000000..d562987
--- /dev/null
+++ b/src/lib/components/templates/sidebar/sidebar-pr-people.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, test } from "bun:test";
+
+import { getSidebarPullRequestPeople } from "@/lib/components/templates/sidebar/sidebar-pr-people";
+import type {
+ GitHubUserInfo,
+ PullRequestInfo,
+ PullRequestReviewState,
+} from "@/types/pr-run";
+
+const user = (login: string): GitHubUserInfo => ({
+ avatarUrl: `https://github.com/${login}.png`,
+ login,
+ url: `https://github.com/${login}`,
+});
+
+const pullRequest = (
+ overrides: Partial = {},
+): PullRequestInfo => ({
+ assignees: [],
+ author: user("author"),
+ baseBranchName: "main",
+ isDraft: false,
+ latestReviews: [],
+ number: 42,
+ reviewRequests: [],
+ state: "OPEN",
+ title: "Sidebar identity",
+ url: "https://github.com/example/repository/pull/42",
+ ...overrides,
+});
+
+describe("getSidebarPullRequestPeople", () => {
+ test("merges people and prioritizes requested reviews over other roles", () => {
+ const summary = getSidebarPullRequestPeople(
+ pullRequest({
+ assignees: [user("requested"), user("assigned")],
+ latestReviews: [
+ { author: user("requested"), state: "APPROVED" },
+ { author: user("reviewed"), state: "COMMENTED" },
+ ],
+ reviewRequests: [user("requested")],
+ }),
+ );
+
+ expect(summary).toEqual({
+ overflowCount: 0,
+ people: [
+ {
+ roleLabel: "Review requested, assignee",
+ user: user("requested"),
+ },
+ { roleLabel: "Commented", user: user("reviewed") },
+ { roleLabel: "Assignee", user: user("assigned") },
+ ],
+ });
+ });
+
+ test("excludes the author from related people", () => {
+ const summary = getSidebarPullRequestPeople(
+ pullRequest({
+ assignees: [user("author")],
+ latestReviews: [{ author: user("author"), state: "APPROVED" }],
+ reviewRequests: [user("author")],
+ }),
+ );
+
+ expect(summary.people).toEqual([]);
+ });
+
+ test.each([
+ ["APPROVED", "Approved"],
+ ["CHANGES_REQUESTED", "Changes requested"],
+ ["COMMENTED", "Commented"],
+ ["DISMISSED", "Dismissed"],
+ ["PENDING", "Pending"],
+ ] as [PullRequestReviewState, string][])(
+ "labels the %s review state",
+ (state, expectedLabel) => {
+ const summary = getSidebarPullRequestPeople(
+ pullRequest({
+ latestReviews: [{ author: user("reviewer"), state }],
+ }),
+ );
+
+ expect(summary.people[0]?.roleLabel).toBe(expectedLabel);
+ },
+ );
+
+ test("caps visible people and reports overflow", () => {
+ const summary = getSidebarPullRequestPeople(
+ pullRequest({
+ reviewRequests: [
+ user("one"),
+ user("two"),
+ user("three"),
+ user("four"),
+ user("five"),
+ user("six"),
+ ],
+ }),
+ );
+
+ expect(summary.people.map((person) => person.user.login)).toEqual([
+ "one",
+ "two",
+ "three",
+ "four",
+ ]);
+ expect(summary.overflowCount).toBe(2);
+ });
+});
diff --git a/src/lib/components/templates/sidebar/sidebar-pr-people.ts b/src/lib/components/templates/sidebar/sidebar-pr-people.ts
new file mode 100644
index 0000000..55f2559
--- /dev/null
+++ b/src/lib/components/templates/sidebar/sidebar-pr-people.ts
@@ -0,0 +1,99 @@
+import type {
+ GitHubUserInfo,
+ PullRequestInfo,
+ PullRequestReviewState,
+} from "@/types/pr-run";
+
+const MAX_VISIBLE_RELATED_PEOPLE = 4;
+
+type MergedPullRequestPerson = {
+ isAssignee: boolean;
+ isReviewRequested: boolean;
+ reviewState?: PullRequestReviewState;
+ user: GitHubUserInfo;
+};
+
+export type SidebarPullRequestPerson = {
+ roleLabel: string;
+ user: GitHubUserInfo;
+};
+
+export type SidebarPullRequestPeople = {
+ overflowCount: number;
+ people: SidebarPullRequestPerson[];
+};
+
+const reviewStateLabels: Record = {
+ APPROVED: "Approved",
+ CHANGES_REQUESTED: "Changes requested",
+ COMMENTED: "Commented",
+ DISMISSED: "Dismissed",
+ PENDING: "Pending",
+};
+
+export function getSidebarPullRequestPeople(
+ pullRequest: PullRequestInfo,
+): SidebarPullRequestPeople {
+ const authorLogin = pullRequest.author?.login;
+ const peopleByLogin = new Map();
+
+ const getPerson = (user: GitHubUserInfo) => {
+ const existingPerson = peopleByLogin.get(user.login);
+
+ if (existingPerson) {
+ return existingPerson;
+ }
+
+ const person: MergedPullRequestPerson = {
+ isAssignee: false,
+ isReviewRequested: false,
+ user,
+ };
+ peopleByLogin.set(user.login, person);
+ return person;
+ };
+
+ for (const reviewer of pullRequest.reviewRequests) {
+ if (reviewer.login !== authorLogin) {
+ getPerson(reviewer).isReviewRequested = true;
+ }
+ }
+
+ for (const review of pullRequest.latestReviews) {
+ if (review.author.login !== authorLogin) {
+ getPerson(review.author).reviewState = review.state;
+ }
+ }
+
+ for (const assignee of pullRequest.assignees) {
+ if (assignee.login !== authorLogin) {
+ getPerson(assignee).isAssignee = true;
+ }
+ }
+
+ const people = [...peopleByLogin.values()].map((person) => ({
+ roleLabel: getRoleLabel(person),
+ user: person.user,
+ }));
+
+ return {
+ overflowCount: Math.max(0, people.length - MAX_VISIBLE_RELATED_PEOPLE),
+ people: people.slice(0, MAX_VISIBLE_RELATED_PEOPLE),
+ };
+}
+
+function getRoleLabel(person: MergedPullRequestPerson) {
+ let label = "Assignee";
+
+ if (person.isReviewRequested) {
+ label = "Review requested";
+ } else if (person.reviewState) {
+ label = reviewStateLabels[person.reviewState];
+ }
+
+ if (person.isAssignee && (person.isReviewRequested || person.reviewState)) {
+ return `${label}, assignee`;
+ }
+
+ return label;
+}
diff --git a/src/lib/components/templates/sidebar/sidebar-project-item.tsx b/src/lib/components/templates/sidebar/sidebar-project-item.tsx
index 4bba268..ecd0437 100644
--- a/src/lib/components/templates/sidebar/sidebar-project-item.tsx
+++ b/src/lib/components/templates/sidebar/sidebar-project-item.tsx
@@ -1,9 +1,10 @@
-import { ChevronDown, ChevronRight, Folder, RefreshCw } from "lucide-react";
+import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { isHandledSshPromptError } from "@/lib/api";
import { BusyIcon } from "@/lib/components/atoms/busy-icon";
-import { Button } from "@/lib/components/atoms/button";
+import { ProjectAvatar } from "@/lib/components/atoms/project-avatar";
+import { Button } from "@/lib/components/ui/button";
import { Skeleton } from "@/lib/components/atoms/skeleton";
import { Surface } from "@/lib/components/atoms/surface";
import { SidebarBranchItem } from "@/lib/components/templates/sidebar/sidebar-branch-item";
@@ -17,7 +18,7 @@ import { getWorktreeOwnerKey } from "@/lib/hooks/store/use-worktree-terminal-sto
import { shortenPath } from "@/lib/format";
import { cn } from "@/lib/utils/cn";
import { getErrorMessage } from "@/lib/utils/get-error-message";
-import type { ProjectConfig } from "@/types/pr-run";
+import type { BranchInfo, ProjectConfig } from "@/types/pr-run";
type SidebarProjectItemProps = {
busyOwnerKeys: Set;
@@ -28,12 +29,13 @@ type SidebarProjectItemProps = {
pendingWorktreeCheckoutKey?: string;
pendingWorktreeRemovalKey?: string;
project: ProjectConfig;
+ projectAvatarUri?: string;
selectedBranchName?: string;
onCheckoutBranch: (projectId: string, branchName: string) => Promise;
onRemoveWorktree: (projectId: string, branchName: string) => Promise;
- onSelectBranch: (projectId: string, branchName: string) => void;
+ onSelectBranch: (project: ProjectConfig, branch: BranchInfo) => void;
onToggleProject: (projectId: string) => void;
- onUpdateProject: (project: ProjectConfig) => Promise;
+ onUpdateProject: (project: ProjectConfig) => Promise;
};
const INITIAL_VISIBLE_BRANCH_COUNT = 5;
@@ -47,6 +49,7 @@ export function SidebarProjectItem({
pendingWorktreeCheckoutKey,
pendingWorktreeRemovalKey,
project,
+ projectAvatarUri,
selectedBranchName,
onCheckoutBranch,
onRemoveWorktree,
@@ -98,121 +101,146 @@ export function SidebarProjectItem({
useSshPassphraseStore
.getState()
- .setRetryAction(() =>
+ .setRetryAction(`sidebar:${project.id}:branches`, () =>
branchesQuery.refetch().then(() => undefined),
);
+ return () =>
+ useSshPassphraseStore
+ .getState()
+ .setRetryAction(`sidebar:${project.id}:branches`, null);
}, [branchesQuery, isAwaitingSshPassphrase]);
const isActionVisible = isUpdatingProject;
+ const displayedBranches = isExpanded
+ ? visibleBranches
+ : visibleBranches.filter((branch) =>
+ busyOwnerKeys.has(getWorktreeOwnerKey(project.id, branch.name)),
+ );
return (
-
{isExpanded || isBusy ? (
@@ -261,45 +289,59 @@ export function SidebarProjectItem({
) : null}
- {(isExpanded
- ? visibleBranches
- : visibleBranches.filter((branch) =>
- busyOwnerKeys.has(
- getWorktreeOwnerKey(project.id, branch.name),
- ),
- )
- ).map((branch) => {
- const isBranchBusy = busyOwnerKeys.has(
- getWorktreeOwnerKey(project.id, branch.name),
- );
+ {displayedBranches.length > 0 ? (
+
+ {displayedBranches.map((branch) => {
+ const isBranchBusy = busyOwnerKeys.has(
+ getWorktreeOwnerKey(
+ project.id,
+ branch.name,
+ ),
+ );
- return (
-
- onCheckoutBranch(project.id, branchName)
- }
- onRemoveWorktree={(branchName) =>
- onRemoveWorktree(project.id, branchName)
- }
- onSelectBranch={(branchName) =>
- onSelectBranch(project.id, branchName)
- }
- />
- );
- })}
+ return (
+
+ onCheckoutBranch(
+ project.id,
+ branchName,
+ )
+ }
+ onRemoveWorktree={(branchName) =>
+ onRemoveWorktree(
+ project.id,
+ branchName,
+ )
+ }
+ onSelectBranch={() =>
+ onSelectBranch(project, branch)
+ }
+ />
+ );
+ })}
+
+ ) : null}
{isExpanded &&
!areAllRecentBranchesVisible &&
@@ -310,7 +352,8 @@ export function SidebarProjectItem({
justify-start rounded-md px-2 text-[11px]"
size="xs"
type="button"
- onPress={() => setAreAllRecentBranchesVisible(true)}
+ variant="ghost"
+ onClick={() => setAreAllRecentBranchesVisible(true)}
>
Show more ({hiddenRecentBranchCount})
@@ -327,7 +370,8 @@ export function SidebarProjectItem({
justify-start rounded-md px-2 text-[11px]"
size="xs"
type="button"
- onPress={() => setAreStaleBranchesVisible(true)}
+ variant="ghost"
+ onClick={() => setAreStaleBranchesVisible(true)}
>
Show stale ({staleBranches.length})
diff --git a/src/lib/components/templates/sidebar/sidebar-section-header.tsx b/src/lib/components/templates/sidebar/sidebar-section-header.tsx
index 855d0f9..6f1c8f5 100644
--- a/src/lib/components/templates/sidebar/sidebar-section-header.tsx
+++ b/src/lib/components/templates/sidebar/sidebar-section-header.tsx
@@ -1,39 +1,39 @@
+import { Plus } from "lucide-react";
import type { ReactNode } from "react";
-import { ChevronDown, ChevronRight } from "lucide-react";
+
+import { Button } from "@/lib/components/ui/button";
type SidebarSectionHeaderProps = {
children: ReactNode;
count: number;
- isExpanded: boolean;
- onToggle: () => void;
+ onCreateProject?: () => void;
};
export function SidebarSectionHeader({
children,
count,
- isExpanded,
- onToggle,
+ onCreateProject,
}: SidebarSectionHeaderProps) {
return (
-
-
- {isExpanded ? (
-
- ) : (
-
- )}
- {children}
-
- {count}
-
+
{children}
+
+
{count}
+ {onCreateProject ? (
+
+
+
+ ) : null}
+
+
);
}
diff --git a/src/lib/components/templates/sidebar/sidebar-shell.tsx b/src/lib/components/templates/sidebar/sidebar-shell.tsx
index 9300e13..171dc55 100644
--- a/src/lib/components/templates/sidebar/sidebar-shell.tsx
+++ b/src/lib/components/templates/sidebar/sidebar-shell.tsx
@@ -1,16 +1,32 @@
import type { ReactNode } from "react";
+import { cn } from "@/lib/utils/cn";
+
type SidebarShellProps = {
children: ReactNode;
+ isDesktopHidden?: boolean;
+ isMobileOpen?: boolean;
sidebarWidth: number;
};
-export function SidebarShell({ children, sidebarWidth }: SidebarShellProps) {
+export function SidebarShell({
+ children,
+ isDesktopHidden = false,
+ isMobileOpen = false,
+ sidebarWidth,
+}: SidebarShellProps) {
return (