From 2b43f5758b293ac44334cbe0330f2c77bb06a0e7 Mon Sep 17 00:00:00 2001 From: felipe-software Date: Tue, 23 Jun 2026 13:32:23 -0300 Subject: [PATCH 1/6] feat: stale worktrees --- .../templates/sidebar/sidebar-branch-item.tsx | 4 ++++ .../components/templates/sidebar/sidebar-item-icon.tsx | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/lib/components/templates/sidebar/sidebar-branch-item.tsx b/src/lib/components/templates/sidebar/sidebar-branch-item.tsx index b500268..3f0c82b 100644 --- a/src/lib/components/templates/sidebar/sidebar-branch-item.tsx +++ b/src/lib/components/templates/sidebar/sidebar-branch-item.tsx @@ -105,6 +105,10 @@ function getBranchStatus(branch: BranchInfo): { label: string; tone: "branch" | "pull-request" | "stale" | "worktree"; } { + if (branch.hasWorktree && branch.isStale) { + return { label: "Stale Worktree", tone: "stale" }; + } + if (branch.hasWorktree) { return { label: "Worktree", tone: "worktree" }; } diff --git a/src/lib/components/templates/sidebar/sidebar-item-icon.tsx b/src/lib/components/templates/sidebar/sidebar-item-icon.tsx index 836006d..addfd18 100644 --- a/src/lib/components/templates/sidebar/sidebar-item-icon.tsx +++ b/src/lib/components/templates/sidebar/sidebar-item-icon.tsx @@ -11,10 +11,12 @@ export function SidebarItemIcon({ branch }: SidebarItemIconProps) { From 1a0ff1edb43b750472084cb32d611fdfda7748d2 Mon Sep 17 00:00:00 2001 From: felipe-software Date: Tue, 23 Jun 2026 13:40:34 -0300 Subject: [PATCH 2/6] style: better stale worktree styling --- docs/SIDEBAR_ITEM.md | 56 ++++++++++++++ src/lib/components/atoms/status-pill.tsx | 2 + .../templates/sidebar/sidebar-branch-item.tsx | 30 ++------ .../templates/sidebar/sidebar-item-icon.tsx | 11 +-- .../templates/sidebar/sidebar-item-status.ts | 74 +++++++++++++++++++ 5 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 docs/SIDEBAR_ITEM.md create mode 100644 src/lib/components/templates/sidebar/sidebar-item-status.ts diff --git a/docs/SIDEBAR_ITEM.md b/docs/SIDEBAR_ITEM.md new file mode 100644 index 0000000..b8a0237 --- /dev/null +++ b/docs/SIDEBAR_ITEM.md @@ -0,0 +1,56 @@ +# Sidebar Item + +Sidebar branch items are split into small components under +`src/lib/components/templates/sidebar`: + +- `sidebar-branch-item.tsx` renders the clickable row, branch name, status pill, + last commit age, and worktree removal action. +- `sidebar-item-icon.tsx` renders the branch or pull request icon. +- `sidebar-item-status.ts` classifies each branch item and stores the sidebar + label, icon color classes, and pill color classes in one place. +- `sidebar-project-item.tsx` groups branch items by project and decides which + stale items are visible. + +## Classification + +`getSidebarItemStatus` classifies a `BranchInfo` using this priority order: + +1. `stale-worktree`: `branch.hasWorktree === true` and + `branch.isStale === true`. + - Label: `Stale Worktree` + - Color: danger/red + - Reason: this branch has a local worktree, but the backing branch is stale, + so it needs the strongest warning. +2. `worktree`: `branch.hasWorktree === true`. + - Label: `Worktree` + - Color: success/green + - Reason: this branch has a local runnable worktree. +3. `stale`: `branch.isStale === true`. + - Label: `Stale` + - Color: warning/yellow + - Reason: this branch is stale but does not have a local worktree. +4. `pull-request`: `branch.source === "pull-request"`. + - Label: `PR` + - Color: blue + - Reason: this item came from an open pull request. +5. `branch`: fallback for every other branch. + - Label: `Branch` + - Color: muted + - Reason: this is a regular branch without a worktree, stale marker, or pull + request source. + +The priority order matters. For example, a pull request that is stale is shown +as `Stale`, and a stale branch with a worktree is shown as `Stale Worktree`. + +## Color Source + +Sidebar item colors should be changed only in `sidebar-item-status.ts`. + +Both `SidebarBranchItem` and `SidebarItemIcon` consume the same status config: + +- `pillClassName` controls the status pill color. +- `iconClassName` controls the icon background and icon color. + +This keeps the icon and status pill visually aligned for combined states such as +`Stale Worktree`. Sidebar pills use the `custom` `StatusPill` tone so the +sidebar status config fully owns the background, border, and text colors. diff --git a/src/lib/components/atoms/status-pill.tsx b/src/lib/components/atoms/status-pill.tsx index 7154bbc..ef3fca5 100644 --- a/src/lib/components/atoms/status-pill.tsx +++ b/src/lib/components/atoms/status-pill.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/utils/cn"; type StatusPillTone = | "branch" | "busy" + | "custom" | "error" | "idle" | "pull-request" @@ -18,6 +19,7 @@ type StatusPillProps = ComponentPropsWithoutRef<"span"> & { const statusPillClassName: Record = { branch: "border-border bg-muted/35 text-muted-foreground", busy: "border-success/25 bg-success/12 text-success-foreground", + custom: "", error: "border-danger/25 bg-danger/12 text-danger-foreground", idle: "border-border bg-muted/25 text-muted-foreground", "pull-request": diff --git a/src/lib/components/templates/sidebar/sidebar-branch-item.tsx b/src/lib/components/templates/sidebar/sidebar-branch-item.tsx index 3f0c82b..9226b9e 100644 --- a/src/lib/components/templates/sidebar/sidebar-branch-item.tsx +++ b/src/lib/components/templates/sidebar/sidebar-branch-item.tsx @@ -4,6 +4,7 @@ import { Button } from "@/lib/components/atoms/button"; import { StatusPill } from "@/lib/components/atoms/status-pill"; import { formatBranchAge } from "@/lib/format"; import { SidebarItemIcon } from "@/lib/components/templates/sidebar/sidebar-item-icon"; +import { getSidebarItemStatus } from "@/lib/components/templates/sidebar/sidebar-item-status"; import { cn } from "@/lib/utils/cn"; import type { BranchInfo } from "@/types/pr-run"; @@ -22,7 +23,7 @@ export function SidebarBranchItem({ onRemoveWorktree, onSelectBranch, }: SidebarBranchItemProps) { - const status = getBranchStatus(branch); + const status = getSidebarItemStatus(branch); return (
- {status.label} + + {status.label} + ); } - -function getBranchStatus(branch: BranchInfo): { - label: string; - tone: "branch" | "pull-request" | "stale" | "worktree"; -} { - if (branch.hasWorktree && branch.isStale) { - return { label: "Stale Worktree", tone: "stale" }; - } - - 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-item-icon.tsx b/src/lib/components/templates/sidebar/sidebar-item-icon.tsx index addfd18..139a315 100644 --- a/src/lib/components/templates/sidebar/sidebar-item-icon.tsx +++ b/src/lib/components/templates/sidebar/sidebar-item-icon.tsx @@ -1,5 +1,6 @@ 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 = { @@ -7,17 +8,13 @@ type SidebarItemIconProps = { }; 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.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; +} From dc7b1d9665bd72e8907b74b1b158838f0f25c826 Mon Sep 17 00:00:00 2001 From: felipe-software Date: Tue, 23 Jun 2026 13:59:50 -0300 Subject: [PATCH 3/6] style: better overlay buttons --- .../templates/branch-scripts-section.tsx | 75 +++++++++++++------ .../components/templates/pr-run-app/index.tsx | 2 + .../pr-run-app/use-pr-run-app-state.ts | 3 + .../components/templates/sidebar/index.tsx | 4 + .../templates/sidebar/sidebar-branch-item.tsx | 64 ++++++++++++---- .../sidebar/sidebar-group-section.tsx | 8 ++ .../sidebar/sidebar-project-item.tsx | 36 +++++++-- src/lib/components/templates/sidebar/types.ts | 2 + 8 files changed, 150 insertions(+), 44 deletions(-) diff --git a/src/lib/components/templates/branch-scripts-section.tsx b/src/lib/components/templates/branch-scripts-section.tsx index 2d2f4d1..2cd6999 100644 --- a/src/lib/components/templates/branch-scripts-section.tsx +++ b/src/lib/components/templates/branch-scripts-section.tsx @@ -12,6 +12,7 @@ import { useOpenScriptMutation } from "@/lib/hooks/query/use-open-script-mutatio 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 { cn } from "@/lib/utils/cn"; import type { ScriptInfo } from "@/types/pr-run"; type BranchScriptsSectionProps = { @@ -127,6 +128,7 @@ export function BranchScriptsSection({ const isDeleting = deleteScriptMutation.isPending && deleteScriptMutation.variables === script.id; + const isActionVisible = isDeleting; return (
void runScript(script)} + onClick={() => { + runScript(script); + }} > - - - {isPreparing - ? "Preparing..." - : script.title} + + + + {isPreparing + ? "Preparing..." + : script.title} + + + +  
@@ -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/pr-run-app/index.tsx b/src/lib/components/templates/pr-run-app/index.tsx index 10c9c59..d86ef19 100644 --- a/src/lib/components/templates/pr-run-app/index.tsx +++ b/src/lib/components/templates/pr-run-app/index.tsx @@ -60,6 +60,7 @@ export function PrRunApp() { groups={state.groups} isCreatingScript={state.isCreatingScript} pendingProjectUpdateId={state.pendingProjectUpdateId} + pendingWorktreeCheckoutKey={state.pendingWorktreeCheckoutKey} pendingWorktreeRemovalKey={state.pendingWorktreeRemovalKey} selectedBranchName={ state.selectedBranchView.branchName ?? undefined @@ -69,6 +70,7 @@ export function PrRunApp() { theme={state.theme} onAddProject={state.openAddProject} onBeginResize={state.beginResize} + onCheckoutBranch={state.checkoutBranch} onCreateScript={state.openCreateScript} onOpenSshPassphrase={state.openSshPassphrase} onRemoveWorktree={state.removeWorktree} 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..7ce08e7 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 @@ -306,6 +306,9 @@ 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, diff --git a/src/lib/components/templates/sidebar/index.tsx b/src/lib/components/templates/sidebar/index.tsx index f9d9c51..f799f27 100644 --- a/src/lib/components/templates/sidebar/index.tsx +++ b/src/lib/components/templates/sidebar/index.tsx @@ -11,6 +11,7 @@ export function Sidebar({ groups, isCreatingScript, pendingProjectUpdateId, + pendingWorktreeCheckoutKey, pendingWorktreeRemovalKey, selectedBranchName, selectedProjectId, @@ -18,6 +19,7 @@ export function Sidebar({ theme, onAddProject, onBeginResize, + onCheckoutBranch, onCreateScript, onOpenSshPassphrase, onRemoveWorktree, @@ -46,9 +48,11 @@ export function Sidebar({ isExpanded={expandedGroups.has(group.id)} key={group.id} pendingProjectUpdateId={pendingProjectUpdateId} + pendingWorktreeCheckoutKey={pendingWorktreeCheckoutKey} pendingWorktreeRemovalKey={pendingWorktreeRemovalKey} selectedBranchName={selectedBranchName} selectedProjectId={selectedProjectId} + onCheckoutBranch={onCheckoutBranch} onRemoveWorktree={onRemoveWorktree} onSelectBranch={onSelectBranch} onToggleGroup={onToggleGroup} diff --git a/src/lib/components/templates/sidebar/sidebar-branch-item.tsx b/src/lib/components/templates/sidebar/sidebar-branch-item.tsx index 9226b9e..5bddc76 100644 --- a/src/lib/components/templates/sidebar/sidebar-branch-item.tsx +++ b/src/lib/components/templates/sidebar/sidebar-branch-item.tsx @@ -1,4 +1,4 @@ -import { RefreshCw, Trash2 } from "lucide-react"; +import { FolderPlus, RefreshCw, Trash2 } from "lucide-react"; import { Button } from "@/lib/components/atoms/button"; import { StatusPill } from "@/lib/components/atoms/status-pill"; @@ -10,20 +10,25 @@ import type { BranchInfo } from "@/types/pr-run"; type SidebarBranchItemProps = { branch: BranchInfo; + isCheckingOutWorktree: boolean; isRemovingWorktree: boolean; isSelected: boolean; + onCheckoutBranch: (branchName: string) => Promise; onRemoveWorktree: (branchName: string) => Promise; onSelectBranch: (branchName: string) => void; }; export function SidebarBranchItem({ branch, + isCheckingOutWorktree, isRemovingWorktree, isSelected, + onCheckoutBranch, onRemoveWorktree, onSelectBranch, }: SidebarBranchItemProps) { const status = getSidebarItemStatus(branch); + const isActionPending = isCheckingOutWorktree || isRemovingWorktree; return (
{formatBranchAge(branch.lastCommitTimestamp)} - {branch.hasWorktree ? ( -
+
+ {branch.hasWorktree ? ( -
- ) : null} + ) : ( + + )} +
); } diff --git a/src/lib/components/templates/sidebar/sidebar-group-section.tsx b/src/lib/components/templates/sidebar/sidebar-group-section.tsx index 4b84b89..8fe716a 100644 --- a/src/lib/components/templates/sidebar/sidebar-group-section.tsx +++ b/src/lib/components/templates/sidebar/sidebar-group-section.tsx @@ -8,9 +8,11 @@ type SidebarGroupSectionProps = Pick< SidebarProps, | "collapsedProjects" | "pendingProjectUpdateId" + | "pendingWorktreeCheckoutKey" | "pendingWorktreeRemovalKey" | "selectedBranchName" | "selectedProjectId" + | "onCheckoutBranch" | "onRemoveWorktree" | "onSelectBranch" | "onToggleGroup" @@ -26,9 +28,11 @@ export function SidebarGroupSection({ group, isExpanded, pendingProjectUpdateId, + pendingWorktreeCheckoutKey, pendingWorktreeRemovalKey, selectedBranchName, selectedProjectId, + onCheckoutBranch, onRemoveWorktree, onSelectBranch, onToggleGroup, @@ -64,12 +68,16 @@ export function SidebarGroupSection({ pendingWorktreeRemovalKey={ pendingWorktreeRemovalKey } + pendingWorktreeCheckoutKey={ + pendingWorktreeCheckoutKey + } project={project} selectedBranchName={ selectedProjectId === project.id ? selectedBranchName : undefined } + onCheckoutBranch={onCheckoutBranch} onRemoveWorktree={onRemoveWorktree} onSelectBranch={onSelectBranch} onToggleProject={onToggleProject} diff --git a/src/lib/components/templates/sidebar/sidebar-project-item.tsx b/src/lib/components/templates/sidebar/sidebar-project-item.tsx index 20abd8f..fc94fbf 100644 --- a/src/lib/components/templates/sidebar/sidebar-project-item.tsx +++ b/src/lib/components/templates/sidebar/sidebar-project-item.tsx @@ -17,9 +17,11 @@ type SidebarProjectItemProps = { isExpanded: 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; @@ -32,9 +34,11 @@ export function SidebarProjectItem({ isExpanded, isSelected, isUpdatingProject, + pendingWorktreeCheckoutKey, pendingWorktreeRemovalKey, project, selectedBranchName, + onCheckoutBranch, onRemoveWorktree, onSelectBranch, onToggleProject, @@ -84,6 +88,7 @@ export function SidebarProjectItem({ branchesQuery.refetch().then(() => undefined), ); }, [branchesQuery, isAwaitingSshPassphrase]); + const isActionVisible = isUpdatingProject; return (
@@ -131,20 +136,30 @@ export function SidebarProjectItem({ {project.name} {shortenPath(project.path)}
+ ); +} + +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; From 4023c95dce9f3881444e4903ef1c93d2e6b8ab9b Mon Sep 17 00:00:00 2001 From: felipe-software Date: Tue, 23 Jun 2026 14:19:54 -0300 Subject: [PATCH 5/6] feat: status sidebar --- src/lib/components/atoms/busy-dot.tsx | 21 ---- src/lib/components/atoms/busy-icon.tsx | 44 +++++++ .../worktree-terminal/terminal-tab-bar.tsx | 6 +- .../templates/main-panel/branch-page-tabs.tsx | 9 +- .../components/templates/main-panel/index.tsx | 9 ++ .../templates/sidebar/sidebar-branch-item.tsx | 16 ++- .../sidebar/sidebar-project-item.tsx | 114 +++++++++++------- 7 files changed, 149 insertions(+), 70 deletions(-) delete mode 100644 src/lib/components/atoms/busy-dot.tsx create mode 100644 src/lib/components/atoms/busy-icon.tsx diff --git a/src/lib/components/atoms/busy-dot.tsx b/src/lib/components/atoms/busy-dot.tsx deleted file mode 100644 index 5bcc523..0000000 --- a/src/lib/components/atoms/busy-dot.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { ComponentPropsWithoutRef } from "react"; - -import { cn } from "@/lib/utils/cn"; - -type BusyDotProps = ComponentPropsWithoutRef<"span">; - -export function BusyDot({ className, ...props }: BusyDotProps) { - return ( - - ); -} diff --git a/src/lib/components/atoms/busy-icon.tsx b/src/lib/components/atoms/busy-icon.tsx new file mode 100644 index 0000000..168dcdb --- /dev/null +++ b/src/lib/components/atoms/busy-icon.tsx @@ -0,0 +1,44 @@ +import type { ComponentPropsWithoutRef } from "react"; + +import { cn } from "@/lib/utils/cn"; + +type BusyIconProps = ComponentPropsWithoutRef<"span"> & { + size?: "sm" | "md"; +}; + +const busyIconSizeClassName = { + md: { + container: "h-5 w-5", + dot: "h-2.5 w-2.5", + }, + sm: { + container: "h-4 w-4", + dot: "h-2 w-2", + }, +}; + +export function BusyIcon({ className, size = "md", ...props }: BusyIconProps) { + const sizeClassName = busyIconSizeClassName[size]; + + return ( + + + + ); +} diff --git a/src/lib/components/molecules/worktree-terminal/terminal-tab-bar.tsx b/src/lib/components/molecules/worktree-terminal/terminal-tab-bar.tsx index 97914a3..0d6ed6e 100644 --- a/src/lib/components/molecules/worktree-terminal/terminal-tab-bar.tsx +++ b/src/lib/components/molecules/worktree-terminal/terminal-tab-bar.tsx @@ -1,6 +1,7 @@ import { Plus, X } from "lucide-react"; import type { MouseEvent } from "react"; +import { BusyIcon } from "@/lib/components/atoms/busy-icon"; import { Button } from "@/lib/components/atoms/button"; import { TabShell } from "@/lib/components/atoms/tab-shell"; import { cn } from "@/lib/utils/cn"; @@ -62,10 +63,7 @@ export function TerminalTabBar({ onClick={() => onSelectTab(tab.id)} > {tab.busyState === "busy" ? ( - + ) : tab.status === "exited" ? ( 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({ > diff --git a/src/lib/components/templates/main-panel/index.tsx b/src/lib/components/templates/main-panel/index.tsx index eb5704f..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"); @@ -185,6 +193,7 @@ export function MainPanel({
onSelectBranch(branch.name)} > - - {isBusy ? : null} + + + {isBusy ? ( + + ) : null} + )} - - {isBusy ? : null} + + + {isBusy ? ( + + ) : null} +
- {branchesQuery.isPending ? ( + {branchesQuery.isPending && isExpanded ? (
@@ -210,7 +224,9 @@ export function SidebarProjectItem({
) : null} - {!branchesQuery.isPending && isAwaitingSshPassphrase ? ( + {!branchesQuery.isPending && + isExpanded && + isAwaitingSshPassphrase ? ( ) : null} - {(areAllRecentBranchesVisible || + {isExpanded && + (areAllRecentBranchesVisible || hiddenRecentBranchCount === 0) && !areStaleBranchesVisible && staleBranches.length > 0 ? ( From 0b928cc8ed6dd962feea77eb6bfb264ef7f9d4f9 Mon Sep 17 00:00:00 2001 From: felipe-software Date: Tue, 23 Jun 2026 14:41:15 -0300 Subject: [PATCH 6/6] fix: change drag statusbar behavior --- .../templates/global-terminal-panel.tsx | 399 ++++++++++++++++++ .../components/templates/pr-run-app/index.tsx | 164 ++++++- src/lib/components/templates/status-bar.tsx | 27 +- 3 files changed, 583 insertions(+), 7 deletions(-) create mode 100644 src/lib/components/templates/global-terminal-panel.tsx 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" + /> + + )} +
+ +
+
+
+ + +
+
+ ); +} + +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/pr-run-app/index.tsx b/src/lib/components/templates/pr-run-app/index.tsx index 13d0ffc..076b392 100644 --- a/src/lib/components/templates/pr-run-app/index.tsx +++ b/src/lib/components/templates/pr-run-app/index.tsx @@ -1,18 +1,129 @@ import { AlertTriangle } from "lucide-react"; +import type { PointerEvent as ReactPointerEvent } from "react"; +import { useMemo, useState } from "react"; import { EmptyState } from "@/lib/components/atoms/empty-state"; 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 { MainPanel } 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 { usePrRunAppState } from "@/lib/components/templates/pr-run-app/use-pr-run-app-state"; +import { useWorktreeTerminalStore } from "@/lib/hooks/store/use-worktree-terminal-store"; + +const TERMINAL_PANEL_DEFAULT_HEIGHT = 320; +const TERMINAL_PANEL_MIN_HEIGHT = 180; +const TERMINAL_PANEL_MAX_HEIGHT = 640; +const TERMINAL_PANEL_SIDEBAR_DEFAULT_WIDTH = 180; +const TERMINAL_PANEL_SIDEBAR_MIN_WIDTH = 132; +const TERMINAL_PANEL_SIDEBAR_MAX_WIDTH = 360; export function PrRunApp() { const state = usePrRunAppState(); + const terminalOwners = useWorktreeTerminalStore((store) => 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 ( @@ -96,7 +207,22 @@ export function PrRunApp() { onCheckoutBranch={state.checkoutBranch} onCreateScript={state.openCreateScript} /> - + 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/status-bar.tsx b/src/lib/components/templates/status-bar.tsx index f735cb5..5a33385 100644 --- a/src/lib/components/templates/status-bar.tsx +++ b/src/lib/components/templates/status-bar.tsx @@ -1,23 +1,32 @@ import { GitBranch, GitPullRequest, Terminal } from "lucide-react"; -import type { ReactNode } from "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 }: StatusBarProps) { +export function StatusBar({ + summary, + onBeginTerminalPanelResize, + onOpenBusyTerminals, +}: StatusBarProps) { return (