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
2 changes: 2 additions & 0 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export const AppSettingsSchema = Schema.Struct({
codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "worktree" as const satisfies EnvMode)),
confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
autoDeleteMergedThreads: Schema.Boolean.pipe(withDefaults(() => false)),
autoDeleteMergedThreadsDelayMinutes: Schema.Number.pipe(withDefaults(() => 5)),
diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
locale: AppLocale.pipe(withDefaults(() => DEFAULT_APP_LOCALE)),
Expand Down
186 changes: 186 additions & 0 deletions apps/web/src/hooks/useAutoDeleteMergedThreads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { useEffect, useRef } from "react";
import { useQueries } from "@tanstack/react-query";
import type { ThreadId } from "@okcode/contracts";

import type { AppSettings } from "../appSettings";
import { gitStatusQueryOptions } from "../lib/gitReactQuery";
import { readNativeApi } from "../nativeApi";
import { newCommandId } from "../lib/utils";
import { useStore } from "../store";
import { toastManager } from "../components/ui/toast";

/**
* Duration before a merged-thread countdown toast auto-dismisses (so it stays
* visible long enough for the user to cancel, but isn't permanent).
*/
const TOAST_VISIBLE_MS = 30_000;

interface MergedThreadTimer {
timeoutId: ReturnType<typeof setTimeout>;
toastId: ReturnType<typeof toastManager.add> | null;
}

/**
* Watches every active thread's git status. When the associated PR transitions
* to "merged", starts a countdown and then auto-deletes the thread.
*
* The feature is gated behind two app-settings:
* - `autoDeleteMergedThreads` – master toggle (default off)
* - `autoDeleteMergedThreadsDelayMinutes` – countdown duration (default 5 min)
*
* A toast is shown so the user can cancel before the timer fires.
*/
export function useAutoDeleteMergedThreads(settings: AppSettings) {
const threads = useStore((store) => store.threads);
const projects = useStore((store) => store.projects);

// Track active timers per thread so we can cancel on setting change or
// unmount, and avoid double-scheduling.
const timersRef = useRef<Map<ThreadId, MergedThreadTimer>>(new Map());

const enabled = settings.autoDeleteMergedThreads;
const delayMinutes = settings.autoDeleteMergedThreadsDelayMinutes;

// Build a cwd for each thread (worktree path takes priority).
const threadCwds = threads.map((thread) => {
const project = projects.find((p) => p.id === thread.projectId);
return thread.worktreePath ?? project?.cwd ?? null;
});

// Query git status for every thread – the query already polls at 15 s intervals.
const statusQueries = useQueries({
queries: threads.map((thread, index) =>
gitStatusQueryOptions(enabled ? threadCwds[index]! : null),
),
});

useEffect(() => {
if (!enabled) {
// Feature was just toggled off – cancel all pending timers.
for (const [, timer] of timersRef.current) {
clearTimeout(timer.timeoutId);
if (timer.toastId !== null) {
toastManager.close(timer.toastId);
}
}
timersRef.current.clear();
return;
}

const delayMs = Math.max(1, delayMinutes) * 60_000;

for (let i = 0; i < threads.length; i++) {
const thread = threads[i]!;
const prState = statusQueries[i]?.data?.pr?.state;

if (prState === "merged" && !timersRef.current.has(thread.id)) {
// PR just detected as merged – start countdown.
const threadTitle = thread.title || `Thread ${thread.id.slice(0, 8)}`;
const minutesLabel =
delayMinutes === 1 ? "1 minute" : `${delayMinutes} minutes`;

const toastId = toastManager.add({
type: "info",
title: `PR merged – "${threadTitle}" will be deleted`,
description: `Auto-deleting in ${minutesLabel}. Click Cancel to keep it.`,
dismissAfterVisibleMs: TOAST_VISIBLE_MS,
actionProps: {
children: "Cancel",
onClick: () => {
const timer = timersRef.current.get(thread.id);
if (timer) {
clearTimeout(timer.timeoutId);
timersRef.current.delete(thread.id);
}
toastManager.add({
type: "success",
title: "Auto-delete cancelled",
description: `"${threadTitle}" will be kept.`,
});
},
},
});

const timeoutId = setTimeout(() => {
void deleteThreadById(thread.id);
timersRef.current.delete(thread.id);
toastManager.add({
type: "success",
title: "Merged thread deleted",
description: `"${threadTitle}" was auto-deleted after its PR was merged.`,
});
}, delayMs);

timersRef.current.set(thread.id, { timeoutId, toastId });
}

// If a timer exists but the thread is gone (deleted externally), clean up.
if (prState !== "merged" && timersRef.current.has(thread.id)) {
const timer = timersRef.current.get(thread.id)!;
clearTimeout(timer.timeoutId);
if (timer.toastId !== null) {
toastManager.close(timer.toastId);
}
timersRef.current.delete(thread.id);
}
}

// Also prune timers for threads that no longer exist in the list.
const currentThreadIds = new Set(threads.map((t) => t.id));
for (const [threadId, timer] of timersRef.current) {
if (!currentThreadIds.has(threadId)) {
clearTimeout(timer.timeoutId);
if (timer.toastId !== null) {
toastManager.close(timer.toastId);
}
timersRef.current.delete(threadId);
}
}
}, [enabled, delayMinutes, threads, statusQueries]);

// Cleanup all timers on unmount.
useEffect(() => {
return () => {
for (const [, timer] of timersRef.current) {
clearTimeout(timer.timeoutId);
}
timersRef.current.clear();
};
}, []);
}

