From 2b32124084cd21e7af0ca47f233b45fa4b833ad5 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 6 Apr 2026 23:56:05 -0500 Subject: [PATCH 1/2] Add stale worktree pruning to sidebar - Add a sidebar entry to open worktree cleanup - Show merged PR links and merge age in the dialog - Add bulk prune for stale records and cover age formatting --- apps/web/src/components/Sidebar.tsx | 12 +++ .../src/components/WorktreeCleanupDialog.tsx | 76 +++++++++++++++++-- apps/web/src/worktreeCleanup.test.ts | 38 +++++++++- apps/web/src/worktreeCleanup.ts | 25 ++++++ 4 files changed, 142 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6e53e0d22..72a0e8c51 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -76,6 +76,7 @@ import { useTerminalStateStore } from "../terminalStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import type { Thread } from "../types"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; +import { useWorktreeCleanupStore } from "../worktreeCleanupStore"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -490,6 +491,7 @@ export default function Sidebar() { }); const queryClient = useQueryClient(); const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); + const openWorktreeCleanupDialog = useWorktreeCleanupStore((s) => s.openDialog); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); @@ -2026,6 +2028,16 @@ export default function Sidebar() { Open Workspace + + openWorktreeCleanupDialog()} + > + + Worktree cleanup + + 0; const isBusy = removeWorktreeMutation.isPending || pruneWorktreesMutation.isPending; + const staleCandidates = useMemo( + () => + candidates.filter( + (c) => !c.pathExists && resolveWorktreeUsageCount(c, threadWorktreePaths) === 0, + ), + [candidates, threadWorktreePaths], + ); + const hasStaleCandidates = staleCandidates.length > 0; + const handleClose = () => { closeDialog(); }; + const handlePruneAllStale = async () => { + if (!cwd || !hasStaleCandidates) return; + try { + await pruneWorktreesMutation.mutateAsync({ cwd }); + toastManager.add({ + type: "success", + title: "Stale records pruned", + description: `Pruned ${staleCandidates.length} stale worktree record${staleCandidates.length === 1 ? "" : "s"}.`, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not prune stale records", + description: error instanceof Error ? error.message : "Unknown error.", + }); + } + }; + const handleRemoveCandidate = async (candidate: GitWorktreeCleanupCandidate) => { if (!cwd) return; const usageCount = resolveWorktreeUsageCount(candidate, threadWorktreePaths); @@ -151,10 +178,21 @@ export function WorktreeCleanupDialog() {
- - - Merged PR #{candidate.prNumber} - + + + + Merged PR #{candidate.prNumber} + + {candidate.pathExists ? ( On disk @@ -182,6 +220,11 @@ export function WorktreeCleanupDialog() { {" · "} Path{" "} {displayPath} + {" · "} + Merged{" "} + + {formatBranchAge(candidate.mergedAt)} +
@@ -215,9 +258,26 @@ export function WorktreeCleanupDialog() {
{candidates.length} candidate{candidates.length === 1 ? "" : "s"} found
- +
+ {hasStaleCandidates ? ( + + ) : null} + +
diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index fa494e0b3..01d7eb0d8 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -2,7 +2,11 @@ import { ProjectId, ThreadId } from "@okcode/contracts"; import { describe, expect, it } from "vitest"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; -import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; +import { + formatBranchAge, + formatWorktreePathForDisplay, + getOrphanedWorktreePathForThread, +} from "./worktreeCleanup"; function makeThread(overrides: Partial = {}): Thread { return { @@ -76,6 +80,38 @@ describe("getOrphanedWorktreePathForThread", () => { }); }); +describe("formatBranchAge", () => { + const now = new Date("2026-04-06T12:00:00.000Z"); + + it("returns 'just now' for timestamps less than a minute ago", () => { + expect(formatBranchAge("2026-04-06T11:59:30.000Z", now)).toBe("just now"); + }); + + it("returns minutes for recent merges", () => { + expect(formatBranchAge("2026-04-06T11:45:00.000Z", now)).toBe("15m ago"); + }); + + it("returns hours for same-day merges", () => { + expect(formatBranchAge("2026-04-06T05:00:00.000Z", now)).toBe("7h ago"); + }); + + it("returns days for merges within a week", () => { + expect(formatBranchAge("2026-04-03T12:00:00.000Z", now)).toBe("3d ago"); + }); + + it("returns weeks for merges within a month", () => { + expect(formatBranchAge("2026-03-16T12:00:00.000Z", now)).toBe("3w ago"); + }); + + it("returns months for older merges", () => { + expect(formatBranchAge("2026-01-06T12:00:00.000Z", now)).toBe("3mo ago"); + }); + + it("returns 'just now' for future timestamps", () => { + expect(formatBranchAge("2026-04-07T00:00:00.000Z", now)).toBe("just now"); + }); +}); + describe("formatWorktreePathForDisplay", () => { it("shows only the last path segment for unix-like paths", () => { const result = formatWorktreePathForDisplay( diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 8c09e89af..badc17881 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -32,6 +32,31 @@ export function getOrphanedWorktreePathForThread( return isShared ? null : targetWorktreePath; } +/** + * Return a human-readable relative-time label for how long ago a branch was + * merged. Expects an ISO-8601 `mergedAt` timestamp and an optional `now` + * override (for deterministic testing). + */ +export function formatBranchAge(mergedAt: string, now: Date = new Date()): string { + const mergedDate = new Date(mergedAt); + const diffMs = now.getTime() - mergedDate.getTime(); + if (diffMs < 0) return "just now"; + + const seconds = Math.floor(diffMs / 1_000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const weeks = Math.floor(days / 7); + const months = Math.floor(days / 30); + + if (months >= 1) return `${months}mo ago`; + if (weeks >= 1) return `${weeks}w ago`; + if (days >= 1) return `${days}d ago`; + if (hours >= 1) return `${hours}h ago`; + if (minutes >= 1) return `${minutes}m ago`; + return "just now"; +} + export function formatWorktreePathForDisplay(worktreePath: string): string { const trimmed = worktreePath.trim(); if (!trimmed) { From f6187d4d8982355a74158b4c62f40849e8fd3e6e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:56:17 -0500 Subject: [PATCH 2/2] Fix desktop build and streamline thread UI (#334) * Fix desktop build and runtime cleanup - Harden screenshot image loading cleanup - Simplify thread snapshot hydration and sidebar props - Fix widget notification event typing and test rename * Prune merged worktrees after thread cleanup (#335) * Clean up merged worktrees after thread deletion - Add merged worktree candidate detection and prune support - Auto-remove orphaned worktrees after merged thread cleanup - Surface cleanup state through the web client and WS contract * Add merged worktree cleanup dialog - Add a command-palette entry to review merged worktrees - Let users delete on-disk worktrees or prune stale Git records - Wire the dialog into the chat route and auto-delete flow * Improve merged worktree cleanup feedback - Use the active thread to choose the cleanup project - Rename prune action for stale records - Show an error toast when merged thread auto-delete fails * Format worktree cleanup and prune call - Reflow worktree cleanup dialog strings and layout - Reformat Git worktree prune call for consistency * Simplify merged worktree cleanup handling - Ignore prune failures silently during worktree cleanup - Add explicit git operation labels for merged-worktree cleanup calls --- .../Layers/ProjectionSnapshotQuery.ts | 66 +++++++++---------- apps/web/src/components/ScreenshotTool.tsx | 16 ++++- apps/web/src/components/Sidebar.tsx | 3 - .../src/components/widget/ChatWidgetShell.tsx | 3 +- .../routes/{_chat.test.ts => -_chat.test.ts} | 0 5 files changed, 48 insertions(+), 40 deletions(-) rename apps/web/src/routes/{_chat.test.ts => -_chat.test.ts} (100%) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index abeaaafaa..c8ce0121e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -81,6 +81,15 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ }); const ProjectionStateDbRowSchema = ProjectionState; +function parseGithubRef(serialized: string | null): OrchestrationThread["githubRef"] | undefined { + if (!serialized) return undefined; + try { + return JSON.parse(serialized) as OrchestrationThread["githubRef"]; + } catch { + return undefined; + } +} + const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.projects, ORCHESTRATION_PROJECTOR_NAMES.threads, @@ -543,39 +552,30 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { deletedAt: row.deletedAt, })); - const threads: Array = threadRows.map((row) => { - let githubRef: OrchestrationThread["githubRef"]; - try { - if (row.githubRef) { - githubRef = JSON.parse(row.githubRef); - } - } catch { - // Ignore invalid JSON — treat as no ref - } - const thread = Object.assign( - { - id: row.threadId, - projectId: row.projectId, - title: row.title, - model: row.model, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], - checkpoints: checkpointsByThread.get(row.threadId) ?? [], - session: sessionsByThread.get(row.threadId) ?? null, - }, - githubRef ? { githubRef } : {}, - ) as OrchestrationThread; - return thread; - }); + const threads: Array = []; + for (const row of threadRows) { + const githubRef = parseGithubRef(row.githubRef); + const thread: OrchestrationThread = { + id: row.threadId, + projectId: row.projectId, + title: row.title, + model: row.model, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + }; + threads.push(githubRef ? { ...thread, githubRef } : thread); + } const snapshot = { snapshotSequence: computeSnapshotSequence(stateRows), diff --git a/apps/web/src/components/ScreenshotTool.tsx b/apps/web/src/components/ScreenshotTool.tsx index b061fa959..34e810231 100644 --- a/apps/web/src/components/ScreenshotTool.tsx +++ b/apps/web/src/components/ScreenshotTool.tsx @@ -84,8 +84,20 @@ async function captureRegion(rect: { function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); - img.addEventListener("load", () => resolve(img), { once: true }); - img.addEventListener("error", reject, { once: true }); + const cleanup = () => { + img.removeEventListener("load", onLoad); + img.removeEventListener("error", onError); + }; + const onLoad = () => { + cleanup(); + resolve(img); + }; + const onError = () => { + cleanup(); + reject(new Error("Failed to load image")); + }; + img.addEventListener("load", onLoad); + img.addEventListener("error", onError); img.src = src; }); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 72a0e8c51..e4b18f7cc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -131,7 +131,6 @@ const SIDEBAR_THREAD_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", }; - interface PrStatusIndicator { label: "PR open" | "PR closed" | "PR merged"; colorClass: string; @@ -288,7 +287,6 @@ interface MemoizedThreadRowProps { thread: Thread; isActive: boolean; isSelected: boolean; - routeThreadId: ThreadIdType | null; prByThreadId: Map; orderedProjectThreadIds: ThreadIdType[]; selectedThreadIds: ReadonlySet; @@ -1341,7 +1339,6 @@ export default function Sidebar() { thread={thread} isActive={routeThreadId === thread.id} isSelected={selectedThreadIds.has(thread.id)} - routeThreadId={routeThreadId} prByThreadId={prByThreadId} orderedProjectThreadIds={orderedProjectThreadIds} selectedThreadIds={selectedThreadIds} diff --git a/apps/web/src/components/widget/ChatWidgetShell.tsx b/apps/web/src/components/widget/ChatWidgetShell.tsx index b04ff11ca..b6c33717c 100644 --- a/apps/web/src/components/widget/ChatWidgetShell.tsx +++ b/apps/web/src/components/widget/ChatWidgetShell.tsx @@ -9,7 +9,6 @@ import ThreadSidebar from "../Sidebar"; import { Sidebar, SidebarProvider, SidebarRail } from "../ui/sidebar"; import { ChatWidgetBubble } from "./ChatWidgetBubble"; import { ChatWidgetPanel } from "./ChatWidgetPanel"; - const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; @@ -39,7 +38,7 @@ export function ChatWidgetShell() { useEffect(() => { const expand = useChatWidgetStore.getState().expand; - const onNotificationTap = (() => { + const onNotificationTap = ((_event: CustomEvent<{ threadId?: string }>) => { expand(); // Navigation to the thread is handled by the notification system. }) as EventListener; diff --git a/apps/web/src/routes/_chat.test.ts b/apps/web/src/routes/-_chat.test.ts similarity index 100% rename from apps/web/src/routes/_chat.test.ts rename to apps/web/src/routes/-_chat.test.ts