Open
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
30 changes: 30 additions & 0 deletions apps/mobile/src/features/home/threadActionMessages.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import * as Cause from "effect/Cause";

export type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

export function actionFailureMessage(
action: ThreadListAction,
cause: Cause.Cause<unknown>,
): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

export function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}
114 changes: 86 additions & 28 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { canSettle } from "@t3tools/client-runtime/state/thread-settled";
import { runArchiveWithWorktreeCleanup } from "@t3tools/client-runtime/state/worktreeCleanup";
import * as Cause from "effect/Cause";
import * as Haptics from "expo-haptics";
import { useCallback, useRef } from "react";
Expand All@@ -11,7 +12,14 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
import { vcsEnvironment } from "../../state/vcs";
import { useAtomCommand } from "../../state/use-atom-command";
import {
actionFailureMessage,
actionFailureTitle,
type ThreadListAction,
} from "./threadActionMessages";
import { presentWorktreeCleanupConfirmation } from "./worktreeCleanupPrompt";

/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
Expand All@@ -22,36 +30,10 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en
);
}

type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

function selectionHaptic(): void {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}

function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}

/** Resolves to true iff the action was dispatched and succeeded. */
function useThreadActionExecutor(
onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
Expand DownExpand Up@@ -195,12 +177,88 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
const previewWorktreeCleanup = useAtomCommand(vcsEnvironment.previewWorktreeCleanup, {
reportFailure: false,
});
const cleanupThreadWorktree = useAtomCommand(vcsEnvironment.cleanupThreadWorktree, {
reportFailure: false,
});

const archiveThread = useCallback(
(thread: EnvironmentThreadShell) => {
void executeAction("archive", thread);
void runArchiveWithWorktreeCleanup({
// Server-authoritative preview; a failure (old server, transient
// error) degrades to a plain archive without a prompt. A thread
// mid-turn keeps the original archive guard: executeAction re-checks
// and surfaces the alert, so it must not be prompted for cleanup.
previewCandidate: async () => {
if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
return null;
}
const preview = await previewWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
return preview._tag === "Success" ? preview.value.candidate : null;
},
confirmRemoval: ({ displayWorktreePath }) =>
presentWorktreeCleanupConfirmation({
isIos: process.env.EXPO_OS === "ios",
displayWorktreePath,
presentAlert: (buttons) => {
Alert.alert(buttons.title, buttons.message, [
{ text: "Keep", style: "cancel", onPress: buttons.onKeep },
{ text: "Remove", style: "destructive", onPress: buttons.onRemove },
]);
},
presentConfirmDialog: (buttons) => {
showConfirmDialog({
title: buttons.title,
message: buttons.message,
cancelText: "Keep",
confirmText: "Remove",
destructive: true,
onConfirm: buttons.onRemove,
onCancel: buttons.onKeep,
});
},
}),
archive: () => executeAction("archive", thread),
isArchiveSuccess: (archived) => archived,
cleanup: async () => {
const result = await cleanupThreadWorktree({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
return {
kind: "failed",
message:
error instanceof Error && error.message.trim().length > 0
? error.message
: "The worktree could not be removed.",
} as const;
}
return { kind: "done", status: result.value.status } as const;
},
// Cleanup problems are nonfatal: the archive itself already
// succeeded.
onCleanupFailed: (displayWorktreePath, message) => {
Alert.alert(
"Thread archived, but worktree removal failed",
`Could not remove ${displayWorktreePath}. ${message}`,
);
},
onCleanupRetained: (displayWorktreePath) => {
Alert.alert(
"Worktree kept",
`${displayWorktreePath} is still used by another active thread.`,
);
},
});
},
[executeAction],
[cleanupThreadWorktree, executeAction, previewWorktreeCleanup],
);
const settleThread = useCallback(
async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true,
Expand Down
102 changes: 102 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
import { WorktreeLifecycleError } from "@t3tools/contracts";
import { ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { describe, expect, it, vi } from "vite-plus/test";

import { actionFailureMessage, actionFailureTitle } from "./threadActionMessages";
import {
buildWorktreeCleanupPrompt,
presentWorktreeCleanupConfirmation,
} from "./worktreeCleanupPrompt";

describe("presentWorktreeCleanupConfirmation", () => {
it("uses the native alert on iOS and never the confirm dialog", async () => {
const presentAlert = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onRemove();
},
);
const presentConfirmDialog = vi.fn();

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "confirmed" });
expect(presentAlert).toHaveBeenCalledTimes(1);
expect(presentConfirmDialog).not.toHaveBeenCalled();
});

it("uses the confirm dialog host elsewhere", async () => {
const presentAlert = vi.fn();
const presentConfirmDialog = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onKeep();
},
);

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "declined" });
expect(presentAlert).not.toHaveBeenCalled();
expect(presentConfirmDialog).toHaveBeenCalledTimes(1);
});

it("resolves declined for Keep and confirmed for Remove", async () => {
const keep = presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert: (buttons) => {
buttons.onKeep();
},
presentConfirmDialog: () => {},
});
await expect(keep).resolves.toEqual({ kind: "declined" });

const remove = presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert: () => {},
presentConfirmDialog: (buttons) => {
buttons.onRemove();
},
});
await expect(remove).resolves.toEqual({ kind: "confirmed" });
});

it("names the worktree in the prompt copy", () => {
const prompt = buildWorktreeCleanupPrompt("feature-1");
expect(prompt.title).toBe("Remove worktree?");
expect(prompt.message).toContain("feature-1");
expect(prompt.message).toContain("branch is kept");
});
});

describe("thread action failure messages", () => {
it("surfaces unarchive restoration errors with the server-provided detail", () => {
const restorationError = new WorktreeLifecycleError({
operation: "restore",
threadId: ThreadId.make("thread-1"),
detail:
"Failed to recreate the worktree at /wt/feature-1 from branch 'feature-1': branch missing. The thread stays archived.",
});
expect(actionFailureTitle("unarchive")).toBe("Could not unarchive thread");
expect(actionFailureMessage("unarchive", Cause.fail(restorationError))).toContain(
"Failed to recreate the worktree",
);
});

it("falls back to a generic message when the cause has no message", () => {
expect(actionFailureMessage("unarchive", Cause.fail(new Error("")))).toBe(
"The thread could not be unarchived.",
);
});
});
49 changes: 49 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import type { WorktreeCleanupConfirmation } from "@t3tools/client-runtime/state/worktreeCleanup";

export interface WorktreeCleanupPromptButtons {
readonly title: string;
readonly message: string;
readonly onKeep: () => void;
readonly onRemove: () => void;
}

export function buildWorktreeCleanupPrompt(displayWorktreePath: string): {
readonly title: string;
readonly message: string;
} {
return {
title: "Remove worktree?",
message: `This thread is the last active one linked to the worktree “${displayWorktreePath}”. Remove it when archiving? The branch is kept.`,
};
}

/**
* Presents the archive-time cleanup confirmation through the platform's
* surface: the native alert on iOS, the in-app confirm dialog elsewhere.
* Keep archives without cleanup; Remove archives and cleans up.
*/
export function presentWorktreeCleanupConfirmation(input: {
readonly isIos: boolean;
readonly displayWorktreePath: string;
readonly presentAlert: (buttons: WorktreeCleanupPromptButtons) => void;
readonly presentConfirmDialog: (buttons: WorktreeCleanupPromptButtons) => void;
}): Promise<WorktreeCleanupConfirmation<never>> {
const { title, message } = buildWorktreeCleanupPrompt(input.displayWorktreePath);
return new Promise((resolve) => {
const buttons: WorktreeCleanupPromptButtons = {
title,
message,
onKeep: () => {
resolve({ kind: "declined" });
},
onRemove: () => {
resolve({ kind: "confirmed" });
},
};
if (input.isIos) {
input.presentAlert(buttons);
return;
}
input.presentConfirmDialog(buttons);
});
}
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsPreviewWorktreeCleanup]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCleanupThreadWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,7 @@ describe("CheckpointDiffQuery.layer", () => {
});
}),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -200,6 +201,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -353,6 +356,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -407,6 +411,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,6 +201,7 @@ describe("OrchestrationEngine", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
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
30 changes: 30 additions & 0 deletions apps/mobile/src/features/home/threadActionMessages.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import * as Cause from "effect/Cause";

export type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

export function actionFailureMessage(
action: ThreadListAction,
cause: Cause.Cause<unknown>,
): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

export function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}
114 changes: 86 additions & 28 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { canSettle } from "@t3tools/client-runtime/state/thread-settled";
import { runArchiveWithWorktreeCleanup } from "@t3tools/client-runtime/state/worktreeCleanup";
import * as Cause from "effect/Cause";
import * as Haptics from "expo-haptics";
import { useCallback, useRef } from "react";
Expand All@@ -11,7 +12,14 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
import { vcsEnvironment } from "../../state/vcs";
import { useAtomCommand } from "../../state/use-atom-command";
import {
actionFailureMessage,
actionFailureTitle,
type ThreadListAction,
} from "./threadActionMessages";
import { presentWorktreeCleanupConfirmation } from "./worktreeCleanupPrompt";

