diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts
index bbc23fb04097b2ebc1c5bd130976436148bc4f1a..d6930948cc6ce7d2d40cb407e772644d3289b2b6 100644
--- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts
+++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts
@@ -21,12 +21,23 @@ function makeFakeCodexBinary(dir: string) {
"#!/bin/sh",
'output_path=""',
"while [ $# -gt 0 ]; do",
+ ' if [ "$1" = "--image" ]; then',
+ " shift",
+ ' if [ -n "$1" ]; then',
+ ' seen_image="1"',
+ " fi",
+ " continue",
+ " fi",
' if [ "$1" = "--output-last-message" ]; then',
" shift",
' output_path="$1"',
" fi",
" shift",
"done",
+ 'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then',
+ ' printf "%s\\n" "missing --image input" >&2',
+ " exit 2",
+ "fi",
"cat >/dev/null",
'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then',
' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2',
@@ -48,6 +59,7 @@ function withFakeCodexEnv(
output: string;
exitCode?: number;
stderr?: string;
+ requireImage?: boolean;
},
effect: Effect.Effect,
) {
@@ -60,6 +72,7 @@ function withFakeCodexEnv(
const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64;
const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;
const previousStderr = process.env.T3_FAKE_CODEX_STDERR;
+ const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
yield* Effect.sync(() => {
process.env.PATH = `${binDir}:${previousPath ?? ""}`;
@@ -76,6 +89,12 @@ function withFakeCodexEnv(
} else {
delete process.env.T3_FAKE_CODEX_STDERR;
}
+
+ if (input.requireImage) {
+ process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = "1";
+ } else {
+ delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
+ }
});
return {
@@ -83,6 +102,7 @@ function withFakeCodexEnv(
previousOutput,
previousExitCode,
previousStderr,
+ previousRequireImage,
};
}),
() => effect,
@@ -107,6 +127,12 @@ function withFakeCodexEnv(
} else {
process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr;
}
+
+ if (previous.previousRequireImage === undefined) {
+ delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
+ } else {
+ process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;
+ }
}),
);
}
@@ -170,6 +196,76 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
),
);
+ it.effect("generates branch names and normalizes branch fragments", () =>
+ withFakeCodexEnv(
+ {
+ output: JSON.stringify({
+ branch: " Feat/Session ",
+ }),
+ },
+ Effect.gen(function* () {
+ const textGeneration = yield* TextGeneration;
+
+ const generated = yield* textGeneration.generateBranchName({
+ cwd: process.cwd(),
+ message: "Please update session handling.",
+ });
+
+ expect(generated.branch).toBe("feat/session");
+ }),
+ ),
+ );
+
+ it.effect("passes image attachments through as codex image inputs", () =>
+ withFakeCodexEnv(
+ {
+ output: JSON.stringify({
+ branch: "fix/ui-regression",
+ }),
+ requireImage: true,
+ },
+ Effect.gen(function* () {
+ const textGeneration = yield* TextGeneration;
+
+ const generated = yield* textGeneration.generateBranchName({
+ cwd: process.cwd(),
+ message: "Fix layout bug from screenshot.",
+ attachments: [
+ {
+ type: "image",
+ name: "bug.png",
+ mimeType: "image/png",
+ sizeBytes: 5,
+ dataUrl: "data:image/png;base64,SGVsbG8=",
+ },
+ ],
+ });
+
+ expect(generated.branch).toBe("fix/ui-regression");
+ }),
+ ),
+ );
+
+ it.effect("returns null branch when codex returns the wrong object shape", () =>
+ withFakeCodexEnv(
+ {
+ output: JSON.stringify({
+ title: "This is not a branch payload",
+ }),
+ },
+ Effect.gen(function* () {
+ const textGeneration = yield* TextGeneration;
+
+ const generated = yield* textGeneration.generateBranchName({
+ cwd: process.cwd(),
+ message: "Fix websocket reconnect flake",
+ });
+
+ expect(generated.branch).toBeNull();
+ }),
+ ),
+ );
+
it.effect("returns typed TextGenerationError when codex exits non-zero", () =>
withFakeCodexEnv(
{
diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts
index 3fbeb1aac76e907ed102fd5451f8daa43f07ed10..a24a3e90bde54f23fc6d95b4e3d72bedc01e9b02 100644
--- a/apps/server/src/git/Layers/CodexTextGeneration.ts
+++ b/apps/server/src/git/Layers/CodexTextGeneration.ts
@@ -4,7 +4,10 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { TextGenerationError } from "../Errors.ts";
+import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
import {
+ type BranchNameGenerationInput,
+ type BranchNameGenerationResult,
type CommitMessageGenerationResult,
type PrContentGenerationResult,
type TextGenerationShape,
@@ -35,6 +38,15 @@ const PR_OUTPUT_SCHEMA_JSON = {
additionalProperties: false,
} as const;
+const BRANCH_NAME_OUTPUT_SCHEMA_JSON = {
+ type: "object",
+ properties: {
+ branch: { type: "string" },
+ },
+ required: ["branch"],
+ additionalProperties: false,
+} as const;
+
function normalizeCodexError(
operation: string,
error: unknown,
@@ -97,6 +109,18 @@ function parsePrOutput(raw: unknown): { title: string; body: string } {
return { title, body };
}
+function parseBranchNameOutput(raw: unknown): { branch: string } {
+ if (!raw || typeof raw !== "object") {
+ throw new Error("Codex returned a non-object branch payload.");
+ }
+ const record = raw as Record;
+ const branch = typeof record.branch === "string" ? record.branch.trim() : "";
+ if (branch.length === 0) {
+ throw new Error("Codex returned an empty branch name payload.");
+ }
+ return { branch };
+}
+
function limitSection(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
const truncated = value.slice(0, maxChars);
@@ -124,6 +148,24 @@ function sanitizePrTitle(raw: string): string {
return "Update project changes";
}
+function sanitizeBranchName(raw: string): string {
+ const normalized = raw
+ .trim()
+ .toLowerCase()
+ .replace(/['"`]/g, "")
+ .replace(/^[./\s_-]+|[./\s_-]+$/g, "");
+
+ const branchFragment = normalized
+ .replace(/[^a-z0-9/_-]+/g, "-")
+ .replace(/\/+/g, "/")
+ .replace(/-+/g, "-")
+ .replace(/^[./_-]+|[./_-]+$/g, "")
+ .slice(0, 64)
+ .replace(/[./_-]+$/g, "");
+
+ return branchFragment.length > 0 ? branchFragment : "update";
+}
+
const makeCodexTextGeneration = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
@@ -168,20 +210,86 @@ const makeCodexTextGeneration = Effect.gen(function* () {
);
};
+ const writeTempBinaryFile = (
+ operation: string,
+ prefix: string,
+ bytes: Uint8Array,
+ extension: string,
+ ): Effect.Effect => {
+ const normalizedExtension = extension.startsWith(".") ? extension : `.${extension}`;
+ const filePath = path.join(
+ tempDir,
+ `t3code-${prefix}-${process.pid}-${randomUUID()}${normalizedExtension}`,
+ );
+ return fileSystem.writeFile(filePath, bytes).pipe(
+ Effect.mapError(
+ (cause) =>
+ new TextGenerationError({
+ operation,
+ detail: `Failed to write temp file at ${filePath}.`,
+ cause,
+ }),
+ ),
+ Effect.as(filePath),
+ );
+ };
+
const safeUnlink = (filePath: string): Effect.Effect =>
fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));
+ const materializeImageAttachments = (
+ operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName",
+ attachments: BranchNameGenerationInput["attachments"],
+ ): Effect.Effect, TextGenerationError> =>
+ Effect.gen(function* () {
+ if (!attachments || attachments.length === 0) {
+ return [];
+ }
+
+ const imagePaths: string[] = [];
+ for (const [index, attachment] of attachments.entries()) {
+ if (attachment.type !== "image") {
+ continue;
+ }
+
+ const parsed = parseBase64DataUrl(attachment.dataUrl);
+ if (!parsed || !parsed.mimeType.startsWith("image/")) {
+ continue;
+ }
+
+ const bytes = Buffer.from(parsed.base64, "base64");
+ if (bytes.byteLength === 0) {
+ continue;
+ }
+
+ const extension = inferImageExtension({
+ mimeType: parsed.mimeType,
+ fileName: attachment.name,
+ });
+ const imagePath = yield* writeTempBinaryFile(
+ operation,
+ `codex-image-${index}`,
+ bytes,
+ extension,
+ );
+ imagePaths.push(imagePath);
+ }
+ return imagePaths;
+ });
+
const runCodexJson = ({
operation,
cwd,
prompt,
outputSchemaJson,
+ imagePaths = [],
parse,
}: {
- operation: "generateCommitMessage" | "generatePrContent";
+ operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
cwd: string;
prompt: string;
outputSchemaJson: object;
+ imagePaths?: ReadonlyArray;
parse: (raw: unknown) => T;
}): Effect.Effect =>
Effect.gen(function* () {
@@ -208,6 +316,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
schemaPath,
"--output-last-message",
outputPath,
+ ...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
"-",
],
{
@@ -254,9 +363,12 @@ const makeCodexTextGeneration = Effect.gen(function* () {
}
});
- const cleanup = Effect.all([safeUnlink(schemaPath), safeUnlink(outputPath)], {
- concurrency: "unbounded",
- }).pipe(Effect.asVoid);
+ const cleanup = Effect.all(
+ [schemaPath, outputPath, ...imagePaths].map((filePath) => safeUnlink(filePath)),
+ {
+ concurrency: "unbounded",
+ },
+ ).pipe(Effect.asVoid);
return yield* Effect.gen(function* () {
yield* runCodexCommand.pipe(
@@ -384,9 +496,60 @@ const makeCodexTextGeneration = Effect.gen(function* () {
);
};
+ const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
+ return Effect.gen(function* () {
+ const imagePaths = yield* materializeImageAttachments("generateBranchName", input.attachments);
+ const attachmentLines = (input.attachments ?? []).map(
+ (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
+ );
+
+ const prompt = [
+ "You generate concise git branch names.",
+ "Return a JSON object with key: branch.",
+ "Rules:",
+ "- Branch should describe the requested work from the user message.",
+ "- Keep it short and specific (2-6 words).",
+ "- Use plain words only, no issue prefixes and no punctuation-heavy text.",
+ "- If images are attached, use them as primary context for visual/UI issues.",
+ "",
+ `Image attachments supplied to the model: ${imagePaths.length}`,
+ "",
+ "User message:",
+ limitSection(input.message, 8_000),
+ "",
+ "Attachment metadata:",
+ attachmentLines.length > 0 ? limitSection(attachmentLines.join("\n"), 4_000) : "- none",
+ ].join("\n");
+
+ const generated = yield* runCodexJson({
+ operation: "generateBranchName",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON,
+ imagePaths,
+ parse: (raw) => parseBranchNameOutput(raw),
+ }).pipe(
+ Effect.catch((error) =>
+ Effect.gen(function* () {
+ yield* Effect.logWarning("branch-name generation failed, skipping rename", {
+ operation: "generateBranchName",
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ return { branch: null };
+ }),
+ ),
+ );
+
+ return {
+ branch: generated.branch ? sanitizeBranchName(generated.branch) : null,
+ } satisfies BranchNameGenerationResult;
+ });
+ };
+
return {
generateCommitMessage,
generatePrContent,
+ generateBranchName,
} satisfies TextGenerationShape;
});
diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts
index f94bd7631fa82baccb85478ab5fdea9b156fb51c..19bd998a36a8f30d6c3d5cf726961e201b178e66 100644
--- a/apps/server/src/git/Layers/GitCore.test.ts
+++ b/apps/server/src/git/Layers/GitCore.test.ts
@@ -106,6 +106,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>
listBranches: (input) => core.listBranches(input),
createWorktree: (input) => core.createWorktree(input),
removeWorktree: (input) => core.removeWorktree(input),
+ renameBranch: (input) => core.renameBranch(input),
createBranch: (input) => core.createBranch(input),
checkoutBranch: (input) => core.checkoutBranch(input),
initRepo: (input) => core.initRepo(input),
@@ -154,6 +155,13 @@ function removeGitWorktree(input: Parameters[0])
});
}
+function renameGitBranch(input: Parameters[0]) {
+ return Effect.gen(function* () {
+ const core = yield* GitCore;
+ return yield* core.renameBranch(input);
+ });
+}
+
function pullGitBranch({ cwd }: { cwd: string }) {
return Effect.gen(function* () {
const core = yield* GitCore;
@@ -819,6 +827,94 @@ it.layer(TestLayer)("git integration", (it) => {
);
});
+ // ── renameGitBranch ──
+
+ describe("renameGitBranch", () => {
+ it.effect("renames the current branch", () =>
+ Effect.gen(function* () {
+ const tmp = yield* makeTmpDir();
+ yield* initRepoWithCommit(tmp);
+ yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" });
+ yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" });
+
+ const renamed = yield* renameGitBranch({
+ cwd: tmp,
+ oldBranch: "feature/old-name",
+ newBranch: "feature/new-name",
+ });
+
+ expect(renamed.branch).toBe("feature/new-name");
+
+ const branches = yield* listGitBranches({ cwd: tmp });
+ expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false);
+ const current = branches.branches.find((branch) => branch.current);
+ expect(current?.name).toBe("feature/new-name");
+ }),
+ );
+
+ it.effect("returns success without git invocation when old/new names match", () =>
+ Effect.gen(function* () {
+ const tmp = yield* makeTmpDir();
+ yield* initRepoWithCommit(tmp);
+ const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!;
+
+ const renamed = yield* renameGitBranch({
+ cwd: tmp,
+ oldBranch: current.name,
+ newBranch: current.name,
+ });
+
+ expect(renamed.branch).toBe(current.name);
+ }),
+ );
+
+ it.effect("appends numeric suffix when target branch already exists", () =>
+ Effect.gen(function* () {
+ const tmp = yield* makeTmpDir();
+ yield* initRepoWithCommit(tmp);
+ yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });
+ yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
+ yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
+
+ const renamed = yield* renameGitBranch({
+ cwd: tmp,
+ oldBranch: "t3code/tmp-working",
+ newBranch: "t3code/feat/session",
+ });
+
+ expect(renamed.branch).toBe("t3code/feat/session-1");
+ const branches = yield* listGitBranches({ cwd: tmp });
+ expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe(
+ true,
+ );
+ expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe(
+ true,
+ );
+ const current = branches.branches.find((branch) => branch.current);
+ expect(current?.name).toBe("t3code/feat/session-1");
+ }),
+ );
+
+ it.effect("increments suffix until it finds an available branch name", () =>
+ Effect.gen(function* () {
+ const tmp = yield* makeTmpDir();
+ yield* initRepoWithCommit(tmp);
+ yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });
+ yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" });
+ yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
+ yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
+
+ const renamed = yield* renameGitBranch({
+ cwd: tmp,
+ oldBranch: "t3code/tmp-working",
+ newBranch: "t3code/feat/session",
+ });
+
+ expect(renamed.branch).toBe("t3code/feat/session-2");
+ }),
+ );
+ });
+
// ── createGitWorktree + removeGitWorktree ──
describe("createGitWorktree", () => {
diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts
index 2a2d358bd3811e38e660fa12d1e67c119b9e230c..8807918c0ff5686b647f6beedb18911eaa448c5a 100644
--- a/apps/server/src/git/Layers/GitCore.ts
+++ b/apps/server/src/git/Layers/GitCore.ts
@@ -250,6 +250,43 @@ const makeGitCore = Effect.gen(function* () {
Effect.map((result) => result.stdout),
);
+ const branchExists = (cwd: string, branch: string): Effect.Effect =>
+ executeGit(
+ "GitCore.branchExists",
+ cwd,
+ ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
+ {
+ allowNonZeroExit: true,
+ timeoutMs: 5_000,
+ },
+ ).pipe(Effect.map((result) => result.code === 0));
+
+ const resolveAvailableBranchName = (
+ cwd: string,
+ desiredBranch: string,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
+ if (!isDesiredTaken) {
+ return desiredBranch;
+ }
+
+ for (let suffix = 1; suffix <= 100; suffix += 1) {
+ const candidate = `${desiredBranch}-${suffix}`;
+ const isCandidateTaken = yield* branchExists(cwd, candidate);
+ if (!isCandidateTaken) {
+ return candidate;
+ }
+ }
+
+ return yield* createGitCommandError(
+ "GitCore.renameBranch",
+ cwd,
+ ["branch", "-m", desiredBranch],
+ `Could not find an available branch name for '${desiredBranch}'.`,
+ );
+ });
+
const resolveCurrentUpstream = (
cwd: string,
): Effect.Effect<
@@ -1028,6 +1065,26 @@ const makeGitCore = Effect.gen(function* () {
);
});
+ const renameBranch: GitCoreShape["renameBranch"] = (input) =>
+ Effect.gen(function* () {
+ if (input.oldBranch === input.newBranch) {
+ return { branch: input.newBranch };
+ }
+ const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
+
+ yield* executeGit(
+ "GitCore.renameBranch",
+ input.cwd,
+ ["branch", "-m", input.oldBranch, targetBranch],
+ {
+ timeoutMs: 10_000,
+ fallbackErrorMessage: "git branch rename failed",
+ },
+ );
+
+ return { branch: targetBranch };
+ });
+
const createBranch: GitCoreShape["createBranch"] = (input) =>
executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
timeoutMs: 10_000,
@@ -1135,6 +1192,7 @@ const makeGitCore = Effect.gen(function* () {
listBranches,
createWorktree,
removeWorktree,
+ renameBranch,
createBranch,
checkoutBranch,
initRepo,
diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts
index 0e870503d8cb73543bff10522eefed705cb37fa5..33a820ac3fb23ab636bc7cea9ab4d4442b88d72b 100644
--- a/apps/server/src/git/Layers/GitManager.test.ts
+++ b/apps/server/src/git/Layers/GitManager.test.ts
@@ -41,6 +41,10 @@ interface FakeGitTextGeneration {
diffSummary: string;
diffPatch: string;
}) => Effect.Effect<{ title: string; body: string }, TextGenerationError>;
+ generateBranchName: (input: {
+ cwd: string;
+ message: string;
+ }) => Effect.Effect<{ branch: string }, TextGenerationError>;
}
function makeTempDir(
@@ -110,6 +114,10 @@ function createTextGeneration(overrides: Partial = {}): T
title: "Add stacked git actions",
body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run",
}),
+ generateBranchName: () =>
+ Effect.succeed({
+ branch: "update-workflow",
+ }),
...overrides,
};
@@ -136,6 +144,17 @@ function createTextGeneration(overrides: Partial = {}): T
}),
),
),
+ generateBranchName: (input) =>
+ implementation.generateBranchName(input).pipe(
+ Effect.mapError(
+ (cause) =>
+ new TextGenerationError({
+ operation: "generateBranchName",
+ detail: "fake text generation failed",
+ ...(cause !== undefined ? { cause } : {}),
+ }),
+ ),
+ ),
};
}
diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts
index c0c08af739e462b8b9d0eac2dd378ffaca30888a..0c5d540075ae6e803d71550a58b72969a8ce6109 100644
--- a/apps/server/src/git/Services/GitCore.ts
+++ b/apps/server/src/git/Services/GitCore.ts
@@ -17,6 +17,8 @@ import type {
GitListBranchesInput,
GitListBranchesResult,
GitPullResult,
+ GitRenameBranchInput,
+ GitRenameBranchResult,
GitRemoveWorktreeInput,
GitStatusInput,
GitStatusResult,
@@ -124,6 +126,13 @@ export interface GitCoreShape {
*/
readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect;
+ /**
+ * Rename an existing local branch.
+ */
+ readonly renameBranch: (
+ input: GitRenameBranchInput,
+ ) => Effect.Effect;
+
/**
* Create a local branch.
*/
diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts
index 6073553ac85bca7a4072c1a69e8b4864a8f6221b..155349f174ddc12577269022b67f971b565a3d5c 100644
--- a/apps/server/src/git/Services/TextGeneration.ts
+++ b/apps/server/src/git/Services/TextGeneration.ts
@@ -8,6 +8,7 @@
*/
import { ServiceMap } from "effect";
import type { Effect } from "effect";
+import type { ChatAttachment } from "@t3tools/contracts";
import type { TextGenerationError } from "../Errors.ts";
@@ -37,11 +38,22 @@ export interface PrContentGenerationResult {
body: string;
}
+export interface BranchNameGenerationInput {
+ cwd: string;
+ message: string;
+ attachments?: ReadonlyArray | undefined;
+}
+
+export interface BranchNameGenerationResult {
+ branch: string | null;
+}
+
export interface TextGenerationService {
generateCommitMessage(
input: CommitMessageGenerationInput,
): Promise;
generatePrContent(input: PrContentGenerationInput): Promise;
+ generateBranchName(input: BranchNameGenerationInput): Promise;
}
/**
@@ -61,6 +73,13 @@ export interface TextGenerationShape {
readonly generatePrContent: (
input: PrContentGenerationInput,
) => Effect.Effect;
+
+ /**
+ * Generate a concise branch name from a user message.
+ */
+ readonly generateBranchName: (
+ input: BranchNameGenerationInput,
+ ) => Effect.Effect;
}
/**
diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts
new file mode 100644
index 0000000000000000000000000000000000000000..249deff0ca8efb37a8708c6fbe3039a9aa3a2ca4
--- /dev/null
+++ b/apps/server/src/imageMime.ts
@@ -0,0 +1,64 @@
+import Mime from "@effect/platform-node/Mime";
+
+export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = {
+ "image/avif": ".avif",
+ "image/bmp": ".bmp",
+ "image/gif": ".gif",
+ "image/heic": ".heic",
+ "image/heif": ".heif",
+ "image/jpeg": ".jpg",
+ "image/jpg": ".jpg",
+ "image/png": ".png",
+ "image/svg+xml": ".svg",
+ "image/tiff": ".tiff",
+ "image/webp": ".webp",
+};
+
+export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
+ ".avif",
+ ".bmp",
+ ".gif",
+ ".heic",
+ ".heif",
+ ".ico",
+ ".jpeg",
+ ".jpg",
+ ".png",
+ ".svg",
+ ".tiff",
+ ".webp",
+]);
+
+export function parseBase64DataUrl(
+ dataUrl: string,
+): { readonly mimeType: string; readonly base64: string } | null {
+ const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim());
+ if (!match) return null;
+
+ const mimeType = match[1]?.trim().toLowerCase();
+ const base64 = match[2]?.replace(/\s+/g, "");
+ if (!mimeType || !base64) return null;
+
+ return { mimeType, base64 };
+}
+
+export function inferImageExtension(input: { mimeType: string; fileName?: string }): string {
+ const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[input.mimeType.toLowerCase()];
+ if (fromMime) {
+ return fromMime;
+ }
+
+ const fromMimeExtension = Mime.getExtension(input.mimeType);
+ if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) {
+ return fromMimeExtension;
+ }
+
+ const fileName = input.fileName?.trim() ?? "";
+ const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName);
+ const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : "";
+ if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) {
+ return fileNameExtension;
+ }
+
+ return ".bin";
+}
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
index c518744d138d807bcec461883abae83924590357..49ba13d824650f4de4370bddad4e77db432bd59e 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
@@ -1,4 +1,3 @@
-import Mime from "@effect/platform-node/Mime";
import {
ApprovalRequestId,
type ChatAttachment,
@@ -40,9 +39,8 @@ import {
} from "../Services/ProjectionPipeline.ts";
import {
ATTACHMENTS_ROUTE_PREFIX,
- IMAGE_EXTENSION_BY_MIME_TYPE,
- SAFE_IMAGE_FILE_EXTENSIONS,
} from "../../projectFaviconRoute.ts";
+import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
export const ORCHESTRATION_PROJECTOR_NAMES = {
projects: "projection.projects",
@@ -92,30 +90,6 @@ function toSafeThreadAttachmentSegment(threadId: string): string | null {
return segment;
}
-function parseBase64DataUrl(
- dataUrl: string,
-): { readonly mimeType: string; readonly base64: string } | null {
- const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim());
- if (!match) return null;
-
- const mimeType = match[1]?.trim().toLowerCase();
- const base64 = match[2]?.replace(/\s+/g, "");
- if (!mimeType || !base64) return null;
-
- return { mimeType, base64 };
-}
-
-function inferImageExtension(attachment: Extract): string {
- const normalizedMimeType = attachment.mimeType.toLowerCase();
- if (normalizedMimeType.startsWith("image/")) {
- const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[normalizedMimeType];
- if (fromMime) return fromMime;
- }
- const ext = Mime.getExtension(attachment.mimeType);
- if (ext && SAFE_IMAGE_FILE_EXTENSIONS.has(ext)) return ext;
- return ".bin";
-}
-
const materializeAttachmentsForProjection = Effect.fn(function* (input: {
readonly threadId: string;
readonly messageId: string;
@@ -159,8 +133,8 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: {
if (bytes.byteLength === 0) return attachment;
const fileName = `${index}${inferImageExtension({
- ...attachment,
mimeType: parsed.mimeType,
+ fileName: attachment.name,
})}`;
const uniqueFileName = `${messageSegment}-${fileName}`;
const relativePath = `${threadSegment}/${uniqueFileName}`;
diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts
index cddf2c15af1ae1e944750b9c798ea156a29fd989..65d9b50c3df622ff79e56b30004a70faea9124bf 100644
--- a/apps/server/src/projectFaviconRoute.ts
+++ b/apps/server/src/projectFaviconRoute.ts
@@ -1,33 +1,10 @@
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
+export { IMAGE_EXTENSION_BY_MIME_TYPE, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts";
export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
-export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = {
- "image/png": ".png",
- "image/jpeg": ".jpg",
- "image/jpg": ".jpg",
- "image/gif": ".gif",
- "image/webp": ".webp",
- "image/svg+xml": ".svg",
- "image/bmp": ".bmp",
- "image/tiff": ".tiff",
- "image/heic": ".heic",
-};
-
-export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
- ".jpg",
- ".jpeg",
- ".png",
- ".gif",
- ".webp",
- ".bmp",
- ".tiff",
- ".svg",
- ".ico",
-]);
-
const FAVICON_MIME_TYPES: Record = {
".png": "image/png",
".jpg": "image/jpeg",
diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts
index d821ac446e6c57e90a6cc792314d2a729c959a56..6d4b02a6c2d1e8d40b70c937270387d06a1ec3ac 100644
--- a/apps/server/src/wsServer.test.ts
+++ b/apps/server/src/wsServer.test.ts
@@ -46,6 +46,7 @@ import { Open, type OpenShape } from "./open";
import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
import type { GitCoreShape } from "./git/Services/GitCore.ts";
import { GitCore } from "./git/Services/GitCore.ts";
+import { TextGeneration, type TextGenerationShape } from "./git/Services/TextGeneration.ts";
import { GitCommandError, GitManagerError } from "./git/Errors.ts";
import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
@@ -369,7 +370,11 @@ describe("WebSocket Server", () => {
providerLayer?: Layer.Layer;
open?: OpenShape;
gitManager?: GitManagerShape;
- gitCore?: Pick;
+ gitCore?: Pick<
+ GitCoreShape,
+ "listBranches" | "initRepo" | "pullCurrentBranch" | "renameBranch"
+ >;
+ textGeneration?: Pick;
terminalManager?: TerminalManagerShape;
} = {},
): Promise {
@@ -402,6 +407,9 @@ describe("WebSocket Server", () => {
options.gitCore
? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape)
: Layer.empty,
+ options.textGeneration
+ ? Layer.succeed(TextGeneration, options.textGeneration as unknown as TextGenerationShape)
+ : Layer.empty,
options.terminalManager
? Layer.succeed(TerminalManager, options.terminalManager)
: Layer.empty,
@@ -1468,6 +1476,8 @@ describe("WebSocket Server", () => {
}),
);
const initRepo = vi.fn(() => Effect.void);
+ const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" }));
+ const generateBranchName = vi.fn(() => Effect.succeed({ branch: "new-name" as const }));
const pullCurrentBranch = vi.fn(() =>
Effect.fail(
new GitCommandError({
@@ -1484,8 +1494,12 @@ describe("WebSocket Server", () => {
gitCore: {
listBranches,
initRepo,
+ renameBranch,
pullCurrentBranch,
},
+ textGeneration: {
+ generateBranchName,
+ },
});
const addr = server.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
@@ -1507,6 +1521,120 @@ describe("WebSocket Server", () => {
expect(pullResponse.result).toBeUndefined();
expect(pullResponse.error?.message).toContain("No upstream configured");
expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
+
+ const generatedBranchResponse = await sendRequest(ws, WS_METHODS.gitGenerateBranchName, {
+ cwd: "/repo/path",
+ message: "Add retry logic for websocket reconnects",
+ attachments: [
+ {
+ type: "image",
+ name: "bug.png",
+ mimeType: "image/png",
+ sizeBytes: 5,
+ dataUrl: "data:image/png;base64,SGVsbG8=",
+ },
+ ],
+ });
+ expect(generatedBranchResponse.error).toBeUndefined();
+ expect(generatedBranchResponse.result).toEqual({ branch: "new-name" });
+ expect(generateBranchName).toHaveBeenCalledWith({
+ cwd: "/repo/path",
+ message: "Add retry logic for websocket reconnects",
+ attachments: [
+ {
+ type: "image",
+ name: "bug.png",
+ mimeType: "image/png",
+ sizeBytes: 5,
+ dataUrl: "data:image/png;base64,SGVsbG8=",
+ },
+ ],
+ });
+
+ const generateAndRenameResponse = await sendRequest(
+ ws,
+ WS_METHODS.gitGenerateAndRenameBranch,
+ {
+ cwd: "/repo/path",
+ oldBranch: "t3code/tmp-123",
+ message: "Add retry logic for websocket reconnects",
+ attachments: [
+ {
+ type: "image",
+ name: "bug.png",
+ mimeType: "image/png",
+ sizeBytes: 5,
+ dataUrl: "data:image/png;base64,SGVsbG8=",
+ },
+ ],
+ },
+ );
+ expect(generateAndRenameResponse.error).toBeUndefined();
+ expect(generateAndRenameResponse.result).toEqual({ branch: "t3code/new-name" });
+ expect(renameBranch).toHaveBeenCalledWith({
+ cwd: "/repo/path",
+ oldBranch: "t3code/tmp-123",
+ newBranch: "t3code/new-name",
+ });
+
+ const renameResponse = await sendRequest(ws, WS_METHODS.gitRenameBranch, {
+ cwd: "/repo/path",
+ oldBranch: "t3code/tmp-456",
+ newBranch: "t3code/new-name",
+ });
+ expect(renameResponse.error).toBeUndefined();
+ expect(renameResponse.result).toEqual({ branch: "t3code/new-name" });
+ expect(renameBranch).toHaveBeenCalledWith({
+ cwd: "/repo/path",
+ oldBranch: "t3code/tmp-456",
+ newBranch: "t3code/new-name",
+ });
+ });
+
+ it("skips rename in git.generateAndRenameBranch when branch generation returns null", async () => {
+ const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" }));
+ const generateBranchName = vi.fn(() => Effect.succeed({ branch: null }));
+
+ server = await createTestServer({
+ cwd: "/test",
+ gitCore: {
+ listBranches: () =>
+ Effect.succeed({
+ branches: [],
+ isRepo: false,
+ }),
+ initRepo: () => Effect.void,
+ renameBranch,
+ pullCurrentBranch: () =>
+ Effect.fail(
+ new GitCommandError({
+ operation: "GitCore.test.pullCurrentBranch",
+ detail: "No upstream configured",
+ command: "git pull",
+ cwd: "/repo/path",
+ }),
+ ),
+ },
+ textGeneration: {
+ generateBranchName,
+ },
+ });
+ const addr = server.address();
+ const port = typeof addr === "object" && addr !== null ? addr.port : 0;
+ const ws = await connectWs(port);
+ connections.push(ws);
+ await waitForMessage(ws);
+
+ const response = await sendRequest(ws, WS_METHODS.gitGenerateAndRenameBranch, {
+ cwd: "/repo/path",
+ oldBranch: "t3code/tmp-123",
+ message: "Fix visual regression",
+ attachments: [],
+ });
+
+ expect(response.error).toBeUndefined();
+ expect(response.result).toEqual({ branch: null });
+ expect(renameBranch).not.toHaveBeenCalled();
});
it("supports git.status over websocket", async () => {
diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts
index 5f4fb9d616a3d53c8ef6911c5a405167ec9a169b..838b4c50b0fe9e0a93a9fcd38f2ffd75a978b9cf 100644
--- a/apps/server/src/wsServer.ts
+++ b/apps/server/src/wsServer.ts
@@ -54,6 +54,7 @@ import { clamp } from "effect/Number";
import { Open } from "./open";
import { ServerConfig } from "./config";
import { GitCore } from "./git/Services/GitCore.ts";
+import { TextGeneration } from "./git/Services/TextGeneration.ts";
import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
/**
@@ -135,6 +136,31 @@ function stripRequestTag(body: T) {
return Struct.omit(body, ["_tag"]);
}
+const WORKTREE_BRANCH_PREFIX = "t3code";
+
+function buildGeneratedWorktreeBranchName(raw: string): string {
+ const normalized = raw
+ .trim()
+ .toLowerCase()
+ .replace(/^refs\/heads\//, "")
+ .replace(/['"`]/g, "");
+
+ const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`)
+ ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length)
+ : normalized;
+
+ const branchFragment = withoutPrefix
+ .replace(/[^a-z0-9/_-]+/g, "-")
+ .replace(/\/+/g, "/")
+ .replace(/-+/g, "-")
+ .replace(/^[./_-]+|[./_-]+$/g, "")
+ .slice(0, 64)
+ .replace(/[./_-]+$/g, "");
+
+ const safeFragment = branchFragment.length > 0 ? branchFragment : "update";
+ return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
+}
+
export type ServerCoreRuntimeServices =
| OrchestrationEngineService
| ProjectionSnapshotQuery
@@ -146,6 +172,7 @@ export type ServerRuntimeServices =
| ServerCoreRuntimeServices
| GitManager
| GitCore
+ | TextGeneration
| TerminalManager
| Keybindings
| Open;
@@ -184,6 +211,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
const terminalManager = yield* TerminalManager;
const keybindingsManager = yield* Keybindings;
const git = yield* GitCore;
+ const textGeneration = yield* TextGeneration;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
@@ -572,6 +600,37 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return yield* git.createWorktree(body);
}
+ case WS_METHODS.gitGenerateBranchName: {
+ const body = stripRequestTag(request.body);
+ return yield* textGeneration.generateBranchName(body);
+ }
+
+ case WS_METHODS.gitGenerateAndRenameBranch: {
+ const body = stripRequestTag(request.body);
+ const generated = yield* textGeneration.generateBranchName({
+ cwd: body.cwd,
+ message: body.message,
+ ...(body.attachments !== undefined ? { attachments: body.attachments } : {}),
+ });
+ if (!generated.branch) {
+ return { branch: null };
+ }
+ const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
+ if (targetBranch === body.oldBranch) {
+ return { branch: body.oldBranch };
+ }
+ return yield* git.renameBranch({
+ cwd: body.cwd,
+ oldBranch: body.oldBranch,
+ newBranch: targetBranch,
+ });
+ }
+
+ case WS_METHODS.gitRenameBranch: {
+ const body = stripRequestTag(request.body);
+ return yield* git.renameBranch(body);
+ }
+
case WS_METHODS.gitRemoveWorktree: {
const body = stripRequestTag(request.body);
return yield* git.removeWorktree(body);
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 597d067c8fc83262d3cd8ea64b24a3794894a709..56c31c299d358bd63ad87836f09b97f7f504aba1 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -129,6 +129,7 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
const SCRIPT_TERMINAL_COLS = 120;
const SCRIPT_TERMINAL_ROWS = 30;
+const WORKTREE_BRANCH_PREFIX = "t3code";
function readLastInvokedScriptByProjectFromStorage(): Record {
const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
@@ -232,6 +233,57 @@ function readFileAsDataUrl(file: File): Promise {
});
}
+function hashBranchSeed(value: string): string {
+ let hash = 2166136261;
+ for (let index = 0; index < value.length; index++) {
+ hash ^= value.charCodeAt(index);
+ hash = Math.imul(hash, 16777619);
+ }
+ return (hash >>> 0).toString(16).padStart(8, "0");
+}
+
+function buildTemporaryWorktreeBranchName(message: string): string {
+ const seed = `${Date.now()}:${message.trim().toLowerCase()}:${crypto.randomUUID()}`;
+ return `${WORKTREE_BRANCH_PREFIX}/${hashBranchSeed(seed)}`;
+}
+
+function imageReferenceForBranchNaming(image: ComposerImageAttachment): string {
+ const maybePath = (image.file as File & { path?: unknown }).path;
+ if (typeof maybePath === "string" && maybePath.trim().length > 0) {
+ return maybePath.trim();
+ }
+ return image.name;
+}
+
+function buildBranchNameGenerationMessage(
+ text: string,
+ images: ReadonlyArray,
+): string {
+ const trimmedText = text.trim();
+ const imageReferences = Array.from(
+ new Set(
+ images
+ .map((image) => imageReferenceForBranchNaming(image).trim())
+ .filter((reference) => reference.length > 0),
+ ),
+ );
+ const attachmentsSection =
+ imageReferences.length > 0
+ ? `Attached images:\n${imageReferences.map((reference) => `- ${reference}`).join("\n")}`
+ : "";
+
+ if (trimmedText.length > 0 && attachmentsSection.length > 0) {
+ return `${trimmedText}\n\n${attachmentsSection}`;
+ }
+ if (trimmedText.length > 0) {
+ return trimmedText;
+ }
+ if (attachmentsSection.length > 0) {
+ return attachmentsSection;
+ }
+ return "New worktree change";
+}
+
const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
pathValue: string;
kind: "file" | "directory";
@@ -1478,6 +1530,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
const composerImagesSnapshot = [...composerImages];
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
+ const branchNameMessage = buildBranchNameGenerationMessage(trimmed, composerImagesSnapshot);
+ const turnAttachmentsPromise = Promise.all(
+ composerImagesSnapshot.map(
+ async (
+ image,
+ ): Promise<{
+ type: "image";
+ name: string;
+ mimeType: string;
+ sizeBytes: number;
+ dataUrl: string;
+ }> => ({
+ type: "image",
+ name: image.name,
+ mimeType: image.mimeType,
+ sizeBytes: image.sizeBytes,
+ dataUrl: await readFileAsDataUrl(image.file),
+ }),
+ ),
+ );
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
type: "image" as const,
id: image.id,
@@ -1506,11 +1578,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
setComposerHighlightedItemId(null);
let attemptedTurnStart = false;
+ let pendingWorktreeBranchRename: Promise | null = null;
try {
// On first message: lock in branch + create worktree if needed.
if (baseBranchForWorktree) {
setSendPhase("preparing-worktree");
- const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
+ const newBranch = buildTemporaryWorktreeBranchName(trimmed);
const result = await createWorktreeMutation.mutateAsync({
cwd: activeProject.cwd,
branch: baseBranchForWorktree,
@@ -1531,6 +1604,36 @@ export default function ChatView({ threadId }: ChatViewProps) {
branch: result.worktree.branch,
worktreePath: result.worktree.path,
});
+ pendingWorktreeBranchRename = (async () => {
+ const turnAttachments = await turnAttachmentsPromise;
+ const renamed = await api.git.generateAndRenameBranch({
+ cwd: result.worktree.path,
+ oldBranch: result.worktree.branch,
+ message: branchNameMessage,
+ attachments: turnAttachments,
+ });
+ if (!renamed.branch || renamed.branch === result.worktree.branch) {
+ return;
+ }
+ await api.orchestration.dispatchCommand({
+ type: "thread.meta.update",
+ commandId: newCommandId(),
+ threadId: threadIdForSend,
+ branch: renamed.branch,
+ worktreePath: result.worktree.path,
+ });
+ dispatch({
+ type: "SET_THREAD_BRANCH",
+ threadId: threadIdForSend,
+ branch: renamed.branch,
+ worktreePath: result.worktree.path,
+ });
+ })().catch((error) => {
+ console.warn("Failed to rename generated worktree branch", {
+ threadId: threadIdForSend,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ });
const setupScript = setupProjectScript(activeProject.scripts);
if (setupScript) {
await runProjectScript(setupScript, {
@@ -1558,25 +1661,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}
setSendPhase("sending-turn");
- const turnAttachments = await Promise.all(
- composerImagesSnapshot.map(
- async (
- image,
- ): Promise<{
- type: "image";
- name: string;
- mimeType: string;
- sizeBytes: number;
- dataUrl: string;
- }> => ({
- type: "image",
- name: image.name,
- mimeType: image.mimeType,
- sizeBytes: image.sizeBytes,
- dataUrl: await readFileAsDataUrl(image.file),
- }),
- ),
- );
+ const turnAttachments = await turnAttachmentsPromise;
attemptedTurnStart = true;
const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
const sandboxMode =
@@ -1598,6 +1683,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
sandboxMode,
createdAt: messageCreatedAt,
});
+ if (pendingWorktreeBranchRename) {
+ void pendingWorktreeBranchRename;
+ }
} catch (err) {
if (
!attemptedTurnStart &&
diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts
index 2d997f2f8dd34a64c98eec5e9b4fbbb8fbccdfac..4b4d90227a5969468d0d441802c9e31314240e52 100644
--- a/apps/web/src/worktreeCleanup.test.ts
+++ b/apps/web/src/worktreeCleanup.test.ts
@@ -88,16 +88,16 @@ describe("getOrphanedWorktreePathForThread", () => {
describe("formatWorktreePathForDisplay", () => {
it("shows only the last path segment for unix-like paths", () => {
const result = formatWorktreePathForDisplay(
- "/Users/julius/.t3/worktrees/codething-mvp/codething-4e609bb8",
+ "/Users/julius/.t3/worktrees/t3code-mvp/t3code-4e609bb8",
);
- expect(result).toBe("codething-4e609bb8");
+ expect(result).toBe("t3code-4e609bb8");
});
it("normalizes windows separators before selecting the final segment", () => {
const result = formatWorktreePathForDisplay(
- "C:\\Users\\julius\\.t3\\worktrees\\codething-mvp\\codething-4e609bb8",
+ "C:\\Users\\julius\\.t3\\worktrees\\t3code-mvp\\t3code-4e609bb8",
);
- expect(result).toBe("codething-4e609bb8");
+ expect(result).toBe("t3code-4e609bb8");
});
it("uses the final segment even when outside ~/.t3/worktrees", () => {
diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts
index f7ac04a9b07a6b4b10dc3214ea53b41b067a7e31..d7b08779258324091ef682d69e25cd8cb0fd12d8 100644
--- a/apps/web/src/wsNativeApi.ts
+++ b/apps/web/src/wsNativeApi.ts
@@ -164,6 +164,10 @@ export function createWsNativeApi(): NativeApi {
runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
+ generateBranchName: (input) => transport.request(WS_METHODS.gitGenerateBranchName, input),
+ generateAndRenameBranch: (input) =>
+ transport.request(WS_METHODS.gitGenerateAndRenameBranch, input),
+ renameBranch: (input) => transport.request(WS_METHODS.gitRenameBranch, input),
removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),
checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),
diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts
index e0441366ef5aaae7ed893f26f7c5b14a4e97eda2..4f9df0ccc1f90ab51d6cddfe2852a63756c25f2e 100644
--- a/packages/contracts/src/git.ts
+++ b/packages/contracts/src/git.ts
@@ -1,5 +1,6 @@
import { Schema } from "effect";
import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
+import { ChatAttachment, PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "./orchestration";
const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
@@ -69,6 +70,32 @@ export const GitCreateWorktreeInput = Schema.Struct({
});
export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;
+export const GitGenerateBranchNameInput = Schema.Struct({
+ cwd: TrimmedNonEmptyStringSchema,
+ message: TrimmedNonEmptyStringSchema,
+ attachments: Schema.optional(
+ Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
+ ),
+});
+export type GitGenerateBranchNameInput = typeof GitGenerateBranchNameInput.Type;
+
+export const GitGenerateAndRenameBranchInput = Schema.Struct({
+ cwd: TrimmedNonEmptyStringSchema,
+ oldBranch: TrimmedNonEmptyStringSchema,
+ message: TrimmedNonEmptyStringSchema,
+ attachments: Schema.optional(
+ Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
+ ),
+});
+export type GitGenerateAndRenameBranchInput = typeof GitGenerateAndRenameBranchInput.Type;
+
+export const GitRenameBranchInput = Schema.Struct({
+ cwd: TrimmedNonEmptyStringSchema,
+ oldBranch: TrimmedNonEmptyStringSchema,
+ newBranch: TrimmedNonEmptyStringSchema,
+});
+export type GitRenameBranchInput = typeof GitRenameBranchInput.Type;
+
export const GitRemoveWorktreeInput = Schema.Struct({
cwd: TrimmedNonEmptyStringSchema,
path: TrimmedNonEmptyStringSchema,
@@ -137,6 +164,21 @@ export const GitCreateWorktreeResult = Schema.Struct({
});
export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;
+export const GitGenerateBranchNameResult = Schema.Struct({
+ branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
+});
+export type GitGenerateBranchNameResult = typeof GitGenerateBranchNameResult.Type;
+
+export const GitGenerateAndRenameBranchResult = Schema.Struct({
+ branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
+});
+export type GitGenerateAndRenameBranchResult = typeof GitGenerateAndRenameBranchResult.Type;
+
+export const GitRenameBranchResult = Schema.Struct({
+ branch: TrimmedNonEmptyStringSchema,
+});
+export type GitRenameBranchResult = typeof GitRenameBranchResult.Type;
+
export const GitRunStackedActionResult = Schema.Struct({
action: GitStackedAction,
commit: Schema.Struct({
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index ce1c0305a54a918515205b55ba98a2fcfad11c0b..c68ba8e0bf1ea6cdc05d2d938261e57b6506c3a2 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -1,6 +1,12 @@
import type {
GitCheckoutInput,
GitCreateBranchInput,
+ GitGenerateAndRenameBranchInput,
+ GitGenerateAndRenameBranchResult,
+ GitGenerateBranchNameInput,
+ GitGenerateBranchNameResult,
+ GitRenameBranchInput,
+ GitRenameBranchResult,
GitCreateWorktreeInput,
GitCreateWorktreeResult,
GitInitInput,
@@ -74,6 +80,11 @@ export interface NativeApi {
// Existing branch/worktree API
listBranches: (input: GitListBranchesInput) => Promise;
createWorktree: (input: GitCreateWorktreeInput) => Promise;
+ generateBranchName: (input: GitGenerateBranchNameInput) => Promise;
+ generateAndRenameBranch: (
+ input: GitGenerateAndRenameBranchInput,
+ ) => Promise;
+ renameBranch: (input: GitRenameBranchInput) => Promise;
removeWorktree: (input: GitRemoveWorktreeInput) => Promise;
createBranch: (input: GitCreateBranchInput) => Promise;
checkout: (input: GitCheckoutInput) => Promise;
diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts
index 7f94615bdd47cc146697ac238a5b4590627b4c4f..42f3c3f0f9b13f83c1f978f549491122f1602e03 100644
--- a/packages/contracts/src/ws.ts
+++ b/packages/contracts/src/ws.ts
@@ -11,11 +11,14 @@ import {
} from "./orchestration";
import {
GitCheckoutInput,
+ GitGenerateAndRenameBranchInput,
GitCreateBranchInput,
+ GitGenerateBranchNameInput,
GitCreateWorktreeInput,
GitInitInput,
GitListBranchesInput,
GitPullInput,
+ GitRenameBranchInput,
GitRemoveWorktreeInput,
GitRunStackedActionInput,
GitStatusInput,
@@ -50,6 +53,9 @@ export const WS_METHODS = {
gitRunStackedAction: "git.runStackedAction",
gitListBranches: "git.listBranches",
gitCreateWorktree: "git.createWorktree",
+ gitGenerateBranchName: "git.generateBranchName",
+ gitGenerateAndRenameBranch: "git.generateAndRenameBranch",
+ gitRenameBranch: "git.renameBranch",
gitRemoveWorktree: "git.removeWorktree",
gitCreateBranch: "git.createBranch",
gitCheckout: "git.checkout",
@@ -111,6 +117,9 @@ export const WebSocketRequestBody = Schema.Union([
tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),
tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),
tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),
+ tagRequestBody(WS_METHODS.gitGenerateBranchName, GitGenerateBranchNameInput),
+ tagRequestBody(WS_METHODS.gitGenerateAndRenameBranch, GitGenerateAndRenameBranchInput),
+ tagRequestBody(WS_METHODS.gitRenameBranch, GitRenameBranchInput),
tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),
tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput),
tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput),