Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d10f888
docs(lastcode): plan durable worktree cleanup
lastobelus Aug 23, 2026
f6c19b4
feat(lastcode): make worktree cleanup durable
lastobelus Aug 24, 2026
b38b11c
fix(lastcode): fence durable worktree cleanup
lastobelus Aug 24, 2026
6f3ce0a
fix(lastcode): keep cleanup tombstones inert
lastobelus Aug 24, 2026
83783fa
fix(lastcode): harden cleanup version boundaries
lastobelus Aug 24, 2026
d28d55f
fix(web): keep cleanup hover details readable
lastobelus Aug 24, 2026
5b6f29f
fix(lastcode): complete cleanup recovery paths
lastobelus Aug 24, 2026
2cd0853
fix(lastcode): preserve cleanup recovery
lastobelus Aug 24, 2026
b1577c4
fix(lastcode): retain cleanup completion retries
lastobelus Aug 24, 2026
16cdb31
fix(lastcode): serialize durable cleanup safely
lastobelus Aug 24, 2026
bf9061c
fix(web): preserve cleanup recovery paths
lastobelus Aug 24, 2026
795bd93
fix(mobile): surface cleanup tombstones in v2
lastobelus Aug 24, 2026
eece716
fix(server): retire idle cleanup workers
lastobelus Aug 24, 2026
3795fc4
fix(shared): release retired worker resources
lastobelus Aug 24, 2026
15cb754
fix(web): clean up archived thread worktrees
lastobelus Aug 24, 2026
0e9934b
fix(server): preserve active project workspaces
lastobelus Aug 24, 2026
0956a4c
fix(web): include archived worktree owners
lastobelus Aug 24, 2026
75884be
fix(server): resolve cleanup ownership paths
lastobelus Aug 24, 2026
e8c8d33
fix(sync): negotiate cleanup tombstones
lastobelus Aug 24, 2026
937f2b3
fix(web): use tooltip width for cleanup paths
lastobelus Aug 24, 2026
a2c2cde
Revert "fix(sync): negotiate cleanup tombstones"
lastobelus Aug 24, 2026
2803d89
fix(server): close cleanup concurrency gaps
lastobelus Aug 24, 2026
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
91 changes: 85 additions & 6 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { themeColorWithAlpha } from "../../lib/mobileTheme";
import { useThemeColor } from "../../lib/useThemeColor";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { terminalEnvironment } from "../../state/terminal";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";
import type { HomeGroupDisplayAction } from "../home/homeListItems";
Expand Down Expand Up @@ -418,6 +419,11 @@ const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
];

const FAILED_CLEANUP_MENU_ACTIONS: MenuAction[] = [
{ id: "retry-worktree-cleanup", title: "Retry", image: "arrow.clockwise" },
{ id: "keep-worktree", title: "Keep worktree", image: "externaldrive" },
];

export const ThreadListRow = memo(function ThreadListRow(props: {
readonly variant: ThreadListVariant;
readonly thread: EnvironmentThreadShell;
Expand Down Expand Up @@ -459,8 +465,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: {

const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
props;
const cleanupFailed = thread.worktreeCleanup?.status === "failed";
const cleanupPending = thread.worktreeCleanup != null && !cleanupFailed;
const runningAction = thread.actionResume?.outcome === "running" ? thread.actionResume : null;
const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false });
const retryWorktreeCleanup = useAtomCommand(threadEnvironment.retryWorktreeCleanup, {
reportFailure: false,
});
const abandonWorktreeCleanup = useAtomCommand(threadEnvironment.abandonWorktreeCleanup, {
reportFailure: false,
});
const status = resolveThreadStatus(thread);
const pr = useThreadPr(thread, props.projectCwd);
const timestamp = relativeTime(
Expand Down Expand Up @@ -504,6 +518,48 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
);
}
}, [closeTerminal, runningAction, thread.environmentId, thread.id]);
const handleRetryWorktreeCleanup = useCallback(async () => {
const result = await retryWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
Alert.alert(
"Could not retry worktree cleanup",
error instanceof Error ? error.message : "The worktree cleanup could not be retried.",
);
}
}, [retryWorktreeCleanup, thread.environmentId, thread.id]);
const handleKeepWorktree = useCallback(() => {
Alert.alert(
"Keep worktree?",
"LastCode will stop trying to remove this worktree. You can remove it manually later.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Keep worktree",
style: "destructive",
onPress: () => {
void abandonWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
}).then((result) => {
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
Alert.alert(
"Could not keep worktree",
error instanceof Error
? error.message
: "The worktree cleanup could not be dismissed.",
);
}
});
},
},
],
);
}, [abandonWorktreeCleanup, thread.environmentId, thread.id]);
const menuActions = useMemo<MenuAction[]>(
() => [
THREAD_ROW_MENU_ACTIONS[0]!,
Expand Down Expand Up @@ -539,9 +595,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
if (nativeEvent.event === "archive") handleArchive();
if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
if (nativeEvent.event === "cancel-action") void handleCancelAction();
if (nativeEvent.event === "retry-worktree-cleanup") void handleRetryWorktreeCleanup();
if (nativeEvent.event === "keep-worktree") handleKeepWorktree();
if (nativeEvent.event === "delete") handleDelete();
},
[handleArchive, handleCancelAction, handleDelete, handleRegenerateTitle],
[
handleArchive,
handleCancelAction,
handleDelete,
handleKeepWorktree,
handleRegenerateTitle,
handleRetryWorktreeCleanup,
],
);