/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
Expand All@@ -22,36 +30,10 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en
);
}

type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

function selectionHaptic(): void {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}

function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}

/** Resolves to true iff the action was dispatched and succeeded. */
function useThreadActionExecutor(
onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
Expand DownExpand Up@@ -195,12 +177,88 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
const previewWorktreeCleanup = useAtomCommand(vcsEnvironment.previewWorktreeCleanup, {
reportFailure: false,
});
const cleanupThreadWorktree = useAtomCommand(vcsEnvironment.cleanupThreadWorktree, {
reportFailure: false,
});

const archiveThread = useCallback(
(thread: EnvironmentThreadShell) => {
void executeAction("archive", thread);
void runArchiveWithWorktreeCleanup({
// Server-authoritative preview; a failure (old server, transient
// error) degrades to a plain archive without a prompt. A thread
// mid-turn keeps the original archive guard: executeAction re-checks
// and surfaces the alert, so it must not be prompted for cleanup.
previewCandidate: async () => {
if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
return null;
}
const preview = await previewWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
return preview._tag === "Success" ? preview.value.candidate : null;
},
confirmRemoval: ({ displayWorktreePath }) =>
presentWorktreeCleanupConfirmation({
isIos: process.env.EXPO_OS === "ios",
displayWorktreePath,
presentAlert: (buttons) => {
Alert.alert(buttons.title, buttons.message, [
{ text: "Keep", style: "cancel", onPress: buttons.onKeep },
{ text: "Remove", style: "destructive", onPress: buttons.onRemove },
]);
},
presentConfirmDialog: (buttons) => {
showConfirmDialog({
title: buttons.title,
message: buttons.message,
cancelText: "Keep",
confirmText: "Remove",
destructive: true,
onConfirm: buttons.onRemove,
onCancel: buttons.onKeep,
});
},
}),
archive: () => executeAction("archive", thread),
isArchiveSuccess: (archived) => archived,
cleanup: async () => {
const result = await cleanupThreadWorktree({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
return {
kind: "failed",
message:
error instanceof Error && error.message.trim().length > 0
? error.message
: "The worktree could not be removed.",
} as const;
}
return { kind: "done", status: result.value.status } as const;
},
// Cleanup problems are nonfatal: the archive itself already
// succeeded.
onCleanupFailed: (displayWorktreePath, message) => {
Alert.alert(
"Thread archived, but worktree removal failed",
`Could not remove ${displayWorktreePath}. ${message}`,
);
},
onCleanupRetained: (displayWorktreePath) => {
Alert.alert(
"Worktree kept",
`${displayWorktreePath} is still used by another active thread.`,
);
},
});
},
[executeAction],
[cleanupThreadWorktree, executeAction, previewWorktreeCleanup],
);
const settleThread = useCallback(
async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true,
Expand Down
102 changes: 102 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
import { WorktreeLifecycleError } from "@t3tools/contracts";
import { ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { describe, expect, it, vi } from "vite-plus/test";

import { actionFailureMessage, actionFailureTitle } from "./threadActionMessages";
import {
buildWorktreeCleanupPrompt,
presentWorktreeCleanupConfirmation,
} from "./worktreeCleanupPrompt";

describe("presentWorktreeCleanupConfirmation", () => {
it("uses the native alert on iOS and never the confirm dialog", async () => {
const presentAlert = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onRemove();
},
);
const presentConfirmDialog = vi.fn();

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "confirmed" });
expect(presentAlert).toHaveBeenCalledTimes(1);
expect(presentConfirmDialog).not.toHaveBeenCalled();
});

it("uses the confirm dialog host elsewhere", async () => {
const presentAlert = vi.fn();
const presentConfirmDialog = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onKeep();
},
);

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "declined" });
expect(presentAlert).not.toHaveBeenCalled();
expect(presentConfirmDialog).toHaveBeenCalledTimes(1);
});

it("resolves declined for Keep and confirmed for Remove", async () => {
const keep = presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert: (buttons) => {
buttons.onKeep();
},
presentConfirmDialog: () => {},
});
await expect(keep).resolves.toEqual({ kind: "declined" });

const remove = presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert: () => {},
presentConfirmDialog: (buttons) => {
buttons.onRemove();
},
});
await expect(remove).resolves.toEqual({ kind: "confirmed" });
});

it("names the worktree in the prompt copy", () => {
const prompt = buildWorktreeCleanupPrompt("feature-1");
expect(prompt.title).toBe("Remove worktree?");
expect(prompt.message).toContain("feature-1");
expect(prompt.message).toContain("branch is kept");
});
});

describe("thread action failure messages", () => {
it("surfaces unarchive restoration errors with the server-provided detail", () => {
const restorationError = new WorktreeLifecycleError({
operation: "restore",
threadId: ThreadId.make("thread-1"),
detail:
"Failed to recreate the worktree at /wt/feature-1 from branch 'feature-1': branch missing. The thread stays archived.",
});
expect(actionFailureTitle("unarchive")).toBe("Could not unarchive thread");
expect(actionFailureMessage("unarchive", Cause.fail(restorationError))).toContain(
"Failed to recreate the worktree",
);
});

it("falls back to a generic message when the cause has no message", () => {
expect(actionFailureMessage("unarchive", Cause.fail(new Error("")))).toBe(
"The thread could not be unarchived.",
);
});
});
49 changes: 49 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import type { WorktreeCleanupConfirmation } from "@t3tools/client-runtime/state/worktreeCleanup";

export interface WorktreeCleanupPromptButtons {
readonly title: string;
readonly message: string;
readonly onKeep: () => void;
readonly onRemove: () => void;
}

export function buildWorktreeCleanupPrompt(displayWorktreePath: string): {
readonly title: string;
readonly message: string;
} {
return {
title: "Remove worktree?",
message: `This thread is the last active one linked to the worktree “${displayWorktreePath}”. Remove it when archiving? The branch is kept.`,
};
}

/**
* Presents the archive-time cleanup confirmation through the platform's
* surface: the native alert on iOS, the in-app confirm dialog elsewhere.
* Keep archives without cleanup; Remove archives and cleans up.
*/
export function presentWorktreeCleanupConfirmation(input: {
readonly isIos: boolean;
readonly displayWorktreePath: string;
readonly presentAlert: (buttons: WorktreeCleanupPromptButtons) => void;
readonly presentConfirmDialog: (buttons: WorktreeCleanupPromptButtons) => void;
}): Promise<WorktreeCleanupConfirmation<never>> {
const { title, message } = buildWorktreeCleanupPrompt(input.displayWorktreePath);
return new Promise((resolve) => {
const buttons: WorktreeCleanupPromptButtons = {
title,
message,
onKeep: () => {
resolve({ kind: "declined" });
},
onRemove: () => {
resolve({ kind: "confirmed" });
},
};
if (input.isIos) {
input.presentAlert(buttons);
return;
}
input.presentConfirmDialog(buttons);
});
}
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsPreviewWorktreeCleanup]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCleanupThreadWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,7 @@ describe("CheckpointDiffQuery.layer", () => {
});
}),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -200,6 +201,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -353,6 +356,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -407,6 +411,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,6 +201,7 @@ describe("OrchestrationEngine", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
30 changes: 30 additions & 0 deletions apps/mobile/src/features/home/threadActionMessages.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import * as Cause from "effect/Cause";

export type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

export function actionFailureMessage(
action: ThreadListAction,
cause: Cause.Cause<unknown>,
): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

export function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}
114 changes: 86 additions & 28 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { canSettle } from "@t3tools/client-runtime/state/thread-settled";
import { runArchiveWithWorktreeCleanup } from "@t3tools/client-runtime/state/worktreeCleanup";
import * as Cause from "effect/Cause";
import * as Haptics from "expo-haptics";
import { useCallback, useRef } from "react";
Expand All@@ -11,7 +12,14 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
import { vcsEnvironment } from "../../state/vcs";
import { useAtomCommand } from "../../state/use-atom-command";
import {
actionFailureMessage,
actionFailureTitle,
type ThreadListAction,
} from "./threadActionMessages";
import { presentWorktreeCleanupConfirmation } from "./worktreeCleanupPrompt";

