Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 33 additions & 33 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -543,39 +552,30 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
deletedAt: row.deletedAt,
}));

const threads: Array<OrchestrationThread> = 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<OrchestrationThread> = [];
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),
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/components/ScreenshotTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,20 @@ async function captureRegion(rect: {
function loadImage(src: string): Promise<HTMLImageElement> {
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;
});
}
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -130,7 +131,6 @@ const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = {
updated_at: "Last user message",
created_at: "Created at",
};

interface PrStatusIndicator {
label: "PR open" | "PR closed" | "PR merged";
colorClass: string;
Expand Down Expand Up @@ -287,7 +287,6 @@ interface MemoizedThreadRowProps {
thread: Thread;
isActive: boolean;
isSelected: boolean;
routeThreadId: ThreadIdType | null;
prByThreadId: Map<ThreadIdType, ThreadPr>;
orderedProjectThreadIds: ThreadIdType[];
selectedThreadIds: ReadonlySet<ThreadIdType>;
Expand Down Expand Up @@ -490,6 +489,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);
Expand Down Expand Up @@ -1339,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}
Expand Down Expand Up @@ -2026,6 +2025,16 @@ export default function Sidebar() {
<span className="text-xs">Open Workspace</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
onClick={() => openWorktreeCleanupDialog()}
>
<GitMergeIcon className="size-3.5" />
<span className="text-xs">Worktree cleanup</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
Expand Down
76 changes: 68 additions & 8 deletions apps/web/src/components/WorktreeCleanupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
gitPruneWorktreesMutationOptions,
gitRemoveWorktreeMutationOptions,
} from "~/lib/gitReactQuery";
import { formatWorktreePathForDisplay } from "~/worktreeCleanup";
import { formatBranchAge, formatWorktreePathForDisplay } from "~/worktreeCleanup";
import { useWorktreeCleanupStore } from "~/worktreeCleanupStore";
import { toastManager } from "./ui/toast";
import { Badge } from "./ui/badge";
Expand Down Expand Up @@ -64,10 +64,37 @@ export function WorktreeCleanupDialog() {
const hasCandidates = candidates.length > 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);
Expand Down Expand Up @@ -151,10 +178,21 @@ export function WorktreeCleanupDialog() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" size="sm">
<GitMergeIcon className="size-3.5" />
Merged PR #{candidate.prNumber}
</Badge>
<a
href={candidate.prUrl}
target="_blank"
rel="noopener noreferrer"
className="no-underline"
>
<Badge
variant="outline"
size="sm"
className="cursor-pointer hover:bg-accent"
>
<GitMergeIcon className="size-3.5" />
Merged PR #{candidate.prNumber}
</Badge>
</a>
{candidate.pathExists ? (
<Badge variant="success" size="sm">
On disk
Expand Down Expand Up @@ -182,6 +220,11 @@ export function WorktreeCleanupDialog() {
{" · "}
Path{" "}
<span className="font-mono text-foreground">{displayPath}</span>
{" · "}
Merged{" "}
<span className="text-foreground">
{formatBranchAge(candidate.mergedAt)}
</span>
</div>
</div>
</div>
Expand Down Expand Up @@ -215,9 +258,26 @@ export function WorktreeCleanupDialog() {
<div className="text-xs text-muted-foreground">
{candidates.length} candidate{candidates.length === 1 ? "" : "s"} found
</div>
<Button variant="outline" size="sm" onClick={handleClose}>
Close
</Button>
<div className="flex items-center gap-2">
{hasStaleCandidates ? (
<Button
variant="destructive-outline"
size="sm"
disabled={isBusy}
onClick={() => void handlePruneAllStale()}
>
{pruneWorktreesMutation.isPending ? (
<LoaderCircleIcon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Prune all stale ({staleCandidates.length})
</Button>
) : null}
<Button variant="outline" size="sm" onClick={handleClose}>
Close
</Button>
</div>
</div>
</DialogFooter>
</DialogPopup>
Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/components/widget/ChatWidgetShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
File renamed without changes.
38 changes: 37 additions & 1 deletion apps/web/src/worktreeCleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): Thread {
return {
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/worktreeCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading