- void editScript(script)
- }
+ onPress={() => {
+ editScript(script);
+ }}
>
@@ -227,9 +256,9 @@ export function BranchScriptsSection({
isDisabled={deleteScriptMutation.isPending}
type="button"
variant="danger"
- onPress={() =>
- void deleteScript(scriptPendingDelete)
- }
+ onPress={() => {
+ deleteScript(scriptPendingDelete);
+ }}
>
{deleteScriptMutation.isPending
diff --git a/src/lib/components/templates/global-terminal-panel.tsx b/src/lib/components/templates/global-terminal-panel.tsx
new file mode 100644
index 0000000..7949bfa
--- /dev/null
+++ b/src/lib/components/templates/global-terminal-panel.tsx
@@ -0,0 +1,399 @@
+import { ChevronDown, ChevronRight, Terminal, X } from "lucide-react";
+import type { PointerEvent as ReactPointerEvent } from "react";
+import { useEffect, useMemo, useState } from "react";
+
+import { BusyIcon } from "@/lib/components/atoms/busy-icon";
+import { Button } from "@/lib/components/atoms/button";
+import { EmptyState } from "@/lib/components/atoms/empty-state";
+import { Surface } from "@/lib/components/atoms/surface";
+import { TerminalPane } from "@/lib/components/molecules/worktree-terminal/terminal-pane";
+import { useWorktreeTerminalStore } from "@/lib/hooks/store/use-worktree-terminal-store";
+import { cn } from "@/lib/utils/cn";
+import type { ProjectGroup } from "@/types/pr-run";
+
+type GlobalTerminalPanelProps = {
+ groups: ProjectGroup[];
+ height: number;
+ isOpen: boolean;
+ sidebarWidth: number;
+ selectedTerminalKey: string | null;
+ onBeginSidebarResize: (event: ReactPointerEvent
) => void;
+ onBeginResize: (event: ReactPointerEvent) => void;
+ onClose: () => void;
+ onSelectTerminal: (terminalKey: string) => void;
+};
+
+type TerminalTreeGroup = {
+ branchName: string;
+ id: string;
+ isBusy: boolean;
+ ownerKey: string;
+ projectId: string;
+ title: string;
+ terminals: TerminalTreeTab[];
+};
+
+type TerminalTreeTab = {
+ branchName: string;
+ busyState: "idle" | "busy" | "unknown";
+ id: string;
+ isAlive: boolean;
+ label: string;
+ ownerKey: string;
+ projectId: string;
+ sessionId: string;
+ terminalKey: string;
+};
+
+export function GlobalTerminalPanel({
+ groups,
+ height,
+ isOpen,
+ sidebarWidth,
+ selectedTerminalKey,
+ onBeginSidebarResize,
+ onBeginResize,
+ onClose,
+ onSelectTerminal,
+}: GlobalTerminalPanelProps) {
+ const owners = useWorktreeTerminalStore((state) => state.owners);
+ const setActiveTab = useWorktreeTerminalStore(
+ (state) => state.setActiveTab,
+ );
+ const tree = useMemo(
+ () => buildTerminalTree(groups, owners),
+ [groups, owners],
+ );
+ const terminals = useMemo(() => flattenTerminalTree(tree), [tree]);
+ const selectedTerminal =
+ terminals.find(
+ (terminal) => terminal.terminalKey === selectedTerminalKey,
+ ) ??
+ terminals[0] ??
+ null;
+ const [expandedGroupIds, setExpandedGroupIds] = useState>(
+ () => new Set(),
+ );
+
+ useEffect(() => {
+ if (!isOpen || !selectedTerminal) {
+ return;
+ }
+
+ setExpandedGroupIds((current) => {
+ if (current.has(selectedTerminal.ownerKey)) {
+ return current;
+ }
+
+ return new Set([...current, selectedTerminal.ownerKey]);
+ });
+ }, [isOpen, selectedTerminal]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ function selectTerminal(terminal: TerminalTreeTab) {
+ setActiveTab(terminal.ownerKey, terminal.id);
+ onSelectTerminal(terminal.terminalKey);
+ }
+
+ return (
+
+
+
+
+
+ {selectedTerminal ? (
+
+ ) : (
+
+ }
+ title="No terminals"
+ />
+
+ )}
+
+
+
+
+
+
+ {tree.length === 0 ? (
+
+ No terminals.
+
+ ) : null}
+
+ {tree.map((group) => {
+ const isGroupExpanded = expandedGroupIds.has(
+ group.id,
+ );
+
+ return (
+
+
+ setExpandedGroupIds((current) =>
+ toggleSetValue(
+ current,
+ group.id,
+ ),
+ )
+ }
+ >
+ {isGroupExpanded ? (
+
+ ) : (
+
+ )}
+
+ {group.title}
+
+ {group.isBusy ? (
+
+ ) : null}
+
+ {group.terminals.length}
+
+
+
+ {isGroupExpanded
+ ? group.terminals.map((tab) => (
+
+ selectTerminal(tab)
+ }
+ >
+
+
+ {tab.label}
+
+ {tab.busyState === "busy" ? (
+
+ ) : null}
+
+ ))
+ : null}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function buildTerminalTree(
+ groups: ProjectGroup[],
+ owners: ReturnType["owners"],
+) {
+ const projects = groups.flatMap((group) => group.projects);
+ const projectNameById = new Map(
+ projects.map((project) => [project.id, project.name]),
+ );
+ const projectOrder = new Map(
+ projects.map((project, index) => [project.id, index]),
+ );
+ const groupMap = new Map();
+
+ for (const [ownerKey, owner] of Object.entries(owners)) {
+ if (owner.tabs.length === 0) {
+ continue;
+ }
+
+ const { branchName, projectId } = parseOwnerKey(ownerKey);
+ const projectName = projectNameById.get(projectId) ?? projectId;
+ const group = ensureTerminalGroup(
+ groupMap,
+ ownerKey,
+ branchName,
+ projectId,
+ `${projectName} - ${branchName}`,
+ );
+
+ group.terminals.push(
+ ...owner.tabs.map((tab) => ({
+ branchName,
+ busyState: tab.busyState,
+ id: tab.id,
+ isAlive: tab.status === "alive",
+ label: tab.label,
+ ownerKey,
+ projectId,
+ sessionId: tab.sessionId,
+ terminalKey: getTerminalKey(ownerKey, tab.id),
+ })),
+ );
+ group.isBusy = group.terminals.some(
+ (terminal) => terminal.isAlive && terminal.busyState === "busy",
+ );
+ }
+
+ return [...groupMap.values()].sort(
+ (left, right) =>
+ (projectOrder.get(left.projectId) ?? Number.MAX_SAFE_INTEGER) -
+ (projectOrder.get(right.projectId) ??
+ Number.MAX_SAFE_INTEGER) ||
+ left.branchName.localeCompare(right.branchName),
+ );
+}
+
+function ensureTerminalGroup(
+ groupMap: Map,
+ ownerKey: string,
+ branchName: string,
+ projectId: string,
+ title: string,
+) {
+ const existing = groupMap.get(ownerKey);
+
+ if (existing) {
+ return existing;
+ }
+
+ const group: TerminalTreeGroup = {
+ branchName,
+ id: ownerKey,
+ isBusy: false,
+ ownerKey,
+ projectId,
+ title,
+ terminals: [],
+ };
+
+ groupMap.set(ownerKey, group);
+ return group;
+}
+
+function flattenTerminalTree(tree: TerminalTreeGroup[]) {
+ return tree.flatMap((group) => group.terminals);
+}
+
+export function getTerminalKey(ownerKey: string, tabId: string) {
+ return `${ownerKey}::${tabId}`;
+}
+
+function parseOwnerKey(ownerKey: string) {
+ const separatorIndex = ownerKey.indexOf(":");
+
+ if (separatorIndex === -1) {
+ return {
+ branchName: ownerKey,
+ projectId: ownerKey,
+ };
+ }
+
+ return {
+ branchName: ownerKey.slice(separatorIndex + 1),
+ projectId: ownerKey.slice(0, separatorIndex),
+ };
+}
+
+function toggleSetValue(set: Set, value: string) {
+ const next = new Set(set);
+
+ if (next.has(value)) {
+ next.delete(value);
+ } else {
+ next.add(value);
+ }
+
+ return next;
+}
diff --git a/src/lib/components/templates/main-panel/branch-empty-state.tsx b/src/lib/components/templates/main-panel/branch-empty-state.tsx
index 5de3df8..1658070 100644
--- a/src/lib/components/templates/main-panel/branch-empty-state.tsx
+++ b/src/lib/components/templates/main-panel/branch-empty-state.tsx
@@ -5,7 +5,7 @@ import { EmptyState } from "@/lib/components/atoms/empty-state";
export function BranchEmptyState() {
return (
void;
};
@@ -17,6 +19,7 @@ const tabs: { label: string; value: BranchPageTab }[] = [
export function BranchPageTabs({
activeTab,
+ isRunTabBusy,
onSelectTab,
}: BranchPageTabsProps) {
return (
@@ -32,12 +35,16 @@ export function BranchPageTabs({
>
onSelectTab(tab.value)}
>
+ {tab.value === "run" && isRunTabBusy ? (
+
+ ) : null}
{tab.label}
diff --git a/src/lib/components/templates/main-panel/index.tsx b/src/lib/components/templates/main-panel/index.tsx
index 499989c..da5c7ba 100644
--- a/src/lib/components/templates/main-panel/index.tsx
+++ b/src/lib/components/templates/main-panel/index.tsx
@@ -75,6 +75,9 @@ export function MainPanel({
const [activeTab, setActiveTab] = useState("general");
const selectedKey =
project && branchName ? `${project.id}:${branchName}` : "";
+ const selectedTerminalOwner = useWorktreeTerminalStore((state) =>
+ selectedKey ? state.owners[selectedKey] : undefined,
+ );
const branchesQuery = useProjectBranchesQuery(
project?.id,
Boolean(project),
@@ -98,6 +101,11 @@ export function MainPanel({
const isAwaitingCommitPassphrase = isHandledSshPromptError(
commitsQuery.error,
);
+ const isRunTabBusy = Boolean(
+ selectedTerminalOwner?.tabs.some(
+ (tab) => tab.status === "alive" && tab.busyState === "busy",
+ ),
+ );
useEffect(() => {
setActiveTab("general");
@@ -175,7 +183,7 @@ export function MainPanel({
return (
store.owners);
+ const [isTerminalPanelOpen, setIsTerminalPanelOpen] = useState(false);
+ const [terminalPanelHeight, setTerminalPanelHeight] = useState(
+ TERMINAL_PANEL_DEFAULT_HEIGHT,
+ );
+ const [terminalPanelSidebarWidth, setTerminalPanelSidebarWidth] = useState(
+ TERMINAL_PANEL_SIDEBAR_DEFAULT_WIDTH,
+ );
+ const [selectedGlobalTerminalKey, setSelectedGlobalTerminalKey] = useState<
+ string | null
+ >(null);
+ const preferredGlobalTerminalKey = useMemo(
+ () => getPreferredGlobalTerminalKey(terminalOwners),
+ [terminalOwners],
+ );
+
+ function openGlobalTerminalPanel() {
+ setSelectedGlobalTerminalKey(
+ (current) => current ?? preferredGlobalTerminalKey,
+ );
+ setIsTerminalPanelOpen(true);
+ }
+
+ function beginTerminalPanelResize(
+ event: ReactPointerEvent,
+ startHeightOverride?: number,
+ ) {
+ event.preventDefault();
+
+ const startY = event.clientY;
+ const startHeight = startHeightOverride ?? terminalPanelHeight;
+ const maxHeight = Math.min(
+ TERMINAL_PANEL_MAX_HEIGHT,
+ Math.floor(window.innerHeight * 0.78),
+ );
+
+ function handlePointerMove(moveEvent: PointerEvent) {
+ setTerminalPanelHeight(
+ clamp(
+ startHeight + startY - moveEvent.clientY,
+ TERMINAL_PANEL_MIN_HEIGHT,
+ maxHeight,
+ ),
+ );
+ }
+
+ function handlePointerUp() {
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("pointerup", handlePointerUp);
+ window.removeEventListener("pointercancel", handlePointerUp);
+ }
+
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("pointerup", handlePointerUp);
+ window.addEventListener("pointercancel", handlePointerUp);
+ }
+
+ function openAndBeginTerminalPanelResize(
+ event: ReactPointerEvent,
+ ) {
+ openGlobalTerminalPanel();
+ beginTerminalPanelResize(
+ event,
+ isTerminalPanelOpen
+ ? terminalPanelHeight
+ : TERMINAL_PANEL_MIN_HEIGHT,
+ );
+ }
+
+ function beginTerminalPanelSidebarResize(
+ event: ReactPointerEvent,
+ ) {
+ event.preventDefault();
+
+ const startX = event.clientX;
+ const startWidth = terminalPanelSidebarWidth;
+
+ function handlePointerMove(moveEvent: PointerEvent) {
+ setTerminalPanelSidebarWidth(
+ clamp(
+ startWidth + moveEvent.clientX - startX,
+ TERMINAL_PANEL_SIDEBAR_MIN_WIDTH,
+ TERMINAL_PANEL_SIDEBAR_MAX_WIDTH,
+ ),
+ );
+ }
+
+ function handlePointerUp() {
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("pointerup", handlePointerUp);
+ window.removeEventListener("pointercancel", handlePointerUp);
+ }
+
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("pointerup", handlePointerUp);
+ window.addEventListener("pointercancel", handlePointerUp);
+ }
if (state.configError) {
return (
@@ -55,11 +167,14 @@ export function PrRunApp() {
variant="plain"
>
-
+
+
+ setIsTerminalPanelOpen(false)}
+ onSelectTerminal={setSelectedGlobalTerminalKey}
+ />
+
+
);
}
+
+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 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/use-app-status-summary.ts b/src/lib/components/templates/pr-run-app/use-app-status-summary.ts
new file mode 100644
index 0000000..8f7b2b0
--- /dev/null
+++ b/src/lib/components/templates/pr-run-app/use-app-status-summary.ts
@@ -0,0 +1,72 @@
+import { useQueries } from "@tanstack/react-query";
+import { useMemo } from "react";
+
+import { projectBranchesQueryOptions } from "@/lib/hooks/query/use-project-branches-query";
+import {
+ getBusyTerminalSummary,
+ useWorktreeTerminalStore,
+} from "@/lib/hooks/store/use-worktree-terminal-store";
+import type { ProjectConfig } from "@/types/pr-run";
+
+export type AppStatusSummary = {
+ branchCount: number;
+ busyOwnerKeys: Set;
+ busyProjectIds: Set;
+ busyTerminalCount: number;
+ isLoadingBranchCounts: boolean;
+ openPullRequestCount: number;
+ staleWorktreeCount: number;
+ worktreeCount: number;
+};
+
+export function useAppStatusSummary(
+ projects: ProjectConfig[],
+): AppStatusSummary {
+ const owners = useWorktreeTerminalStore((state) => state.owners);
+ const branchQueries = useQueries({
+ queries: projects.map((project) => ({
+ ...projectBranchesQueryOptions(project.id),
+ enabled: projects.length > 0,
+ })),
+ });
+
+ const busySummary = useMemo(() => getBusyTerminalSummary(owners), [owners]);
+
+ return useMemo(() => {
+ let branchCount = 0;
+ let openPullRequestCount = 0;
+ let staleWorktreeCount = 0;
+ let worktreeCount = 0;
+
+ for (const query of branchQueries) {
+ for (const branch of query.data ?? []) {
+ if (branch.source === "branch") {
+ branchCount += 1;
+ }
+
+ if (branch.source === "pull-request") {
+ openPullRequestCount += 1;
+ }
+
+ if (branch.hasWorktree) {
+ worktreeCount += 1;
+ }
+
+ if (branch.hasWorktree && branch.isStale) {
+ staleWorktreeCount += 1;
+ }
+ }
+ }
+
+ return {
+ ...busySummary,
+ branchCount,
+ isLoadingBranchCounts: branchQueries.some(
+ (query) => query.isPending,
+ ),
+ openPullRequestCount,
+ staleWorktreeCount,
+ worktreeCount,
+ };
+ }, [branchQueries, busySummary]);
+}
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 975ef11..8215816 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
@@ -22,6 +22,7 @@ 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";
const SIDEBAR_WIDTH_STORAGE_KEY = "pr-run.sidebar.width";
const SIDEBAR_MIN_WIDTH = 256;
@@ -65,6 +66,7 @@ export function usePrRunAppState() {
[groups],
);
usePreloadProjects(projects);
+ const statusSummary = useAppStatusSummary(projects);
const selectedProject = useMemo(
() =>
projects.find(
@@ -306,11 +308,15 @@ export function usePrRunAppState() {
pendingProjectUpdateId: updateProjectWorktreesMutation.isPending
? updateProjectWorktreesMutation.variables
: undefined,
+ pendingWorktreeCheckoutKey: checkoutBranchMutation.isPending
+ ? `${checkoutBranchMutation.variables?.projectId}:${checkoutBranchMutation.variables?.branchName}`
+ : undefined,
pendingWorktreeRemovalKey: removeWorktreeMutation.isPending
? `${removeWorktreeMutation.variables?.projectId}:${removeWorktreeMutation.variables?.branchName}`
: undefined,
selectedBranchView,
sidebarWidth,
+ statusSummary,
theme,
beginResize: () => setIsResizingSidebar(true),
closeAddProject: () => setIsAddProjectOpen(false),
diff --git a/src/lib/components/templates/sidebar/index.tsx b/src/lib/components/templates/sidebar/index.tsx
index f9d9c51..4e68f88 100644
--- a/src/lib/components/templates/sidebar/index.tsx
+++ b/src/lib/components/templates/sidebar/index.tsx
@@ -6,11 +6,14 @@ import { SidebarShell } from "@/lib/components/templates/sidebar/sidebar-shell";
import type { SidebarProps } from "@/lib/components/templates/sidebar/types";
export function Sidebar({
+ busyOwnerKeys,
+ busyProjectIds,
collapsedProjects,
expandedGroups,
groups,
isCreatingScript,
pendingProjectUpdateId,
+ pendingWorktreeCheckoutKey,
pendingWorktreeRemovalKey,
selectedBranchName,
selectedProjectId,
@@ -18,6 +21,7 @@ export function Sidebar({
theme,
onAddProject,
onBeginResize,
+ onCheckoutBranch,
onCreateScript,
onOpenSshPassphrase,
onRemoveWorktree,
@@ -41,14 +45,18 @@ export function Sidebar({
{groups.map((group) => (
Promise;
onRemoveWorktree: (branchName: string) => Promise;
onSelectBranch: (branchName: string) => void;
};
export function SidebarBranchItem({
branch,
+ isBusy,
+ isCollapsedPreview = false,
+ isCheckingOutWorktree,
isRemovingWorktree,
isSelected,
+ onCheckoutBranch,
onRemoveWorktree,
onSelectBranch,
}: SidebarBranchItemProps) {
- const status = getBranchStatus(branch);
+ const status = getSidebarItemStatus(branch);
+ const isActionPending = isCheckingOutWorktree || isRemovingWorktree;
return (
onSelectBranch(branch.name)}
>
-
+
+
+ {isBusy ? (
+
+ ) : null}
+
- {status.label}
+
+ {status.label}
+
{formatBranchAge(branch.lastCommitTimestamp)}
- {branch.hasWorktree ? (
-
+
+ {branch.hasWorktree ? (
)}
-
- ) : null}
+ ) : (
+
{
+ onCheckoutBranch(branch.name);
+ }}
+ >
+ {isCheckingOutWorktree ? (
+
+ ) : (
+
+ )}
+
+ )}
+
);
}
-
-function getBranchStatus(branch: BranchInfo): {
- label: string;
- tone: "branch" | "pull-request" | "stale" | "worktree";
-} {
- if (branch.hasWorktree) {
- return { label: "Worktree", tone: "worktree" };
- }
-
- if (branch.isStale) {
- return { label: "Stale", tone: "stale" };
- }
-
- if (branch.source === "pull-request") {
- return { label: "PR", tone: "pull-request" };
- }
-
- return { label: "Branch", tone: "branch" };
-}
diff --git a/src/lib/components/templates/sidebar/sidebar-group-section.tsx b/src/lib/components/templates/sidebar/sidebar-group-section.tsx
index 4b84b89..367b0e6 100644
--- a/src/lib/components/templates/sidebar/sidebar-group-section.tsx
+++ b/src/lib/components/templates/sidebar/sidebar-group-section.tsx
@@ -1,16 +1,21 @@
import { SidebarEmptyState } from "@/lib/components/templates/sidebar/sidebar-empty-state";
import { SidebarProjectItem } from "@/lib/components/templates/sidebar/sidebar-project-item";
+import { sortProjectsByBusyState } from "@/lib/components/templates/sidebar/sidebar-sort";
import { SidebarSectionHeader } from "@/lib/components/templates/sidebar/sidebar-section-header";
import type { SidebarProps } from "@/lib/components/templates/sidebar/types";
import type { ProjectGroup } from "@/types/pr-run";
type SidebarGroupSectionProps = Pick<
SidebarProps,
+ | "busyOwnerKeys"
+ | "busyProjectIds"
| "collapsedProjects"
| "pendingProjectUpdateId"
+ | "pendingWorktreeCheckoutKey"
| "pendingWorktreeRemovalKey"
| "selectedBranchName"
| "selectedProjectId"
+ | "onCheckoutBranch"
| "onRemoveWorktree"
| "onSelectBranch"
| "onToggleGroup"
@@ -22,19 +27,28 @@ type SidebarGroupSectionProps = Pick<
};
export function SidebarGroupSection({
+ busyOwnerKeys,
+ busyProjectIds,
collapsedProjects,
group,
isExpanded,
pendingProjectUpdateId,
+ pendingWorktreeCheckoutKey,
pendingWorktreeRemovalKey,
selectedBranchName,
selectedProjectId,
+ onCheckoutBranch,
onRemoveWorktree,
onSelectBranch,
onToggleGroup,
onToggleProject,
onUpdateProject,
}: SidebarGroupSectionProps) {
+ const sortedProjects = sortProjectsByBusyState(
+ group.projects,
+ busyProjectIds,
+ );
+
return (
) : null}
- {group.projects.map((project) => (
+ {sortedProjects.map((project) => (
{branch.source === "pull-request" ? (
diff --git a/src/lib/components/templates/sidebar/sidebar-item-status.ts b/src/lib/components/templates/sidebar/sidebar-item-status.ts
new file mode 100644
index 0000000..9cee80f
--- /dev/null
+++ b/src/lib/components/templates/sidebar/sidebar-item-status.ts
@@ -0,0 +1,74 @@
+import type { BranchInfo } from "@/types/pr-run";
+
+export type SidebarItemStatus =
+ | "stale-worktree"
+ | "worktree"
+ | "stale"
+ | "pull-request"
+ | "branch";
+
+type SidebarItemStatusConfig = {
+ iconClassName: string;
+ label: string;
+ pillClassName: string;
+ status: SidebarItemStatus;
+};
+
+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",
+ },
+ worktree: {
+ iconClassName: "bg-success/15 text-success",
+ label: "Worktree",
+ pillClassName:
+ "border-success/25 bg-success/10 text-success-foreground",
+ status: "worktree",
+ },
+ stale: {
+ iconClassName: "bg-warning/15 text-warning-foreground",
+ 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",
+ label: "Branch",
+ pillClassName: "border-border bg-muted/35 text-muted-foreground",
+ status: "branch",
+ },
+};
+
+export function getSidebarItemStatus(branch: BranchInfo) {
+ if (branch.hasWorktree && branch.isStale) {
+ return sidebarItemStatusConfigs["stale-worktree"];
+ }
+
+ if (branch.hasWorktree) {
+ return sidebarItemStatusConfigs.worktree;
+ }
+
+ if (branch.isStale) {
+ return sidebarItemStatusConfigs.stale;
+ }
+
+ if (branch.source === "pull-request") {
+ return sidebarItemStatusConfigs["pull-request"];
+ }
+
+ return sidebarItemStatusConfigs.branch;
+}
diff --git a/src/lib/components/templates/sidebar/sidebar-project-item.tsx b/src/lib/components/templates/sidebar/sidebar-project-item.tsx
index 20abd8f..4bba268 100644
--- a/src/lib/components/templates/sidebar/sidebar-project-item.tsx
+++ b/src/lib/components/templates/sidebar/sidebar-project-item.tsx
@@ -2,24 +2,34 @@ import { ChevronDown, ChevronRight, Folder, 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 { Skeleton } from "@/lib/components/atoms/skeleton";
import { Surface } from "@/lib/components/atoms/surface";
import { SidebarBranchItem } from "@/lib/components/templates/sidebar/sidebar-branch-item";
+import {
+ getVisibleSidebarBranches,
+ sortBranchesByLastCommit,
+} from "@/lib/components/templates/sidebar/sidebar-sort";
import { useProjectBranchesQuery } from "@/lib/hooks/query/use-project-branches-query";
import { useSshPassphraseStore } from "@/lib/hooks/store/use-ssh-passphrase-store";
+import { getWorktreeOwnerKey } from "@/lib/hooks/store/use-worktree-terminal-store";
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";
type SidebarProjectItemProps = {
+ busyOwnerKeys: Set;
isExpanded: boolean;
+ isBusy: boolean;
isSelected: boolean;
isUpdatingProject: boolean;
+ pendingWorktreeCheckoutKey?: string;
pendingWorktreeRemovalKey?: string;
project: ProjectConfig;
selectedBranchName?: string;
+ onCheckoutBranch: (projectId: string, branchName: string) => Promise;
onRemoveWorktree: (projectId: string, branchName: string) => Promise;
onSelectBranch: (projectId: string, branchName: string) => void;
onToggleProject: (projectId: string) => void;
@@ -29,12 +39,16 @@ type SidebarProjectItemProps = {
const INITIAL_VISIBLE_BRANCH_COUNT = 5;
export function SidebarProjectItem({
+ busyOwnerKeys,
isExpanded,
+ isBusy,
isSelected,
isUpdatingProject,
+ pendingWorktreeCheckoutKey,
pendingWorktreeRemovalKey,
project,
selectedBranchName,
+ onCheckoutBranch,
onRemoveWorktree,
onSelectBranch,
onToggleProject,
@@ -44,7 +58,10 @@ export function SidebarProjectItem({
useState(false);
const [areStaleBranchesVisible, setAreStaleBranchesVisible] =
useState(false);
- const branchesQuery = useProjectBranchesQuery(project.id, isExpanded);
+ const branchesQuery = useProjectBranchesQuery(
+ project.id,
+ isExpanded || isBusy,
+ );
const isAwaitingSshPassphrase = isHandledSshPromptError(
branchesQuery.error,
);
@@ -52,26 +69,27 @@ export function SidebarProjectItem({
? getErrorMessage(branchesQuery.error)
: undefined;
const sortedBranches = useMemo(
- () =>
- [...(branchesQuery.data ?? [])].sort(
- (left, right) =>
- (right.lastCommitTimestamp ?? 0) -
- (left.lastCommitTimestamp ?? 0),
- ),
+ () => sortBranchesByLastCommit(branchesQuery.data ?? []),
[branchesQuery.data],
);
- const recentBranches = sortedBranches.filter((branch) => !branch.isStale);
- const staleBranches = sortedBranches.filter((branch) => branch.isStale);
- const hiddenRecentBranchCount = Math.max(
- recentBranches.length - INITIAL_VISIBLE_BRANCH_COUNT,
- 0,
+ const { hiddenRecentBranchCount, staleBranches, visibleBranches } = useMemo(
+ () =>
+ getVisibleSidebarBranches({
+ areAllRecentBranchesVisible,
+ areStaleBranchesVisible,
+ branches: sortedBranches,
+ busyOwnerKeys,
+ initialVisibleBranchCount: INITIAL_VISIBLE_BRANCH_COUNT,
+ projectId: project.id,
+ }),
+ [
+ areAllRecentBranchesVisible,
+ areStaleBranchesVisible,
+ busyOwnerKeys,
+ project.id,
+ sortedBranches,
+ ],
);
- const visibleBranches = [
- ...(areAllRecentBranchesVisible
- ? recentBranches
- : recentBranches.slice(0, INITIAL_VISIBLE_BRANCH_COUNT)),
- ...(areStaleBranchesVisible ? staleBranches : []),
- ];
useEffect(() => {
if (!isAwaitingSshPassphrase) {
@@ -84,6 +102,7 @@ export function SidebarProjectItem({
branchesQuery.refetch().then(() => undefined),
);
}, [branchesQuery, isAwaitingSshPassphrase]);
+ const isActionVisible = isUpdatingProject;
return (
@@ -119,10 +138,19 @@ export function SidebarProjectItem({
shrink-0"
/>
)}
-
+
+
+ {isBusy ? (
+
+ ) : null}
+
{shortenPath(project.path)}
- {isExpanded ? (
+ {isExpanded || isBusy ? (
- {branchesQuery.isPending ? (
+ {branchesQuery.isPending && isExpanded ? (
@@ -183,7 +224,9 @@ export function SidebarProjectItem({
) : null}
- {!branchesQuery.isPending && isAwaitingSshPassphrase ? (
+ {!branchesQuery.isPending &&
+ isExpanded &&
+ isAwaitingSshPassphrase ? (
) : null}
- {(areAllRecentBranchesVisible ||
+ {isExpanded &&
+ (areAllRecentBranchesVisible ||
hiddenRecentBranchCount === 0) &&
!areStaleBranchesVisible &&
staleBranches.length > 0 ? (
diff --git a/src/lib/components/templates/sidebar/sidebar-shell.tsx b/src/lib/components/templates/sidebar/sidebar-shell.tsx
index 86c47d3..9300e13 100644
--- a/src/lib/components/templates/sidebar/sidebar-shell.tsx
+++ b/src/lib/components/templates/sidebar/sidebar-shell.tsx
@@ -9,7 +9,7 @@ export function SidebarShell({ children, sidebarWidth }: SidebarShellProps) {
return (
diff --git a/src/lib/components/templates/sidebar/sidebar-sort.test.ts b/src/lib/components/templates/sidebar/sidebar-sort.test.ts
new file mode 100644
index 0000000..650bd99
--- /dev/null
+++ b/src/lib/components/templates/sidebar/sidebar-sort.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from "bun:test";
+
+import type { BranchInfo, ProjectConfig } from "@/types/pr-run";
+
+import {
+ getVisibleSidebarBranches,
+ sortBranchesByLastCommit,
+ sortProjectsByBusyState,
+} from "./sidebar-sort";
+
+describe("sortProjectsByBusyState", () => {
+ it("promotes busy projects without changing relative order", () => {
+ const projects = [
+ createProject("project-a"),
+ createProject("project-b"),
+ createProject("project-c"),
+ createProject("project-d"),
+ ];
+
+ expect(
+ sortProjectsByBusyState(
+ projects,
+ new Set(["project-b", "project-d"]),
+ ).map((project) => project.id),
+ ).toEqual(["project-b", "project-d", "project-a", "project-c"]);
+ });
+});
+
+describe("getVisibleSidebarBranches", () => {
+ it("promotes busy branches before recent branches", () => {
+ const branches = sortBranchesByLastCommit([
+ createBranch("recent", 300),
+ createBranch("busy", 100),
+ createBranch("older", 200),
+ ]);
+
+ const result = getVisibleSidebarBranches({
+ areAllRecentBranchesVisible: false,
+ areStaleBranchesVisible: false,
+ branches,
+ busyOwnerKeys: new Set(["project-one:busy"]),
+ initialVisibleBranchCount: 5,
+ projectId: "project-one",
+ });
+
+ expect(result.visibleBranches.map((branch) => branch.name)).toEqual([
+ "busy",
+ "recent",
+ "older",
+ ]);
+ });
+
+ it("shows busy stale branches even when stale branches are collapsed", () => {
+ const branches = sortBranchesByLastCommit([
+ createBranch("recent", 300),
+ createBranch("busy-stale", 100, { isStale: true }),
+ createBranch("stale", 200, { isStale: true }),
+ ]);
+
+ const result = getVisibleSidebarBranches({
+ areAllRecentBranchesVisible: false,
+ areStaleBranchesVisible: false,
+ branches,
+ busyOwnerKeys: new Set(["project-one:busy-stale"]),
+ initialVisibleBranchCount: 5,
+ projectId: "project-one",
+ });
+
+ expect(result.visibleBranches.map((branch) => branch.name)).toEqual([
+ "busy-stale",
+ "recent",
+ ]);
+ expect(result.staleBranches.map((branch) => branch.name)).toEqual([
+ "stale",
+ ]);
+ });
+
+ it("keeps non-busy stale branches hidden until stale branches are expanded", () => {
+ const branches = sortBranchesByLastCommit([
+ createBranch("recent", 300),
+ createBranch("stale", 200, { isStale: true }),
+ ]);
+
+ const collapsedResult = getVisibleSidebarBranches({
+ areAllRecentBranchesVisible: false,
+ areStaleBranchesVisible: false,
+ branches,
+ busyOwnerKeys: new Set(),
+ initialVisibleBranchCount: 5,
+ projectId: "project-one",
+ });
+ const expandedResult = getVisibleSidebarBranches({
+ areAllRecentBranchesVisible: false,
+ areStaleBranchesVisible: true,
+ branches,
+ busyOwnerKeys: new Set(),
+ initialVisibleBranchCount: 5,
+ projectId: "project-one",
+ });
+
+ expect(
+ collapsedResult.visibleBranches.map((branch) => branch.name),
+ ).toEqual(["recent"]);
+ expect(
+ expandedResult.visibleBranches.map((branch) => branch.name),
+ ).toEqual(["recent", "stale"]);
+ });
+});
+
+function createProject(id: string): ProjectConfig {
+ return {
+ id,
+ name: id,
+ path: `/tmp/${id}`,
+ };
+}
+
+function createBranch(
+ name: string,
+ lastCommitTimestamp: number,
+ options: Partial = {},
+): BranchInfo {
+ return {
+ compareBranchName: "main",
+ hasWorktree: true,
+ isStale: false,
+ lastCommitTimestamp,
+ name,
+ remoteName: `origin/${name}`,
+ source: "branch",
+ worktreePath: `/tmp/${name}`,
+ ...options,
+ };
+}
diff --git a/src/lib/components/templates/sidebar/sidebar-sort.ts b/src/lib/components/templates/sidebar/sidebar-sort.ts
new file mode 100644
index 0000000..e76dbf2
--- /dev/null
+++ b/src/lib/components/templates/sidebar/sidebar-sort.ts
@@ -0,0 +1,70 @@
+import { getWorktreeOwnerKey } from "@/lib/hooks/store/use-worktree-terminal-store";
+import type { BranchInfo, ProjectConfig } from "@/types/pr-run";
+
+export function sortProjectsByBusyState(
+ projects: ProjectConfig[],
+ busyProjectIds: Set,
+) {
+ return stableBusyFirst(projects, (project) =>
+ busyProjectIds.has(project.id),
+ );
+}
+
+export function sortBranchesByLastCommit(branches: BranchInfo[]) {
+ return [...branches].sort(
+ (left, right) =>
+ (right.lastCommitTimestamp ?? 0) - (left.lastCommitTimestamp ?? 0),
+ );
+}
+
+export function getVisibleSidebarBranches(params: {
+ areAllRecentBranchesVisible: boolean;
+ areStaleBranchesVisible: boolean;
+ branches: BranchInfo[];
+ busyOwnerKeys: Set;
+ initialVisibleBranchCount: number;
+ projectId: string;
+}) {
+ const busyBranches = params.branches.filter((branch) =>
+ params.busyOwnerKeys.has(
+ getWorktreeOwnerKey(params.projectId, branch.name),
+ ),
+ );
+ const busyBranchNames = new Set(busyBranches.map((branch) => branch.name));
+ const nonBusyBranches = params.branches.filter(
+ (branch) => !busyBranchNames.has(branch.name),
+ );
+ const recentBranches = nonBusyBranches.filter((branch) => !branch.isStale);
+ const staleBranches = nonBusyBranches.filter((branch) => branch.isStale);
+ const visibleRecentBranches = params.areAllRecentBranchesVisible
+ ? recentBranches
+ : recentBranches.slice(0, params.initialVisibleBranchCount);
+
+ return {
+ hiddenRecentBranchCount: Math.max(
+ recentBranches.length - params.initialVisibleBranchCount,
+ 0,
+ ),
+ staleBranches,
+ visibleBranches: [
+ ...busyBranches,
+ ...visibleRecentBranches,
+ ...(params.areStaleBranchesVisible ? staleBranches : []),
+ ],
+ };
+}
+
+function stableBusyFirst(items: T[], isBusy: (item: T) => boolean) {
+ const busyItems: T[] = [];
+ const idleItems: T[] = [];
+
+ for (const item of items) {
+ if (isBusy(item)) {
+ busyItems.push(item);
+ } else {
+ idleItems.push(item);
+ }
+ }
+
+ return [...busyItems, ...idleItems];
+}
diff --git a/src/lib/components/templates/sidebar/types.ts b/src/lib/components/templates/sidebar/types.ts
index 7e289fb..41f079c 100644
--- a/src/lib/components/templates/sidebar/types.ts
+++ b/src/lib/components/templates/sidebar/types.ts
@@ -1,11 +1,14 @@
import type { ProjectConfig, ProjectGroup } from "@/types/pr-run";
export type SidebarProps = {
+ busyOwnerKeys: Set;
+ busyProjectIds: Set;
expandedGroups: Set;
collapsedProjects: Set;
groups: ProjectGroup[];
isCreatingScript: boolean;
pendingProjectUpdateId?: string;
+ pendingWorktreeCheckoutKey?: string;
pendingWorktreeRemovalKey?: string;
selectedBranchName?: string;
selectedProjectId?: string;
@@ -14,6 +17,7 @@ export type SidebarProps = {
onAddProject: () => void;
onBeginResize: () => void;
onCreateScript: () => void;
+ onCheckoutBranch: (projectId: string, branchName: string) => Promise;
onOpenSshPassphrase: () => void;
onRemoveWorktree: (projectId: string, branchName: string) => Promise;
onSelectBranch: (projectId: string, branchName: string) => void;
diff --git a/src/lib/components/templates/status-bar.tsx b/src/lib/components/templates/status-bar.tsx
new file mode 100644
index 0000000..5a33385
--- /dev/null
+++ b/src/lib/components/templates/status-bar.tsx
@@ -0,0 +1,134 @@
+import { GitBranch, GitPullRequest, Terminal } from "lucide-react";
+import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
+
+import type { AppStatusSummary } from "@/lib/components/templates/pr-run-app/use-app-status-summary";
+import { cn } from "@/lib/utils/cn";
+
+type StatusBarProps = {
+ summary: AppStatusSummary;
+ onBeginTerminalPanelResize: (
+ event: ReactPointerEvent,
+ ) => void;
+ onOpenBusyTerminals: () => void;
+};
+
+export function StatusBar({
+ summary,
+ onBeginTerminalPanelResize,
+ onOpenBusyTerminals,
+}: StatusBarProps) {
+ return (
+
+
+
+
+ }
+ label="stale"
+ value={summary.staleWorktreeCount}
+ />
+
+
+
+ }
+ label="busy terminals"
+ value={summary.busyTerminalCount}
+ onPress={onOpenBusyTerminals}
+ />
+
+
+
+ }
+ label="PRs"
+ value={summary.openPullRequestCount}
+ />
+
+
+
+ }
+ label="worktrees"
+ value={summary.worktreeCount}
+ />
+
+
+
+ }
+ label="branches"
+ value={summary.branchCount}
+ />
+
+ );
+}
+
+type StatusBarItemProps = {
+ icon: ReactNode;
+ label: string;
+ onPress?: () => void;
+ value: number;
+};
+
+function StatusBarItem({ icon, label, onPress, value }: StatusBarItemProps) {
+ return (
+ {
+ event.stopPropagation();
+ }}
+ onClick={onPress}
+ >
+ {icon}
+ {value}
+ {label}
+
+ );
+}
+
+function StatusBarIcon({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/lib/hooks/store/use-worktree-terminal-store.test.ts b/src/lib/hooks/store/use-worktree-terminal-store.test.ts
index ee2306b..be7fd46 100644
--- a/src/lib/hooks/store/use-worktree-terminal-store.test.ts
+++ b/src/lib/hooks/store/use-worktree-terminal-store.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "bun:test";
import {
appendWorktreeTerminalTab,
createWorktreeTerminalOwnerState,
+ getBusyTerminalSummary,
removeWorktreeTerminalTab,
resolveScriptExecutionMode,
} from "./use-worktree-terminal-store";
@@ -121,3 +122,68 @@ describe("worktree terminal owner state", () => {
expect(nextOwner.tabs.map((tab) => tab.id)).toEqual(["tab-1", "tab-2"]);
});
});
+
+describe("getBusyTerminalSummary", () => {
+ it("counts alive busy tabs across owners", () => {
+ const summary = getBusyTerminalSummary({
+ "project-one:feature-a": {
+ ...createWorktreeTerminalOwnerState("/tmp/one"),
+ tabs: [
+ createTab("tab-1", "busy", "alive"),
+ createTab("tab-2", "busy", "alive"),
+ ],
+ },
+ "project-two:feature-b": {
+ ...createWorktreeTerminalOwnerState("/tmp/two"),
+ tabs: [createTab("tab-3", "busy", "alive")],
+ },
+ });
+
+ expect(summary.busyTerminalCount).toBe(3);
+ expect([...summary.busyOwnerKeys]).toEqual([
+ "project-one:feature-a",
+ "project-two:feature-b",
+ ]);
+ expect([...summary.busyProjectIds]).toEqual([
+ "project-one",
+ "project-two",
+ ]);
+ });
+
+ it("ignores idle, unknown, and exited tabs", () => {
+ const summary = getBusyTerminalSummary({
+ "project-one:feature-a": {
+ ...createWorktreeTerminalOwnerState("/tmp/one"),
+ tabs: [
+ createTab("tab-1", "idle", "alive"),
+ createTab("tab-2", "unknown", "alive"),
+ createTab("tab-3", "busy", "exited"),
+ ],
+ },
+ "project-two:feature-b": {
+ ...createWorktreeTerminalOwnerState("/tmp/two"),
+ tabs: [createTab("tab-4", "busy", "alive")],
+ },
+ });
+
+ expect(summary.busyTerminalCount).toBe(1);
+ expect([...summary.busyOwnerKeys]).toEqual(["project-two:feature-b"]);
+ expect([...summary.busyProjectIds]).toEqual(["project-two"]);
+ });
+});
+
+function createTab(
+ id: string,
+ busyState: "busy" | "idle" | "unknown",
+ status: "alive" | "exited",
+) {
+ return {
+ busyState,
+ id,
+ label: id,
+ sessionId: id,
+ status,
+ hasManualInput: false,
+ shellName: "zsh",
+ };
+}
diff --git a/src/lib/hooks/store/use-worktree-terminal-store.ts b/src/lib/hooks/store/use-worktree-terminal-store.ts
index e1ccabe..e1fc710 100644
--- a/src/lib/hooks/store/use-worktree-terminal-store.ts
+++ b/src/lib/hooks/store/use-worktree-terminal-store.ts
@@ -34,6 +34,12 @@ export type WorktreeTerminalOwnerState = {
worktreePath: string;
};
+export type BusyTerminalSummary = {
+ busyOwnerKeys: Set;
+ busyProjectIds: Set;
+ busyTerminalCount: number;
+};
+
type CreateTerminalReason =
| { type: "default" | "manual" }
| { type: "script"; scriptTitle: string };
@@ -132,6 +138,39 @@ export function removeWorktreeTerminalTab(
};
}
+export function getBusyTerminalSummary(
+ owners: Record,
+): BusyTerminalSummary {
+ const busyOwnerKeys = new Set();
+ const busyProjectIds = new Set();
+ let busyTerminalCount = 0;
+
+ for (const [ownerKey, owner] of Object.entries(owners)) {
+ const busyTabCount = owner.tabs.filter(
+ (tab) => tab.status === "alive" && tab.busyState === "busy",
+ ).length;
+
+ if (busyTabCount === 0) {
+ continue;
+ }
+
+ busyTerminalCount += busyTabCount;
+ busyOwnerKeys.add(ownerKey);
+
+ const projectId = ownerKey.split(":")[0];
+
+ if (projectId) {
+ busyProjectIds.add(projectId);
+ }
+ }
+
+ return {
+ busyOwnerKeys,
+ busyProjectIds,
+ busyTerminalCount,
+ };
+}
+
export function resolveScriptExecutionMode(params: {
activeTab?: WorktreeTerminalTab;
activeSessionState?: Pick;