/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
Expand All@@ -22,36 +30,10 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en
);
}

type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

function selectionHaptic(): void {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}

function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}

/** Resolves to true iff the action was dispatched and succeeded. */
function useThreadActionExecutor(
onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
Expand DownExpand Up@@ -195,12 +177,88 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
const previewWorktreeCleanup = useAtomCommand(vcsEnvironment.previewWorktreeCleanup, {
reportFailure: false,
});
const cleanupThreadWorktree = useAtomCommand(vcsEnvironment.cleanupThreadWorktree, {
reportFailure: false,
});

const archiveThread = useCallback(
(thread: EnvironmentThreadShell) => {
void executeAction("archive", thread);
void runArchiveWithWorktreeCleanup({
// Server-authoritative preview; a failure (old server, transient
// error) degrades to a plain archive without a prompt. A thread
// mid-turn keeps the original archive guard: executeAction re-checks
// and surfaces the alert, so it must not be prompted for cleanup.
previewCandidate: async () => {
if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
return null;
}
const preview = await previewWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
return preview._tag === "Success" ? preview.value.candidate : null;
},
confirmRemoval: ({ displayWorktreePath }) =>
presentWorktreeCleanupConfirmation({
isIos: process.env.EXPO_OS === "ios",
displayWorktreePath,
presentAlert: (buttons) => {
Alert.alert(buttons.title, buttons.message, [
{ text: "Keep", style: "cancel", onPress: buttons.onKeep },
{ text: "Remove", style: "destructive", onPress: buttons.onRemove },
]);
},
presentConfirmDialog: (buttons) => {
showConfirmDialog({
title: buttons.title,
message: buttons.message,
cancelText: "Keep",
confirmText: "Remove",
destructive: true,
onConfirm: buttons.onRemove,
onCancel: buttons.onKeep,
});
},
}),
archive: () => executeAction("archive", thread),
isArchiveSuccess: (archived) => archived,
cleanup: async () => {
const result = await cleanupThreadWorktree({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
return {
kind: "failed",
message:
error instanceof Error && error.message.trim().length > 0
? error.message
: "The worktree could not be removed.",
} as const;
}
return { kind: "done", status: result.value.status } as const;
},
// Cleanup problems are nonfatal: the archive itself already
// succeeded.
onCleanupFailed: (displayWorktreePath, message) => {
Alert.alert(
"Thread archived, but worktree removal failed",
`Could not remove ${displayWorktreePath}. ${message}`,
);
},
onCleanupRetained: (displayWorktreePath) => {
Alert.alert(
"Worktree kept",
`${displayWorktreePath} is still used by another active thread.`,
);
},
});
},
[executeAction],
[cleanupThreadWorktree, executeAction, previewWorktreeCleanup],
);
const settleThread = useCallback(
async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true,
Expand Down
102 changes: 102 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
import { WorktreeLifecycleError } from "@t3tools/contracts";
import { ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { describe, expect, it, vi } from "vite-plus/test";

import { actionFailureMessage, actionFailureTitle } from "./threadActionMessages";
import {
buildWorktreeCleanupPrompt,
presentWorktreeCleanupConfirmation,
} from "./worktreeCleanupPrompt";

describe("presentWorktreeCleanupConfirmation", () => {
it("uses the native alert on iOS and never the confirm dialog", async () => {
const presentAlert = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onRemove();
},
);
const presentConfirmDialog = vi.fn();

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "confirmed" });
expect(presentAlert).toHaveBeenCalledTimes(1);
expect(presentConfirmDialog).not.toHaveBeenCalled();
});

it("uses the confirm dialog host elsewhere", async () => {
const presentAlert = vi.fn();
const presentConfirmDialog = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onKeep();
},
);

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "declined" });
expect(presentAlert).not.toHaveBeenCalled();
expect(presentConfirmDialog).toHaveBeenCalledTimes(1);
});

it("resolves declined for Keep and confirmed for Remove", async () => {
const keep = presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert: (buttons) => {
buttons.onKeep();
},
presentConfirmDialog: () => {},
});
await expect(keep).resolves.toEqual({ kind: "declined" });

const remove = presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert: () => {},
presentConfirmDialog: (buttons) => {
buttons.onRemove();
},
});
await expect(remove).resolves.toEqual({ kind: "confirmed" });
});

it("names the worktree in the prompt copy", () => {
const prompt = buildWorktreeCleanupPrompt("feature-1");
expect(prompt.title).toBe("Remove worktree?");
expect(prompt.message).toContain("feature-1");
expect(prompt.message).toContain("branch is kept");
});
});

describe("thread action failure messages", () => {
it("surfaces unarchive restoration errors with the server-provided detail", () => {
const restorationError = new WorktreeLifecycleError({
operation: "restore",
threadId: ThreadId.make("thread-1"),
detail:
"Failed to recreate the worktree at /wt/feature-1 from branch 'feature-1': branch missing. The thread stays archived.",
});
expect(actionFailureTitle("unarchive")).toBe("Could not unarchive thread");
expect(actionFailureMessage("unarchive", Cause.fail(restorationError))).toContain(
"Failed to recreate the worktree",
);
});

it("falls back to a generic message when the cause has no message", () => {
expect(actionFailureMessage("unarchive", Cause.fail(new Error("")))).toBe(
"The thread could not be unarchived.",
);
});
});
49 changes: 49 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import type { WorktreeCleanupConfirmation } from "@t3tools/client-runtime/state/worktreeCleanup";

export interface WorktreeCleanupPromptButtons {
readonly title: string;
readonly message: string;
readonly onKeep: () => void;
readonly onRemove: () => void;
}

export function buildWorktreeCleanupPrompt(displayWorktreePath: string): {
readonly title: string;
readonly message: string;
} {
return {
title: "Remove worktree?",
message: `This thread is the last active one linked to the worktree “${displayWorktreePath}”. Remove it when archiving? The branch is kept.`,
};
}

/**
* Presents the archive-time cleanup confirmation through the platform's
* surface: the native alert on iOS, the in-app confirm dialog elsewhere.
* Keep archives without cleanup; Remove archives and cleans up.
*/
export function presentWorktreeCleanupConfirmation(input: {
readonly isIos: boolean;
readonly displayWorktreePath: string;
readonly presentAlert: (buttons: WorktreeCleanupPromptButtons) => void;
readonly presentConfirmDialog: (buttons: WorktreeCleanupPromptButtons) => void;
}): Promise<WorktreeCleanupConfirmation<never>> {
const { title, message } = buildWorktreeCleanupPrompt(input.displayWorktreePath);
return new Promise((resolve) => {
const buttons: WorktreeCleanupPromptButtons = {
title,
message,
onKeep: () => {
resolve({ kind: "declined" });
},
onRemove: () => {
resolve({ kind: "confirmed" });
},
};
if (input.isIos) {
input.presentAlert(buttons);
return;
}
input.presentConfirmDialog(buttons);
});
}
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsPreviewWorktreeCleanup]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCleanupThreadWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,7 @@ describe("CheckpointDiffQuery.layer", () => {
});
}),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -200,6 +201,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -353,6 +356,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -407,6 +411,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,6 +201,7 @@ describe("OrchestrationEngine", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
30 changes: 30 additions & 0 deletions apps/mobile/src/features/home/threadActionMessages.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import * as Cause from "effect/Cause";

export type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

export function actionFailureMessage(
action: ThreadListAction,
cause: Cause.Cause<unknown>,
): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

export function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}
114 changes: 86 additions & 28 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { canSettle } from "@t3tools/client-runtime/state/thread-settled";
import { runArchiveWithWorktreeCleanup } from "@t3tools/client-runtime/state/worktreeCleanup";
import * as Cause from "effect/Cause";
import * as Haptics from "expo-haptics";
import { useCallback, useRef } from "react";
Expand All@@ -11,7 +12,14 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
import { vcsEnvironment } from "../../state/vcs";
import { useAtomCommand } from "../../state/use-atom-command";
import {
actionFailureMessage,
actionFailureTitle,
type ThreadListAction,
} from "./threadActionMessages";
import { presentWorktreeCleanupConfirmation } from "./worktreeCleanupPrompt";