const statusPill = effectiveStatus ? (
Expand Down Expand Up @@ -595,11 +660,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
const rowContent = (close: () => void) =>
compact ? (
<Pressable
accessibilityHint="Swipe left for archive and delete actions"
accessibilityHint={
cleanupFailed
? "Thread unavailable. Long-press for worktree recovery actions"
: "Swipe left for archive and delete actions"
}
accessibilityLabel={threadAccessibilityLabel}
accessibilityRole="button"
accessibilityState={{ disabled: cleanupPending }}
className="bg-screen"
disabled={cleanupPending}
onPress={() => {
if (cleanupFailed) return;
close();
onSelectThread(thread);
}}
Expand Down Expand Up @@ -648,13 +720,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
</Pressable>
) : (
<Pressable
accessibilityHint="Opens the thread"
accessibilityHint={
cleanupFailed
? "Thread unavailable. Long-press for worktree recovery actions"
: "Opens the thread"
}
accessibilityLabel={threadAccessibilityLabel}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityState={{ disabled: cleanupPending, selected }}
disabled={cleanupPending}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
onPress={() => {
if (cleanupFailed) return;
close();
onSelectThread(thread);
}}
Expand Down Expand Up @@ -711,6 +789,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
return (
<ThreadSwipeable
backgroundColor={backgroundColor}
enabled={!cleanupPending && !cleanupFailed}
containerStyle={
compact ? undefined : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" }
}
Expand All @@ -734,9 +813,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
// ControlPillMenu injects onLongPress into the row and anchors the
// token-styled dropdown to it; taps and swipes are untouched.
<ControlPillMenu
actions={menuActions}
actions={cleanupFailed ? FAILED_CLEANUP_MENU_ACTIONS : cleanupPending ? [] : menuActions}
onPressAction={handleMenuAction}
shouldOpenOnLongPress
shouldOpenOnLongPress={cleanupFailed || !cleanupPending}
>
{rowContent(close)}
</ControlPillMenu>
Expand Down
102 changes: 87 additions & 15 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,19 @@ import { relativeTime } from "../../lib/time";
import { useThemeColor } from "../../lib/useThemeColor";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { terminalEnvironment } from "../../state/terminal";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { useThreadPr } from "../../state/use-thread-pr";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import { resolveWorktreeCleanupStatus } from "./threadPresentation";
import {
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2Status,
resolveThreadListV2SwipeActions,
resolveThreadListV2CleanupActions,
type ThreadListV2Status,
} from "./threadListV2";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
Expand Down Expand Up @@ -91,6 +94,11 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
];

const FAILED_CLEANUP_MENU_ACTIONS: MenuAction[] = [
{ id: "retry-worktree-cleanup", title: "Retry", image: "arrow.clockwise" },
{ id: "keep-worktree", title: "Keep worktree", image: "externaldrive" },
];

/** Rounded-row radius shared with the v1 sidebar rows. */
const SIDEBAR_V2_ROW_RADIUS = 12;

Expand Down Expand Up @@ -406,7 +414,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const snoozedRow = props.snoozed === true;
const pinnedRow = props.pinned === true;
const runningAction = thread.actionResume?.outcome === "running" ? thread.actionResume : null;
const cleanupFailed = resolveThreadListV2CleanupActions(thread.worktreeCleanup).length > 0;
const cleanupPending = thread.worktreeCleanup != null && !cleanupFailed;
Comment thread
lastobelus marked this conversation as resolved.
const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false });
const retryWorktreeCleanup = useAtomCommand(threadEnvironment.retryWorktreeCleanup, {
reportFailure: false,
});
const abandonWorktreeCleanup = useAtomCommand(threadEnvironment.abandonWorktreeCleanup, {
reportFailure: false,
});

const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
const prState = pr?.state ?? null;
Expand All @@ -428,7 +444,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const selected = props.selected === true;

const status = resolveThreadListV2Status(thread);
const statusLabel = STATUS_LABEL_BY_STATUS[status];
const cleanupStatus = resolveWorktreeCleanupStatus(thread);
const statusLabel = cleanupStatus
? { label: cleanupStatus.label, className: cleanupStatus.textClassName }
: STATUS_LABEL_BY_STATUS[status];
const timeLabel = threadTimeLabel(thread);

const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
Expand Down Expand Up @@ -468,6 +487,48 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
);
}
}, [closeTerminal, runningAction, thread.environmentId, thread.id]);
const handleRetryWorktreeCleanup = useCallback(async () => {
const result = await retryWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
Alert.alert(
"Could not retry worktree cleanup",
error instanceof Error ? error.message : "The worktree cleanup could not be retried.",
);
}
}, [retryWorktreeCleanup, thread.environmentId, thread.id]);
const handleKeepWorktree = useCallback(() => {
Alert.alert(
"Keep worktree?",
"LastCode will stop trying to remove this worktree. You can remove it manually later.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Keep worktree",
style: "destructive",
onPress: () => {
void abandonWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
}).then((result) => {
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
Alert.alert(
"Could not keep worktree",
error instanceof Error
? error.message
: "The worktree cleanup could not be dismissed.",
);
}
});
},
},
],
);
}, [abandonWorktreeCleanup, thread.environmentId, thread.id]);

// Swipe: the v2 primary action is the lifecycle transition. Every settled
// row can un-settle — explicit settles clear the override, auto-settled
Expand Down Expand Up @@ -628,6 +689,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
if (nativeEvent.event === "archive") handleArchive();
if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
if (nativeEvent.event === "cancel-action") void handleCancelAction();
if (nativeEvent.event === "retry-worktree-cleanup") void handleRetryWorktreeCleanup();
if (nativeEvent.event === "keep-worktree") handleKeepWorktree();
if (nativeEvent.event === "delete") handleDelete();
const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
event: nativeEvent.event,
Expand All @@ -644,7 +707,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handleArchive,
handleCancelAction,
handleDelete,
handleKeepWorktree,
handleRegenerateTitle,
handleRetryWorktreeCleanup,
handleMovePinnedDown,
handleMovePinnedUp,
handlePin,
Expand Down Expand Up @@ -848,10 +913,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={thread.title}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityState={{ disabled: cleanupPending, selected }}
disabled={cleanupPending}
onPress={() => {
close();
onSelectThread(thread);
if (!cleanupFailed) onSelectThread(thread);
}}
style={
sidebarPane
Expand Down Expand Up @@ -888,11 +954,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={thread.title}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityState={{ disabled: cleanupPending, selected }}
disabled={cleanupPending}
className={sidebarPane ? undefined : "bg-screen"}
onPress={() => {
close();
onSelectThread(thread);
if (!cleanupFailed) onSelectThread(thread);
}}
style={
sidebarPane
Expand Down Expand Up @@ -971,6 +1038,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
sidebarPane ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, overflow: "hidden" } : undefined
}
enableTrackpadSwipe
enabled={!cleanupPending && !cleanupFailed}
// Full swipe commits the advertised lifecycle action (Settle /
// Un-settle), never the secondary snooze action.
fullSwipeAction="primary"
Expand All @@ -987,18 +1055,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
{(close) => (
<ControlPillMenu
actions={
snoozedRow
? snoozedMenuActions
: !props.settlementSupported
? legacyMenuActions
: canUnsettle
? slimMenuActions
: swipeActions.secondary === "snooze"
? snoozableCardMenuActions
: cardMenuActions
cleanupFailed
? FAILED_CLEANUP_MENU_ACTIONS
: cleanupPending
? []
: snoozedRow
? snoozedMenuActions
: !props.settlementSupported
? legacyMenuActions
: canUnsettle
? slimMenuActions
: swipeActions.secondary === "snooze"
? snoozableCardMenuActions
: cardMenuActions
}
onPressAction={handleMenuAction}
shouldOpenOnLongPress
shouldOpenOnLongPress={cleanupFailed || !cleanupPending}
>
{rowContent(close)}
</ControlPillMenu>
Expand Down
Loading