Skip to content

Commit c586681

Browse files
authored
Add auto-delete countdown for merged threads (#207)
- Add settings to enable merged-thread auto-delete and configure the delay - Watch PR merge state in the chat route and delete threads after the countdown - Show a cancellable toast before the deletion fires
1 parent 5496044 commit c586681

4 files changed

Lines changed: 285 additions & 0 deletions

File tree

apps/web/src/appSettings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export const AppSettingsSchema = Schema.Struct({
6666
codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
6767
defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "worktree" as const satisfies EnvMode)),
6868
confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
69+
autoDeleteMergedThreads: Schema.Boolean.pipe(withDefaults(() => false)),
70+
autoDeleteMergedThreadsDelayMinutes: Schema.Number.pipe(withDefaults(() => 5)),
6971
diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
7072
enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
7173
locale: AppLocale.pipe(withDefaults(() => DEFAULT_APP_LOCALE)),
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { useEffect, useRef } from "react";
2+
import { useQueries } from "@tanstack/react-query";
3+
import type { ThreadId } from "@okcode/contracts";
4+
5+
import type { AppSettings } from "../appSettings";
6+
import { gitStatusQueryOptions } from "../lib/gitReactQuery";
7+
import { readNativeApi } from "../nativeApi";
8+
import { newCommandId } from "../lib/utils";
9+
import { useStore } from "../store";
10+
import { toastManager } from "../components/ui/toast";
11+
12+
/**
13+
* Duration before a merged-thread countdown toast auto-dismisses (so it stays
14+
* visible long enough for the user to cancel, but isn't permanent).
15+
*/
16+
const TOAST_VISIBLE_MS = 30_000;
17+
18+
interface MergedThreadTimer {
19+
timeoutId: ReturnType<typeof setTimeout>;
20+
toastId: ReturnType<typeof toastManager.add> | null;
21+
}
22+
23+
/**
24+
* Watches every active thread's git status. When the associated PR transitions
25+
* to "merged", starts a countdown and then auto-deletes the thread.
26+
*
27+
* The feature is gated behind two app-settings:
28+
* - `autoDeleteMergedThreads` – master toggle (default off)
29+
* - `autoDeleteMergedThreadsDelayMinutes` – countdown duration (default 5 min)
30+
*
31+
* A toast is shown so the user can cancel before the timer fires.
32+
*/
33+
export function useAutoDeleteMergedThreads(settings: AppSettings) {
34+
const threads = useStore((store) => store.threads);
35+
const projects = useStore((store) => store.projects);
36+
37+
// Track active timers per thread so we can cancel on setting change or
38+
// unmount, and avoid double-scheduling.
39+
const timersRef = useRef<Map<ThreadId, MergedThreadTimer>>(new Map());
40+
41+
const enabled = settings.autoDeleteMergedThreads;
42+
const delayMinutes = settings.autoDeleteMergedThreadsDelayMinutes;
43+
44+
// Build a cwd for each thread (worktree path takes priority).
45+
const threadCwds = threads.map((thread) => {
46+
const project = projects.find((p) => p.id === thread.projectId);
47+
return thread.worktreePath ?? project?.cwd ?? null;
48+
});
49+
50+
// Query git status for every thread – the query already polls at 15 s intervals.
51+
const statusQueries = useQueries({
52+
queries: threads.map((thread, index) =>
53+
gitStatusQueryOptions(enabled ? threadCwds[index]! : null),
54+
),
55+
});
56+
57+
useEffect(() => {
58+
if (!enabled) {
59+
// Feature was just toggled off – cancel all pending timers.
60+
for (const [, timer] of timersRef.current) {
61+
clearTimeout(timer.timeoutId);
62+
if (timer.toastId !== null) {
63+
toastManager.close(timer.toastId);
64+
}
65+
}
66+
timersRef.current.clear();
67+
return;
68+
}
69+
70+
const delayMs = Math.max(1, delayMinutes) * 60_000;
71+
72+
for (let i = 0; i < threads.length; i++) {
73+
const thread = threads[i]!;
74+
const prState = statusQueries[i]?.data?.pr?.state;
75+
76+
if (prState === "merged" && !timersRef.current.has(thread.id)) {
77+
// PR just detected as merged – start countdown.
78+
const threadTitle = thread.title || `Thread ${thread.id.slice(0, 8)}`;
79+
const minutesLabel =
80+
delayMinutes === 1 ? "1 minute" : `${delayMinutes} minutes`;
81+
82+
const toastId = toastManager.add({
83+
type: "info",
84+
title: `PR merged – "${threadTitle}" will be deleted`,
85+
description: `Auto-deleting in ${minutesLabel}. Click Cancel to keep it.`,
86+
dismissAfterVisibleMs: TOAST_VISIBLE_MS,
87+
actionProps: {
88+
children: "Cancel",
89+
onClick: () => {
90+
const timer = timersRef.current.get(thread.id);
91+
if (timer) {
92+
clearTimeout(timer.timeoutId);
93+
timersRef.current.delete(thread.id);
94+
}
95+
toastManager.add({
96+
type: "success",
97+
title: "Auto-delete cancelled",
98+
description: `"${threadTitle}" will be kept.`,
99+
});
100+
},
101+
},
102+
});
103+
104+
const timeoutId = setTimeout(() => {
105+
void deleteThreadById(thread.id);
106+
timersRef.current.delete(thread.id);
107+
toastManager.add({
108+
type: "success",
109+
title: "Merged thread deleted",
110+
description: `"${threadTitle}" was auto-deleted after its PR was merged.`,
111+
});
112+
}, delayMs);
113+
114+
timersRef.current.set(thread.id, { timeoutId, toastId });
115+
}
116+
117+
// If a timer exists but the thread is gone (deleted externally), clean up.
118+
if (prState !== "merged" && timersRef.current.has(thread.id)) {
119+
const timer = timersRef.current.get(thread.id)!;
120+
clearTimeout(timer.timeoutId);
121+
if (timer.toastId !== null) {
122+
toastManager.close(timer.toastId);
123+
}
124+
timersRef.current.delete(thread.id);
125+
}
126+
}
127+
128+
// Also prune timers for threads that no longer exist in the list.
129+
const currentThreadIds = new Set(threads.map((t) => t.id));
130+
for (const [threadId, timer] of timersRef.current) {
131+
if (!currentThreadIds.has(threadId)) {
132+
clearTimeout(timer.timeoutId);
133+
if (timer.toastId !== null) {
134+
toastManager.close(timer.toastId);
135+
}
136+
timersRef.current.delete(threadId);
137+
}
138+
}
139+
}, [enabled, delayMinutes, threads, statusQueries]);
140+
141+
// Cleanup all timers on unmount.
142+
useEffect(() => {
143+
return () => {
144+
for (const [, timer] of timersRef.current) {
145+
clearTimeout(timer.timeoutId);
146+
}
147+
timersRef.current.clear();
148+
};
149+
}, []);
150+
}
151+
152+
/**
153+
* Minimal thread deletion: stops the session, closes the terminal, and
154+
* dispatches the `thread.delete` command. Does not handle navigation or
155+
* worktree cleanup – callers higher up in the tree will react to the
156+
* projection change.
157+
*/
158+
async function deleteThreadById(threadId: ThreadId): Promise<void> {
159+
const api = readNativeApi();
160+
if (!api) return;
161+
162+
try {
163+
await api.orchestration
164+
.dispatchCommand({
165+
type: "thread.session.stop",
166+
commandId: newCommandId(),
167+
threadId,
168+
createdAt: new Date().toISOString(),
169+
})
170+
.catch(() => undefined);
171+
} catch {
172+
// Session may already be stopped.
173+
}
174+
175+
try {
176+
await api.terminal.close({ threadId, deleteHistory: true });
177+
} catch {
178+
// Terminal may already be closed.
179+
}
180+
181+
await api.orchestration.dispatchCommand({
182+
type: "thread.delete",
183+
commandId: newCommandId(),
184+
threadId,
185+
});
186+
}

apps/web/src/routes/_chat.settings.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,13 @@ function SettingsRouteView() {
342342
...(settings.confirmThreadDelete !== defaults.confirmThreadDelete
343343
? ["Delete confirmation"]
344344
: []),
345+
...(settings.autoDeleteMergedThreads !== defaults.autoDeleteMergedThreads
346+
? ["Auto-delete merged threads"]
347+
: []),
348+
...(settings.autoDeleteMergedThreadsDelayMinutes !==
349+
defaults.autoDeleteMergedThreadsDelayMinutes
350+
? ["Auto-delete delay"]
351+
: []),
345352
...(isGitTextGenerationModelDirty ? ["Git writing model"] : []),
346353
...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0
347354
? ["Custom models"]
@@ -1187,6 +1194,92 @@ function SettingsRouteView() {
11871194
/>
11881195
}
11891196
/>
1197+
1198+
<SettingsRow
1199+
title="Auto-delete after merge"
1200+
description="Automatically delete a thread after its associated PR is merged."
1201+
resetAction={
1202+
settings.autoDeleteMergedThreads !== defaults.autoDeleteMergedThreads ? (
1203+
<SettingResetButton
1204+
label="auto-delete merged threads"
1205+
onClick={() =>
1206+
updateSettings({
1207+
autoDeleteMergedThreads: defaults.autoDeleteMergedThreads,
1208+
})
1209+
}
1210+
/>
1211+
) : null
1212+
}
1213+
control={
1214+
<Switch
1215+
checked={settings.autoDeleteMergedThreads}
1216+
onCheckedChange={(checked) =>
1217+
updateSettings({
1218+
autoDeleteMergedThreads: Boolean(checked),
1219+
})
1220+
}
1221+
aria-label="Auto-delete merged threads"
1222+
/>
1223+
}
1224+
/>
1225+
1226+
{settings.autoDeleteMergedThreads ? (
1227+
<SettingsRow
1228+
title="Auto-delete delay"
1229+
description="How long to wait after a PR merge before deleting the thread."
1230+
resetAction={
1231+
settings.autoDeleteMergedThreadsDelayMinutes !==
1232+
defaults.autoDeleteMergedThreadsDelayMinutes ? (
1233+
<SettingResetButton
1234+
label="auto-delete delay"
1235+
onClick={() =>
1236+
updateSettings({
1237+
autoDeleteMergedThreadsDelayMinutes:
1238+
defaults.autoDeleteMergedThreadsDelayMinutes,
1239+
})
1240+
}
1241+
/>
1242+
) : null
1243+
}
1244+
control={
1245+
<Select
1246+
value={String(settings.autoDeleteMergedThreadsDelayMinutes)}
1247+
onValueChange={(value) =>
1248+
updateSettings({
1249+
autoDeleteMergedThreadsDelayMinutes: Number(value),
1250+
})
1251+
}
1252+
>
1253+
<SelectTrigger className="w-32">
1254+
<SelectValue />
1255+
</SelectTrigger>
1256+
<SelectPopup align="end" alignItemWithTrigger={false}>
1257+
<SelectItem hideIndicator value="1">
1258+
1 minute
1259+
</SelectItem>
1260+
<SelectItem hideIndicator value="2">
1261+
2 minutes
1262+
</SelectItem>
1263+
<SelectItem hideIndicator value="5">
1264+
5 minutes
1265+
</SelectItem>
1266+
<SelectItem hideIndicator value="10">
1267+
10 minutes
1268+
</SelectItem>
1269+
<SelectItem hideIndicator value="15">
1270+
15 minutes
1271+
</SelectItem>
1272+
<SelectItem hideIndicator value="30">
1273+
30 minutes
1274+
</SelectItem>
1275+
<SelectItem hideIndicator value="60">
1276+
1 hour
1277+
</SelectItem>
1278+
</SelectPopup>
1279+
</Select>
1280+
}
1281+
/>
1282+
) : null}
11901283
</SettingsSection>
11911284

11921285
<SettingsSection title="Environment">

apps/web/src/routes/_chat.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useStore } from "../store";
1717
import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
1818
import { useAppSettings } from "~/appSettings";
1919
import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
20+
import { useAutoDeleteMergedThreads } from "~/hooks/useAutoDeleteMergedThreads";
2021
import { useClientMode } from "~/hooks/useClientMode";
2122

2223
const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
@@ -198,6 +199,9 @@ function ChatRouteLayout() {
198199
};
199200
}, [navigate]);
200201

202+
// Auto-delete threads whose PR has been merged (when enabled in settings).
203+
useAutoDeleteMergedThreads(settings);
204+
201205
// Apply window opacity via the desktop bridge when the setting changes
202206
useEffect(() => {
203207
if (window.desktopBridge) {

0 commit comments

Comments
 (0)