/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
Expand All@@ -22,36 +30,10 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en
);
}

type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

function selectionHaptic(): void {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}

function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}

/** Resolves to true iff the action was dispatched and succeeded. */
function useThreadActionExecutor(
onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
Expand DownExpand Up@@ -195,12 +177,88 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
const previewWorktreeCleanup = useAtomCommand(vcsEnvironment.previewWorktreeCleanup, {
reportFailure: false,
});
const cleanupThreadWorktree = useAtomCommand(vcsEnvironment.cleanupThreadWorktree, {
reportFailure: false,
});

const archiveThread = useCallback(
(thread: EnvironmentThreadShell) => {
void executeAction("archive", thread);
void runArchiveWithWorktreeCleanup({
// Server-authoritative preview; a failure (old server, transient
// error) degrades to a plain archive without a prompt. A thread
// mid-turn keeps the original archive guard: executeAction re-checks
// and surfaces the alert, so it must not be prompted for cleanup.
previewCandidate: async () => {
if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
return null;
}
const preview = await previewWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
return preview._tag === "Success" ? preview.value.candidate : null;
},
confirmRemoval: ({ displayWorktreePath }) =>
presentWorktreeCleanupConfirmation({
isIos: process.env.EXPO_OS === "ios",
displayWorktreePath,
presentAlert: (buttons) => {
Alert.alert(buttons.title, buttons.message, [
{ text: "Keep", style: "cancel", onPress: buttons.onKeep },
{ text: "Remove", style: "destructive", onPress: buttons.onRemove },
]);
},
presentConfirmDialog: (buttons) => {
showConfirmDialog({
title: buttons.title,
message: buttons.message,
cancelText: "Keep",
confirmText: "Remove",
destructive: true,
onConfirm: buttons.onRemove,
onCancel: buttons.onKeep,
});
},
}),
archive: () => executeAction("archive", thread),
isArchiveSuccess: (archived) => archived,
cleanup: async () => {
const result = await cleanupThreadWorktree({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
return {
kind: "failed",
message:
error instanceof Error && error.message.trim().length > 0
? error.message
: "The worktree could not be removed.",
} as const;
}
return { kind: "done", status: result.value.status } as const;
},
// Cleanup problems are nonfatal: the archive itself already
// succeeded.
onCleanupFailed: (displayWorktreePath, message) => {
Alert.alert(
"Thread archived, but worktree removal failed",
`Could not remove ${displayWorktreePath}. ${message}`,
);
},
onCleanupRetained: (displayWorktreePath) => {
Alert.alert(
"Worktree kept",
`${displayWorktreePath} is still used by another active thread.`,
);
},
});
},
[executeAction],
[cleanupThreadWorktree, executeAction, previewWorktreeCleanup],
);
const settleThread = useCallback(
async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true,
Expand Down
102 changes: 102 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
import { WorktreeLifecycleError } from "@t3tools/contracts";
import { ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { describe, expect, it, vi } from "vite-plus/test";

import { actionFailureMessage, actionFailureTitle } from "./threadActionMessages";
import {
buildWorktreeCleanupPrompt,
presentWorktreeCleanupConfirmation,
} from "./worktreeCleanupPrompt";

describe("presentWorktreeCleanupConfirmation", () => {
it("uses the native alert on iOS and never the confirm dialog", async () => {
const presentAlert = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onRemove();
},
);
const presentConfirmDialog = vi.fn();

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "confirmed" });
expect(presentAlert).toHaveBeenCalledTimes(1);
expect(presentConfirmDialog).not.toHaveBeenCalled();
});

it("uses the confirm dialog host elsewhere", async () => {
const presentAlert = vi.fn();
const presentConfirmDialog = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onKeep();
},
);

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "declined" });
expect(presentAlert).not.toHaveBeenCalled();
expect(presentConfirmDialog).toHaveBeenCalledTimes(1);
});

it("resolves declined for Keep and confirmed for Remove", async () => {
const keep = presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert: (buttons) => {
buttons.onKeep();
},
presentConfirmDialog: () => {},
});
await expect(keep).resolves.toEqual({ kind: "declined" });

const remove = presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert: () => {},
presentConfirmDialog: (buttons) => {
buttons.onRemove();
},
});
await expect(remove).resolves.toEqual({ kind: "confirmed" });
});

it("names the worktree in the prompt copy", () => {
const prompt = buildWorktreeCleanupPrompt("feature-1");
expect(prompt.title).toBe("Remove worktree?");
expect(prompt.message).toContain("feature-1");
expect(prompt.message).toContain("branch is kept");
});
});

describe("thread action failure messages", () => {
it("surfaces unarchive restoration errors with the server-provided detail", () => {
const restorationError = new WorktreeLifecycleError({
operation: "restore",
threadId: ThreadId.make("thread-1"),
detail:
"Failed to recreate the worktree at /wt/feature-1 from branch 'feature-1': branch missing. The thread stays archived.",
});
expect(actionFailureTitle("unarchive")).toBe("Could not unarchive thread");
expect(actionFailureMessage("unarchive", Cause.fail(restorationError))).toContain(
"Failed to recreate the worktree",
);
});

it("falls back to a generic message when the cause has no message", () => {
expect(actionFailureMessage("unarchive", Cause.fail(new Error("")))).toBe(
"The thread could not be unarchived.",
);
});
});
49 changes: 49 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import type { WorktreeCleanupConfirmation } from "@t3tools/client-runtime/state/worktreeCleanup";

export interface WorktreeCleanupPromptButtons {
readonly title: string;
readonly message: string;
readonly onKeep: () => void;
readonly onRemove: () => void;
}

export function buildWorktreeCleanupPrompt(displayWorktreePath: string): {
readonly title: string;
readonly message: string;
} {
return {
title: "Remove worktree?",
message: `This thread is the last active one linked to the worktree “${displayWorktreePath}”. Remove it when archiving? The branch is kept.`,
};
}

/**
* Presents the archive-time cleanup confirmation through the platform's
* surface: the native alert on iOS, the in-app confirm dialog elsewhere.
* Keep archives without cleanup; Remove archives and cleans up.
*/
export function presentWorktreeCleanupConfirmation(input: {
readonly isIos: boolean;
readonly displayWorktreePath: string;
readonly presentAlert: (buttons: WorktreeCleanupPromptButtons) => void;
readonly presentConfirmDialog: (buttons: WorktreeCleanupPromptButtons) => void;
}): Promise<WorktreeCleanupConfirmation<never>> {
const { title, message } = buildWorktreeCleanupPrompt(input.displayWorktreePath);
return new Promise((resolve) => {
const buttons: WorktreeCleanupPromptButtons = {
title,
message,
onKeep: () => {
resolve({ kind: "declined" });
},
onRemove: () => {
resolve({ kind: "confirmed" });
},
};
if (input.isIos) {
input.presentAlert(buttons);
return;
}
input.presentConfirmDialog(buttons);
});
}
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsPreviewWorktreeCleanup]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCleanupThreadWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,7 @@ describe("CheckpointDiffQuery.layer", () => {
});
}),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -200,6 +201,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -353,6 +356,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -407,6 +411,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,6 +201,7 @@ describe("OrchestrationEngine", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
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
30 changes: 30 additions & 0 deletions apps/mobile/src/features/home/threadActionMessages.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import * as Cause from "effect/Cause";

export type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

export function actionFailureMessage(
action: ThreadListAction,
cause: Cause.Cause<unknown>,
): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

export function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}
114 changes: 86 additions & 28 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { canSettle } from "@t3tools/client-runtime/state/thread-settled";
import { runArchiveWithWorktreeCleanup } from "@t3tools/client-runtime/state/worktreeCleanup";
import * as Cause from "effect/Cause";
import * as Haptics from "expo-haptics";
import { useCallback, useRef } from "react";
Expand All@@ -11,7 +12,14 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
import { vcsEnvironment } from "../../state/vcs";
import { useAtomCommand } from "../../state/use-atom-command";
import {
actionFailureMessage,
actionFailureTitle,
type ThreadListAction,
} from "./threadActionMessages";
import { presentWorktreeCleanupConfirmation } from "./worktreeCleanupPrompt";