/**
* Minimal thread deletion: stops the session, closes the terminal, and
* dispatches the `thread.delete` command. Does not handle navigation or
* worktree cleanup – callers higher up in the tree will react to the
* projection change.
*/
async function deleteThreadById(threadId: ThreadId): Promise<void> {
const api = readNativeApi();
if (!api) return;

try {
await api.orchestration
.dispatchCommand({
type: "thread.session.stop",
commandId: newCommandId(),
threadId,
createdAt: new Date().toISOString(),
})
.catch(() => undefined);
} catch {
// Session may already be stopped.
}

try {
await api.terminal.close({ threadId, deleteHistory: true });
} catch {
// Terminal may already be closed.
}

await api.orchestration.dispatchCommand({
type: "thread.delete",
commandId: newCommandId(),
threadId,
});
}
93 changes: 93 additions & 0 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,13 @@ function SettingsRouteView() {
...(settings.confirmThreadDelete !== defaults.confirmThreadDelete
? ["Delete confirmation"]
: []),
...(settings.autoDeleteMergedThreads !== defaults.autoDeleteMergedThreads
? ["Auto-delete merged threads"]
: []),
...(settings.autoDeleteMergedThreadsDelayMinutes !==
defaults.autoDeleteMergedThreadsDelayMinutes
? ["Auto-delete delay"]
: []),
...(isGitTextGenerationModelDirty ? ["Git writing model"] : []),
...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0
? ["Custom models"]
Expand Down Expand Up @@ -1187,6 +1194,92 @@ function SettingsRouteView() {
/>
}
/>

<SettingsRow
title="Auto-delete after merge"
description="Automatically delete a thread after its associated PR is merged."
resetAction={
settings.autoDeleteMergedThreads !== defaults.autoDeleteMergedThreads ? (
<SettingResetButton
label="auto-delete merged threads"
onClick={() =>
updateSettings({
autoDeleteMergedThreads: defaults.autoDeleteMergedThreads,
})
}
/>
) : null
}
control={
<Switch
checked={settings.autoDeleteMergedThreads}
onCheckedChange={(checked) =>
updateSettings({
autoDeleteMergedThreads: Boolean(checked),
})
}
aria-label="Auto-delete merged threads"
/>
}
/>

{settings.autoDeleteMergedThreads ? (
<SettingsRow
title="Auto-delete delay"
description="How long to wait after a PR merge before deleting the thread."
resetAction={
settings.autoDeleteMergedThreadsDelayMinutes !==
defaults.autoDeleteMergedThreadsDelayMinutes ? (
<SettingResetButton
label="auto-delete delay"
onClick={() =>
updateSettings({
autoDeleteMergedThreadsDelayMinutes:
defaults.autoDeleteMergedThreadsDelayMinutes,
})
}
/>
) : null
}
control={
<Select
value={String(settings.autoDeleteMergedThreadsDelayMinutes)}
onValueChange={(value) =>
updateSettings({
autoDeleteMergedThreadsDelayMinutes: Number(value),
})
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="1">
1 minute
</SelectItem>
<SelectItem hideIndicator value="2">
2 minutes
</SelectItem>
<SelectItem hideIndicator value="5">
5 minutes
</SelectItem>
<SelectItem hideIndicator value="10">
10 minutes
</SelectItem>
<SelectItem hideIndicator value="15">
15 minutes
</SelectItem>
<SelectItem hideIndicator value="30">
30 minutes
</SelectItem>
<SelectItem hideIndicator value="60">
1 hour
</SelectItem>
</SelectPopup>
</Select>
}
/>
) : null}
</SettingsSection>

<SettingsSection title="Environment">
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/routes/_chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useStore } from "../store";
import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
import { useAppSettings } from "~/appSettings";
import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
import { useAutoDeleteMergedThreads } from "~/hooks/useAutoDeleteMergedThreads";
import { useClientMode } from "~/hooks/useClientMode";

const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
Expand Down Expand Up @@ -198,6 +199,9 @@ function ChatRouteLayout() {
};
}, [navigate]);

// Auto-delete threads whose PR has been merged (when enabled in settings).
useAutoDeleteMergedThreads(settings);

// Apply window opacity via the desktop bridge when the setting changes
useEffect(() => {
if (window.desktopBridge) {
Expand Down
Loading