From ae9223f25e7629676df120cd30de4d6ba2996e5e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:32:01 -0700 Subject: [PATCH 1/2] Structure mobile project thread validation errors Co-authored-by: codex --- .../projectThreadCreationValidation.test.ts | 69 +++++++++++++++++++ .../projectThreadCreationValidation.ts | 56 +++++++++++++++ .../features/threads/use-project-actions.ts | 20 +++--- 3 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts create mode 100644 apps/mobile/src/features/threads/projectThreadCreationValidation.ts diff --git a/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts b/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts new file mode 100644 index 00000000000..00b8bea7441 --- /dev/null +++ b/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts @@ -0,0 +1,69 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + ProjectThreadBaseBranchRequiredError, + ProjectThreadTaskRequiredError, + validateProjectThreadCreation, +} from "./projectThreadCreationValidation"; + +const environmentId = EnvironmentId.make("environment-1"); +const projectId = ProjectId.make("project-1"); + +describe("validateProjectThreadCreation", () => { + it("returns structured context when a task is missing", () => { + const error = validateProjectThreadCreation({ + environmentId, + projectId, + environmentMode: "local", + branch: null, + initialMessageText: " ", + }); + + expect(error).toBeInstanceOf(ProjectThreadTaskRequiredError); + expect(error).toMatchObject({ + environmentId, + projectId, + environmentMode: "local", + message: "Enter a task before starting the thread.", + }); + }); + + it("returns a distinct error when a worktree branch is missing", () => { + const error = validateProjectThreadCreation({ + environmentId, + projectId, + environmentMode: "worktree", + branch: null, + initialMessageText: "Investigate the failure", + }); + + expect(error).toBeInstanceOf(ProjectThreadBaseBranchRequiredError); + expect(error).toMatchObject({ + environmentId, + projectId, + message: "Select a base branch before creating a worktree.", + }); + }); + + it("accepts valid local and worktree inputs", () => { + expect( + validateProjectThreadCreation({ + environmentId, + projectId, + environmentMode: "local", + branch: null, + initialMessageText: "Start a local task", + }), + ).toBeNull(); + expect( + validateProjectThreadCreation({ + environmentId, + projectId, + environmentMode: "worktree", + branch: "main", + initialMessageText: "Start a worktree task", + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/projectThreadCreationValidation.ts b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts new file mode 100644 index 00000000000..e4ad776e23d --- /dev/null +++ b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts @@ -0,0 +1,56 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export class ProjectThreadTaskRequiredError extends Schema.TaggedErrorClass()( + "ProjectThreadTaskRequiredError", + { + environmentId: EnvironmentId, + projectId: ProjectId, + environmentMode: Schema.Literals(["local", "worktree"]), + }, +) { + override get message(): string { + return "Enter a task before starting the thread."; + } +} + +export class ProjectThreadBaseBranchRequiredError extends Schema.TaggedErrorClass()( + "ProjectThreadBaseBranchRequiredError", + { + environmentId: EnvironmentId, + projectId: ProjectId, + }, +) { + override get message(): string { + return "Select a base branch before creating a worktree."; + } +} + +export const ProjectThreadCreationValidationError = Schema.Union([ + ProjectThreadTaskRequiredError, + ProjectThreadBaseBranchRequiredError, +]); +export type ProjectThreadCreationValidationError = typeof ProjectThreadCreationValidationError.Type; + +export function validateProjectThreadCreation(input: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly environmentMode: "local" | "worktree"; + readonly branch: string | null; + readonly initialMessageText: string; +}): ProjectThreadCreationValidationError | null { + if (input.initialMessageText.trim().length === 0) { + return new ProjectThreadTaskRequiredError({ + environmentId: input.environmentId, + projectId: input.projectId, + environmentMode: input.environmentMode, + }); + } + if (input.environmentMode === "worktree" && !input.branch) { + return new ProjectThreadBaseBranchRequiredError({ + environmentId: input.environmentId, + projectId: input.projectId, + }); + } + return null; +} diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index a0c19d9fe8b..9531567f447 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -21,6 +21,7 @@ import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; +import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -52,15 +53,16 @@ export function useCreateProjectThread() { const initialMessageText = input.initialMessageText.trim(); const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); - if (initialMessageText.length === 0) { - const error = new Error("Enter a task before starting the thread."); - setPendingConnectionError(error.message); - return AsyncResult.failure(Cause.fail(error)); - } - if (input.envMode === "worktree" && !input.branch) { - const error = new Error("Select a base branch before creating a worktree."); - setPendingConnectionError(error.message); - return AsyncResult.failure(Cause.fail(error)); + const validationError = validateProjectThreadCreation({ + environmentId: input.project.environmentId, + projectId: input.project.id, + environmentMode: input.envMode, + branch: input.branch, + initialMessageText, + }); + if (validationError !== null) { + setPendingConnectionError(validationError.message); + return AsyncResult.failure(Cause.fail(validationError)); } const isWorktree = input.envMode === "worktree"; From 4e208c305319765a7cb85d49c1d88e9d33164b32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:37:06 -0700 Subject: [PATCH 2/2] Remove refactor-only validation tests Co-authored-by: codex --- .../projectThreadCreationValidation.test.ts | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts diff --git a/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts b/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts deleted file mode 100644 index 00b8bea7441..00000000000 --- a/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - ProjectThreadBaseBranchRequiredError, - ProjectThreadTaskRequiredError, - validateProjectThreadCreation, -} from "./projectThreadCreationValidation"; - -const environmentId = EnvironmentId.make("environment-1"); -const projectId = ProjectId.make("project-1"); - -describe("validateProjectThreadCreation", () => { - it("returns structured context when a task is missing", () => { - const error = validateProjectThreadCreation({ - environmentId, - projectId, - environmentMode: "local", - branch: null, - initialMessageText: " ", - }); - - expect(error).toBeInstanceOf(ProjectThreadTaskRequiredError); - expect(error).toMatchObject({ - environmentId, - projectId, - environmentMode: "local", - message: "Enter a task before starting the thread.", - }); - }); - - it("returns a distinct error when a worktree branch is missing", () => { - const error = validateProjectThreadCreation({ - environmentId, - projectId, - environmentMode: "worktree", - branch: null, - initialMessageText: "Investigate the failure", - }); - - expect(error).toBeInstanceOf(ProjectThreadBaseBranchRequiredError); - expect(error).toMatchObject({ - environmentId, - projectId, - message: "Select a base branch before creating a worktree.", - }); - }); - - it("accepts valid local and worktree inputs", () => { - expect( - validateProjectThreadCreation({ - environmentId, - projectId, - environmentMode: "local", - branch: null, - initialMessageText: "Start a local task", - }), - ).toBeNull(); - expect( - validateProjectThreadCreation({ - environmentId, - projectId, - environmentMode: "worktree", - branch: "main", - initialMessageText: "Start a worktree task", - }), - ).toBeNull(); - }); -});