/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
Expand All@@ -22,36 +30,10 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en
);
}

type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

function selectionHaptic(): void {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}

function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}

/** Resolves to true iff the action was dispatched and succeeded. */
function useThreadActionExecutor(
onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
Expand DownExpand Up@@ -195,12 +177,88 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
const previewWorktreeCleanup = useAtomCommand(vcsEnvironment.previewWorktreeCleanup, {
reportFailure: false,
});
const cleanupThreadWorktree = useAtomCommand(vcsEnvironment.cleanupThreadWorktree, {
reportFailure: false,
});

const archiveThread = useCallback(
(thread: EnvironmentThreadShell) => {
void executeAction("archive", thread);
void runArchiveWithWorktreeCleanup({
// Server-authoritative preview; a failure (old server, transient
// error) degrades to a plain archive without a prompt. A thread
// mid-turn keeps the original archive guard: executeAction re-checks
// and surfaces the alert, so it must not be prompted for cleanup.
previewCandidate: async () => {
if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
return null;
}
const preview = await previewWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
return preview._tag === "Success" ? preview.value.candidate : null;
},
confirmRemoval: ({ displayWorktreePath }) =>
presentWorktreeCleanupConfirmation({
isIos: process.env.EXPO_OS === "ios",
displayWorktreePath,
presentAlert: (buttons) => {
Alert.alert(buttons.title, buttons.message, [
{ text: "Keep", style: "cancel", onPress: buttons.onKeep },
{ text: "Remove", style: "destructive", onPress: buttons.onRemove },
]);
},
presentConfirmDialog: (buttons) => {
showConfirmDialog({
title: buttons.title,
message: buttons.message,
cancelText: "Keep",
confirmText: "Remove",
destructive: true,
onConfirm: buttons.onRemove,
onCancel: buttons.onKeep,
});
},
}),
archive: () => executeAction("archive", thread),
isArchiveSuccess: (archived) => archived,
cleanup: async () => {
const result = await cleanupThreadWorktree({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
return {
kind: "failed",
message:
error instanceof Error && error.message.trim().length > 0
? error.message
: "The worktree could not be removed.",
} as const;
}
return { kind: "done", status: result.value.status } as const;
},
// Cleanup problems are nonfatal: the archive itself already
// succeeded.
onCleanupFailed: (displayWorktreePath, message) => {
Alert.alert(
"Thread archived, but worktree removal failed",
`Could not remove ${displayWorktreePath}. ${message}`,
);
},
onCleanupRetained: (displayWorktreePath) => {
Alert.alert(
"Worktree kept",
`${displayWorktreePath} is still used by another active thread.`,
);
},
});
},
[executeAction],
[cleanupThreadWorktree, executeAction, previewWorktreeCleanup],
);
const settleThread = useCallback(
async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true,
Expand Down
102 changes: 102 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
import { WorktreeLifecycleError } from "@t3tools/contracts";
import { ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { describe, expect, it, vi } from "vite-plus/test";

import { actionFailureMessage, actionFailureTitle } from "./threadActionMessages";
import {
buildWorktreeCleanupPrompt,
presentWorktreeCleanupConfirmation,
} from "./worktreeCleanupPrompt";

describe("presentWorktreeCleanupConfirmation", () => {
it("uses the native alert on iOS and never the confirm dialog", async () => {
const presentAlert = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onRemove();
},
);
const presentConfirmDialog = vi.fn();

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "confirmed" });
expect(presentAlert).toHaveBeenCalledTimes(1);
expect(presentConfirmDialog).not.toHaveBeenCalled();
});

it("uses the confirm dialog host elsewhere", async () => {
const presentAlert = vi.fn();
const presentConfirmDialog = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onKeep();
},
);

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "declined" });
expect(presentAlert).not.toHaveBeenCalled();
expect(presentConfirmDialog).toHaveBeenCalledTimes(1);
});

it("resolves declined for Keep and confirmed for Remove", async () => {
const keep = presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert: (buttons) => {
buttons.onKeep();
},
presentConfirmDialog: () => {},
});
await expect(keep).resolves.toEqual({ kind: "declined" });

const remove = presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert: () => {},
presentConfirmDialog: (buttons) => {
buttons.onRemove();
},
});
await expect(remove).resolves.toEqual({ kind: "confirmed" });
});

it("names the worktree in the prompt copy", () => {
const prompt = buildWorktreeCleanupPrompt("feature-1");
expect(prompt.title).toBe("Remove worktree?");
expect(prompt.message).toContain("feature-1");
expect(prompt.message).toContain("branch is kept");
});
});

describe("thread action failure messages", () => {
it("surfaces unarchive restoration errors with the server-provided detail", () => {
const restorationError = new WorktreeLifecycleError({
operation: "restore",
threadId: ThreadId.make("thread-1"),
detail:
"Failed to recreate the worktree at /wt/feature-1 from branch 'feature-1': branch missing. The thread stays archived.",
});
expect(actionFailureTitle("unarchive")).toBe("Could not unarchive thread");
expect(actionFailureMessage("unarchive", Cause.fail(restorationError))).toContain(
"Failed to recreate the worktree",
);
});

it("falls back to a generic message when the cause has no message", () => {
expect(actionFailureMessage("unarchive", Cause.fail(new Error("")))).toBe(
"The thread could not be unarchived.",
);
});
});
49 changes: 49 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import type { WorktreeCleanupConfirmation } from "@t3tools/client-runtime/state/worktreeCleanup";

export interface WorktreeCleanupPromptButtons {
readonly title: string;
readonly message: string;
readonly onKeep: () => void;
readonly onRemove: () => void;
}

export function buildWorktreeCleanupPrompt(displayWorktreePath: string): {
readonly title: string;
readonly message: string;
} {
return {
title: "Remove worktree?",
message: `This thread is the last active one linked to the worktree “${displayWorktreePath}”. Remove it when archiving? The branch is kept.`,
};
}

/**
* Presents the archive-time cleanup confirmation through the platform's
* surface: the native alert on iOS, the in-app confirm dialog elsewhere.
* Keep archives without cleanup; Remove archives and cleans up.
*/
export function presentWorktreeCleanupConfirmation(input: {
readonly isIos: boolean;
readonly displayWorktreePath: string;
readonly presentAlert: (buttons: WorktreeCleanupPromptButtons) => void;
readonly presentConfirmDialog: (buttons: WorktreeCleanupPromptButtons) => void;
}): Promise<WorktreeCleanupConfirmation<never>> {
const { title, message } = buildWorktreeCleanupPrompt(input.displayWorktreePath);
return new Promise((resolve) => {
const buttons: WorktreeCleanupPromptButtons = {
title,
message,
onKeep: () => {
resolve({ kind: "declined" });
},
onRemove: () => {
resolve({ kind: "confirmed" });
},
};
if (input.isIos) {
input.presentAlert(buttons);
return;
}
input.presentConfirmDialog(buttons);
});
}
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsPreviewWorktreeCleanup]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCleanupThreadWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,7 @@ describe("CheckpointDiffQuery.layer", () => {
});
}),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -200,6 +201,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -353,6 +356,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -407,6 +411,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,6 +201,7 @@ describe("OrchestrationEngine", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
30 changes: 30 additions & 0 deletions apps/mobile/src/features/home/threadActionMessages.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import * as Cause from "effect/Cause";

export type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

export function actionFailureMessage(
action: ThreadListAction,
cause: Cause.Cause<unknown>,
): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

export function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}
114 changes: 86 additions & 28 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { canSettle } from "@t3tools/client-runtime/state/thread-settled";
import { runArchiveWithWorktreeCleanup } from "@t3tools/client-runtime/state/worktreeCleanup";
import * as Cause from "effect/Cause";
import * as Haptics from "expo-haptics";
import { useCallback, useRef } from "react";
Expand All@@ -11,7 +12,14 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
import { vcsEnvironment } from "../../state/vcs";
import { useAtomCommand } from "../../state/use-atom-command";
import {
actionFailureMessage,
actionFailureTitle,
type ThreadListAction,
} from "./threadActionMessages";
import { presentWorktreeCleanupConfirmation } from "./worktreeCleanupPrompt";

/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
Expand All@@ -22,36 +30,10 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en
);
}

type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

function selectionHaptic(): void {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}

function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}

/** Resolves to true iff the action was dispatched and succeeded. */
function useThreadActionExecutor(
onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
Expand DownExpand Up@@ -195,12 +177,88 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
const previewWorktreeCleanup = useAtomCommand(vcsEnvironment.previewWorktreeCleanup, {
reportFailure: false,
});
const cleanupThreadWorktree = useAtomCommand(vcsEnvironment.cleanupThreadWorktree, {
reportFailure: false,
});

const archiveThread = useCallback(
(thread: EnvironmentThreadShell) => {
void executeAction("archive", thread);
void runArchiveWithWorktreeCleanup({
// Server-authoritative preview; a failure (old server, transient
// error) degrades to a plain archive without a prompt. A thread
// mid-turn keeps the original archive guard: executeAction re-checks
// and surfaces the alert, so it must not be prompted for cleanup.
previewCandidate: async () => {
if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
return null;
}
const preview = await previewWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
return preview._tag === "Success" ? preview.value.candidate : null;
},
confirmRemoval: ({ displayWorktreePath }) =>
presentWorktreeCleanupConfirmation({
isIos: process.env.EXPO_OS === "ios",
displayWorktreePath,
presentAlert: (buttons) => {
Alert.alert(buttons.title, buttons.message, [
{ text: "Keep", style: "cancel", onPress: buttons.onKeep },
{ text: "Remove", style: "destructive", onPress: buttons.onRemove },
]);
},
presentConfirmDialog: (buttons) => {
showConfirmDialog({
title: buttons.title,
message: buttons.message,
cancelText: "Keep",
confirmText: "Remove",
destructive: true,
onConfirm: buttons.onRemove,
onCancel: buttons.onKeep,
});
},
}),
archive: () => executeAction("archive", thread),
isArchiveSuccess: (archived) => archived,
cleanup: async () => {
const result = await cleanupThreadWorktree({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
return {
kind: "failed",
message:
error instanceof Error && error.message.trim().length > 0
? error.message
: "The worktree could not be removed.",
} as const;
}
return { kind: "done", status: result.value.status } as const;
},
// Cleanup problems are nonfatal: the archive itself already
// succeeded.
onCleanupFailed: (displayWorktreePath, message) => {
Alert.alert(
"Thread archived, but worktree removal failed",
`Could not remove ${displayWorktreePath}. ${message}`,
);
},
onCleanupRetained: (displayWorktreePath) => {
Alert.alert(
"Worktree kept",
`${displayWorktreePath} is still used by another active thread.`,
);
},
});
},
[executeAction],
[cleanupThreadWorktree, executeAction, previewWorktreeCleanup],
);
const settleThread = useCallback(
async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true,
Expand Down
102 changes: 102 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
import { WorktreeLifecycleError } from "@t3tools/contracts";
import { ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { describe, expect, it, vi } from "vite-plus/test";

import { actionFailureMessage, actionFailureTitle } from "./threadActionMessages";
import {
buildWorktreeCleanupPrompt,
presentWorktreeCleanupConfirmation,
} from "./worktreeCleanupPrompt";

describe("presentWorktreeCleanupConfirmation", () => {
it("uses the native alert on iOS and never the confirm dialog", async () => {
const presentAlert = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onRemove();
},
);
const presentConfirmDialog = vi.fn();

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "confirmed" });
expect(presentAlert).toHaveBeenCalledTimes(1);
expect(presentConfirmDialog).not.toHaveBeenCalled();
});

it("uses the confirm dialog host elsewhere", async () => {
const presentAlert = vi.fn();
const presentConfirmDialog = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onKeep();
},
);

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "declined" });
expect(presentAlert).not.toHaveBeenCalled();
expect(presentConfirmDialog).toHaveBeenCalledTimes(1);
});

it("resolves declined for Keep and confirmed for Remove", async () => {
const keep = presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert: (buttons) => {
buttons.onKeep();
},
presentConfirmDialog: () => {},
});
await expect(keep).resolves.toEqual({ kind: "declined" });

const remove = presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert: () => {},
presentConfirmDialog: (buttons) => {
buttons.onRemove();
},
});
await expect(remove).resolves.toEqual({ kind: "confirmed" });
});

it("names the worktree in the prompt copy", () => {
const prompt = buildWorktreeCleanupPrompt("feature-1");
expect(prompt.title).toBe("Remove worktree?");
expect(prompt.message).toContain("feature-1");
expect(prompt.message).toContain("branch is kept");
});
});

describe("thread action failure messages", () => {
it("surfaces unarchive restoration errors with the server-provided detail", () => {
const restorationError = new WorktreeLifecycleError({
operation: "restore",
threadId: ThreadId.make("thread-1"),
detail:
"Failed to recreate the worktree at /wt/feature-1 from branch 'feature-1': branch missing. The thread stays archived.",
});
expect(actionFailureTitle("unarchive")).toBe("Could not unarchive thread");
expect(actionFailureMessage("unarchive", Cause.fail(restorationError))).toContain(
"Failed to recreate the worktree",
);
});

it("falls back to a generic message when the cause has no message", () => {
expect(actionFailureMessage("unarchive", Cause.fail(new Error("")))).toBe(
"The thread could not be unarchived.",
);
});
});
49 changes: 49 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import type { WorktreeCleanupConfirmation } from "@t3tools/client-runtime/state/worktreeCleanup";

export interface WorktreeCleanupPromptButtons {
readonly title: string;
readonly message: string;
readonly onKeep: () => void;
readonly onRemove: () => void;
}

export function buildWorktreeCleanupPrompt(displayWorktreePath: string): {
readonly title: string;
readonly message: string;
} {
return {
title: "Remove worktree?",
message: `This thread is the last active one linked to the worktree “${displayWorktreePath}”. Remove it when archiving? The branch is kept.`,
};
}

/**
* Presents the archive-time cleanup confirmation through the platform's
* surface: the native alert on iOS, the in-app confirm dialog elsewhere.
* Keep archives without cleanup; Remove archives and cleans up.
*/
export function presentWorktreeCleanupConfirmation(input: {
readonly isIos: boolean;
readonly displayWorktreePath: string;
readonly presentAlert: (buttons: WorktreeCleanupPromptButtons) => void;
readonly presentConfirmDialog: (buttons: WorktreeCleanupPromptButtons) => void;
}): Promise<WorktreeCleanupConfirmation<never>> {
const { title, message } = buildWorktreeCleanupPrompt(input.displayWorktreePath);
return new Promise((resolve) => {
const buttons: WorktreeCleanupPromptButtons = {
title,
message,
onKeep: () => {
resolve({ kind: "declined" });
},
onRemove: () => {
resolve({ kind: "confirmed" });
},
};
if (input.isIos) {
input.presentAlert(buttons);
return;
}
input.presentConfirmDialog(buttons);
});
}
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsPreviewWorktreeCleanup]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCleanupThreadWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,7 @@ describe("CheckpointDiffQuery.layer", () => {
});
}),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -200,6 +201,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -353,6 +356,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -407,6 +411,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,6 +201,7 @@ describe("OrchestrationEngine", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
30 changes: 30 additions & 0 deletions apps/mobile/src/features/home/threadActionMessages.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import * as Cause from "effect/Cause";

export type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

export function actionFailureMessage(
action: ThreadListAction,
cause: Cause.Cause<unknown>,
): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

export function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}
114 changes: 86 additions & 28 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { canSettle } from "@t3tools/client-runtime/state/thread-settled";
import { runArchiveWithWorktreeCleanup } from "@t3tools/client-runtime/state/worktreeCleanup";
import * as Cause from "effect/Cause";
import * as Haptics from "expo-haptics";
import { useCallback, useRef } from "react";
Expand All@@ -11,7 +12,14 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
import { vcsEnvironment } from "../../state/vcs";
import { useAtomCommand } from "../../state/use-atom-command";
import {
actionFailureMessage,
actionFailureTitle,
type ThreadListAction,
} from "./threadActionMessages";
import { presentWorktreeCleanupConfirmation } from "./worktreeCleanupPrompt";

/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
Expand All@@ -22,36 +30,10 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en
);
}

type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

function selectionHaptic(): void {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}

function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}

/** Resolves to true iff the action was dispatched and succeeded. */
function useThreadActionExecutor(
onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
Expand DownExpand Up@@ -195,12 +177,88 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
const previewWorktreeCleanup = useAtomCommand(vcsEnvironment.previewWorktreeCleanup, {
reportFailure: false,
});
const cleanupThreadWorktree = useAtomCommand(vcsEnvironment.cleanupThreadWorktree, {
reportFailure: false,
});

const archiveThread = useCallback(
(thread: EnvironmentThreadShell) => {
void executeAction("archive", thread);
void runArchiveWithWorktreeCleanup({
// Server-authoritative preview; a failure (old server, transient
// error) degrades to a plain archive without a prompt. A thread
// mid-turn keeps the original archive guard: executeAction re-checks
// and surfaces the alert, so it must not be prompted for cleanup.
previewCandidate: async () => {
if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
return null;
}
const preview = await previewWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
return preview._tag === "Success" ? preview.value.candidate : null;
},
confirmRemoval: ({ displayWorktreePath }) =>
presentWorktreeCleanupConfirmation({
isIos: process.env.EXPO_OS === "ios",
displayWorktreePath,
presentAlert: (buttons) => {
Alert.alert(buttons.title, buttons.message, [
{ text: "Keep", style: "cancel", onPress: buttons.onKeep },
{ text: "Remove", style: "destructive", onPress: buttons.onRemove },
]);
},
presentConfirmDialog: (buttons) => {
showConfirmDialog({
title: buttons.title,
message: buttons.message,
cancelText: "Keep",
confirmText: "Remove",
destructive: true,
onConfirm: buttons.onRemove,
onCancel: buttons.onKeep,
});
},
}),
archive: () => executeAction("archive", thread),
isArchiveSuccess: (archived) => archived,
cleanup: async () => {
const result = await cleanupThreadWorktree({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
return {
kind: "failed",
message:
error instanceof Error && error.message.trim().length > 0
? error.message
: "The worktree could not be removed.",
} as const;
}
return { kind: "done", status: result.value.status } as const;
},
// Cleanup problems are nonfatal: the archive itself already
// succeeded.
onCleanupFailed: (displayWorktreePath, message) => {
Alert.alert(
"Thread archived, but worktree removal failed",
`Could not remove ${displayWorktreePath}. ${message}`,
);
},
onCleanupRetained: (displayWorktreePath) => {
Alert.alert(
"Worktree kept",
`${displayWorktreePath} is still used by another active thread.`,
);
},
});
},
[executeAction],
[cleanupThreadWorktree, executeAction, previewWorktreeCleanup],
);
const settleThread = useCallback(
async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true,
Expand Down
102 changes: 102 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
import { WorktreeLifecycleError } from "@t3tools/contracts";
import { ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { describe, expect, it, vi } from "vite-plus/test";

import { actionFailureMessage, actionFailureTitle } from "./threadActionMessages";
import {
buildWorktreeCleanupPrompt,
presentWorktreeCleanupConfirmation,
} from "./worktreeCleanupPrompt";

describe("presentWorktreeCleanupConfirmation", () => {
it("uses the native alert on iOS and never the confirm dialog", async () => {
const presentAlert = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onRemove();
},
);
const presentConfirmDialog = vi.fn();

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "confirmed" });
expect(presentAlert).toHaveBeenCalledTimes(1);
expect(presentConfirmDialog).not.toHaveBeenCalled();
});

it("uses the confirm dialog host elsewhere", async () => {
const presentAlert = vi.fn();
const presentConfirmDialog = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onKeep();
},
);

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "declined" });
expect(presentAlert).not.toHaveBeenCalled();
expect(presentConfirmDialog).toHaveBeenCalledTimes(1);
});

it("resolves declined for Keep and confirmed for Remove", async () => {
const keep = presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert: (buttons) => {
buttons.onKeep();
},
presentConfirmDialog: () => {},
});
await expect(keep).resolves.toEqual({ kind: "declined" });

const remove = presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert: () => {},
presentConfirmDialog: (buttons) => {
buttons.onRemove();
},
});
await expect(remove).resolves.toEqual({ kind: "confirmed" });
});

it("names the worktree in the prompt copy", () => {
const prompt = buildWorktreeCleanupPrompt("feature-1");
expect(prompt.title).toBe("Remove worktree?");
expect(prompt.message).toContain("feature-1");
expect(prompt.message).toContain("branch is kept");
});
});

describe("thread action failure messages", () => {
it("surfaces unarchive restoration errors with the server-provided detail", () => {
const restorationError = new WorktreeLifecycleError({
operation: "restore",
threadId: ThreadId.make("thread-1"),
detail:
"Failed to recreate the worktree at /wt/feature-1 from branch 'feature-1': branch missing. The thread stays archived.",
});
expect(actionFailureTitle("unarchive")).toBe("Could not unarchive thread");
expect(actionFailureMessage("unarchive", Cause.fail(restorationError))).toContain(
"Failed to recreate the worktree",
);
});

it("falls back to a generic message when the cause has no message", () => {
expect(actionFailureMessage("unarchive", Cause.fail(new Error("")))).toBe(
"The thread could not be unarchived.",
);
});
});
49 changes: 49 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import type { WorktreeCleanupConfirmation } from "@t3tools/client-runtime/state/worktreeCleanup";

export interface WorktreeCleanupPromptButtons {
readonly title: string;
readonly message: string;
readonly onKeep: () => void;
readonly onRemove: () => void;
}

export function buildWorktreeCleanupPrompt(displayWorktreePath: string): {
readonly title: string;
readonly message: string;
} {
return {
title: "Remove worktree?",
message: `This thread is the last active one linked to the worktree “${displayWorktreePath}”. Remove it when archiving? The branch is kept.`,
};
}

/**
* Presents the archive-time cleanup confirmation through the platform's
* surface: the native alert on iOS, the in-app confirm dialog elsewhere.
* Keep archives without cleanup; Remove archives and cleans up.
*/
export function presentWorktreeCleanupConfirmation(input: {
readonly isIos: boolean;
readonly displayWorktreePath: string;
readonly presentAlert: (buttons: WorktreeCleanupPromptButtons) => void;
readonly presentConfirmDialog: (buttons: WorktreeCleanupPromptButtons) => void;
}): Promise<WorktreeCleanupConfirmation<never>> {
const { title, message } = buildWorktreeCleanupPrompt(input.displayWorktreePath);
return new Promise((resolve) => {
const buttons: WorktreeCleanupPromptButtons = {
title,
message,
onKeep: () => {
resolve({ kind: "declined" });
},
onRemove: () => {
resolve({ kind: "confirmed" });
},
};
if (input.isIos) {
input.presentAlert(buttons);
return;
}
input.presentConfirmDialog(buttons);
});
}
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsPreviewWorktreeCleanup]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCleanupThreadWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,7 @@ describe("CheckpointDiffQuery.layer", () => {
});
}),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -200,6 +201,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -353,6 +356,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -407,6 +411,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,6 +201,7 @@ describe("OrchestrationEngine", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
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
30 changes: 30 additions & 0 deletions apps/mobile/src/features/home/threadActionMessages.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
import * as Cause from "effect/Cause";

export type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

export function actionFailureMessage(
action: ThreadListAction,
cause: Cause.Cause<unknown>,
): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

export function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}
114 changes: 86 additions & 28 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { canSettle } from "@t3tools/client-runtime/state/thread-settled";
import { runArchiveWithWorktreeCleanup } from "@t3tools/client-runtime/state/worktreeCleanup";
import * as Cause from "effect/Cause";
import * as Haptics from "expo-haptics";
import { useCallback, useRef } from "react";
Expand All@@ -11,7 +12,14 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadEnvironment } from "../../state/threads";
import { vcsEnvironment } from "../../state/vcs";
import { useAtomCommand } from "../../state/use-atom-command";
import {
actionFailureMessage,
actionFailureTitle,
type ThreadListAction,
} from "./threadActionMessages";
import { presentWorktreeCleanupConfirmation } from "./worktreeCleanupPrompt";

/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
Expand All@@ -22,36 +30,10 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en
);
}

type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";

const ACTION_VERBS: Record<ThreadListAction, string> = {
archive: "archived",
unarchive: "unarchived",
delete: "deleted",
settle: "settled",
unsettle: "un-settled",
};

function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return `The thread could not be ${ACTION_VERBS[action]}.`;
}

function selectionHaptic(): void {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}

function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
if (action === "settle") return "Could not settle thread";
if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}

/** Resolves to true iff the action was dispatched and succeeded. */
function useThreadActionExecutor(
onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
Expand DownExpand Up@@ -195,12 +177,88 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
const previewWorktreeCleanup = useAtomCommand(vcsEnvironment.previewWorktreeCleanup, {
reportFailure: false,
});
const cleanupThreadWorktree = useAtomCommand(vcsEnvironment.cleanupThreadWorktree, {
reportFailure: false,
});

const archiveThread = useCallback(
(thread: EnvironmentThreadShell) => {
void executeAction("archive", thread);
void runArchiveWithWorktreeCleanup({
// Server-authoritative preview; a failure (old server, transient
// error) degrades to a plain archive without a prompt. A thread
// mid-turn keeps the original archive guard: executeAction re-checks
// and surfaces the alert, so it must not be prompted for cleanup.
previewCandidate: async () => {
if (thread.session?.status === "running" && thread.session.activeTurnId != null) {
return null;
}
const preview = await previewWorktreeCleanup({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
return preview._tag === "Success" ? preview.value.candidate : null;
},
confirmRemoval: ({ displayWorktreePath }) =>
presentWorktreeCleanupConfirmation({
isIos: process.env.EXPO_OS === "ios",
displayWorktreePath,
presentAlert: (buttons) => {
Alert.alert(buttons.title, buttons.message, [
{ text: "Keep", style: "cancel", onPress: buttons.onKeep },
{ text: "Remove", style: "destructive", onPress: buttons.onRemove },
]);
},
presentConfirmDialog: (buttons) => {
showConfirmDialog({
title: buttons.title,
message: buttons.message,
cancelText: "Keep",
confirmText: "Remove",
destructive: true,
onConfirm: buttons.onRemove,
onCancel: buttons.onKeep,
});
},
}),
archive: () => executeAction("archive", thread),
isArchiveSuccess: (archived) => archived,
cleanup: async () => {
const result = await cleanupThreadWorktree({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
return {
kind: "failed",
message:
error instanceof Error && error.message.trim().length > 0
? error.message
: "The worktree could not be removed.",
} as const;
}
return { kind: "done", status: result.value.status } as const;
},
// Cleanup problems are nonfatal: the archive itself already
// succeeded.
onCleanupFailed: (displayWorktreePath, message) => {
Alert.alert(
"Thread archived, but worktree removal failed",
`Could not remove ${displayWorktreePath}. ${message}`,
);
},
onCleanupRetained: (displayWorktreePath) => {
Alert.alert(
"Worktree kept",
`${displayWorktreePath} is still used by another active thread.`,
);
},
});
},
[executeAction],
[cleanupThreadWorktree, executeAction, previewWorktreeCleanup],
);
const settleThread = useCallback(
async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true,
Expand Down
102 changes: 102 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
import { WorktreeLifecycleError } from "@t3tools/contracts";
import { ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { describe, expect, it, vi } from "vite-plus/test";

import { actionFailureMessage, actionFailureTitle } from "./threadActionMessages";
import {
buildWorktreeCleanupPrompt,
presentWorktreeCleanupConfirmation,
} from "./worktreeCleanupPrompt";

describe("presentWorktreeCleanupConfirmation", () => {
it("uses the native alert on iOS and never the confirm dialog", async () => {
const presentAlert = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onRemove();
},
);
const presentConfirmDialog = vi.fn();

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "confirmed" });
expect(presentAlert).toHaveBeenCalledTimes(1);
expect(presentConfirmDialog).not.toHaveBeenCalled();
});

it("uses the confirm dialog host elsewhere", async () => {
const presentAlert = vi.fn();
const presentConfirmDialog = vi.fn(
(buttons: { readonly onKeep: () => void; readonly onRemove: () => void }) => {
buttons.onKeep();
},
);

const confirmation = await presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert,
presentConfirmDialog,
});

expect(confirmation).toEqual({ kind: "declined" });
expect(presentAlert).not.toHaveBeenCalled();
expect(presentConfirmDialog).toHaveBeenCalledTimes(1);
});

it("resolves declined for Keep and confirmed for Remove", async () => {
const keep = presentWorktreeCleanupConfirmation({
isIos: true,
displayWorktreePath: "feature-1",
presentAlert: (buttons) => {
buttons.onKeep();
},
presentConfirmDialog: () => {},
});
await expect(keep).resolves.toEqual({ kind: "declined" });

const remove = presentWorktreeCleanupConfirmation({
isIos: false,
displayWorktreePath: "feature-1",
presentAlert: () => {},
presentConfirmDialog: (buttons) => {
buttons.onRemove();
},
});
await expect(remove).resolves.toEqual({ kind: "confirmed" });
});

it("names the worktree in the prompt copy", () => {
const prompt = buildWorktreeCleanupPrompt("feature-1");
expect(prompt.title).toBe("Remove worktree?");
expect(prompt.message).toContain("feature-1");
expect(prompt.message).toContain("branch is kept");
});
});

describe("thread action failure messages", () => {
it("surfaces unarchive restoration errors with the server-provided detail", () => {
const restorationError = new WorktreeLifecycleError({
operation: "restore",
threadId: ThreadId.make("thread-1"),
detail:
"Failed to recreate the worktree at /wt/feature-1 from branch 'feature-1': branch missing. The thread stays archived.",
});
expect(actionFailureTitle("unarchive")).toBe("Could not unarchive thread");
expect(actionFailureMessage("unarchive", Cause.fail(restorationError))).toContain(
"Failed to recreate the worktree",
);
});

it("falls back to a generic message when the cause has no message", () => {
expect(actionFailureMessage("unarchive", Cause.fail(new Error("")))).toBe(
"The thread could not be unarchived.",
);
});
});
49 changes: 49 additions & 0 deletions apps/mobile/src/features/home/worktreeCleanupPrompt.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import type { WorktreeCleanupConfirmation } from "@t3tools/client-runtime/state/worktreeCleanup";

export interface WorktreeCleanupPromptButtons {
readonly title: string;
readonly message: string;
readonly onKeep: () => void;
readonly onRemove: () => void;
}

export function buildWorktreeCleanupPrompt(displayWorktreePath: string): {
readonly title: string;
readonly message: string;
} {
return {
title: "Remove worktree?",
message: `This thread is the last active one linked to the worktree “${displayWorktreePath}”. Remove it when archiving? The branch is kept.`,
};
}

/**
* Presents the archive-time cleanup confirmation through the platform's
* surface: the native alert on iOS, the in-app confirm dialog elsewhere.
* Keep archives without cleanup; Remove archives and cleans up.
*/
export function presentWorktreeCleanupConfirmation(input: {
readonly isIos: boolean;
readonly displayWorktreePath: string;
readonly presentAlert: (buttons: WorktreeCleanupPromptButtons) => void;
readonly presentConfirmDialog: (buttons: WorktreeCleanupPromptButtons) => void;
}): Promise<WorktreeCleanupConfirmation<never>> {
const { title, message } = buildWorktreeCleanupPrompt(input.displayWorktreePath);
return new Promise((resolve) => {
const buttons: WorktreeCleanupPromptButtons = {
title,
message,
onKeep: () => {
resolve({ kind: "declined" });
},
onRemove: () => {
resolve({ kind: "confirmed" });
},
};
if (input.isIos) {
input.presentAlert(buttons);
return;
}
input.presentConfirmDialog(buttons);
});
}
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsPreviewWorktreeCleanup]: AuthOrchestrationReadScope,
[WS_METHODS.vcsCleanupThreadWorktree]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,7 @@ describe("CheckpointDiffQuery.layer", () => {
});
}),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -200,6 +201,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -353,6 +356,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand DownExpand Up@@ -407,6 +411,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,6 +201,7 @@ describe("OrchestrationEngine", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getSessionStopContextById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThreads: () => Effect.succeed({ matches: [] }),
Expand Down
Loading
Loading