History

Add AI-generated worktree branch naming and safe branch rename

- add `git.generateBranchName`, `git.generateAndRenameBranch`, and `git.renameBranch` websocket flows - pass image attachments into Codex branch-name generation and normalize/sanitize results - add GitCore branch rename with collision-safe numeric suffixing - extract shared image MIME/data-url helpers and expand server/web tests for new behavior

Julius Marminge committed 8 months ago

Commit 8416ecd1 parent 206eb6a18 files · +897 −81Browse files

Unified · Split

18 files
  1. apps/server/src/git/Layers/CodexTextGeneration.test.ts +96 −0
  2. apps/server/src/git/Layers/CodexTextGeneration.ts +167 −4
  3. apps/server/src/git/Layers/GitCore.test.ts +96 −0
  4. apps/server/src/git/Layers/GitCore.ts +58 −0
  5. apps/server/src/git/Layers/GitManager.test.ts +19 −0
  6. apps/server/src/git/Services/GitCore.ts +9 −0
  7. apps/server/src/git/Services/TextGeneration.ts +19 −0
  8. apps/server/src/imageMime.ts +64 −0
  9. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +2 −28
  10. apps/server/src/projectFaviconRoute.ts +1 −24
  11. apps/server/src/wsServer.test.ts +129 −1
  12. apps/server/src/wsServer.ts +59 −0
  13. apps/web/src/components/ChatView.tsx +108 −20
  14. apps/web/src/worktreeCleanup.test.ts +4 −4
  15. apps/web/src/wsNativeApi.ts +4 −0
  16. packages/contracts/src/git.ts +42 −0
  17. packages/contracts/src/ipc.ts +11 −0
  18. packages/contracts/src/ws.ts +9 −0
apps/server/src/git/Layers/CodexTextGeneration.test.ts+96−0
@@ -21,12 +21,23 @@ function makeFakeCodexBinary(dir: string) { · 20 unmodified lines
2121 "#!/bin/sh",
2222 'output_path=""',
2323 "while [ $# -gt 0 ]; do",
24+ ' if [ "$1" = "--image" ]; then',
25+ " shift",
26+ ' if [ -n "$1" ]; then',
27+ ' seen_image="1"',
28+ " fi",
29+ " continue",
30+ " fi",
2431 ' if [ "$1" = "--output-last-message" ]; then',
2532 " shift",
2633 ' output_path="$1"',
2734 " fi",
2835 " shift",
2936 "done",
37+ 'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then',
38+ ' printf "%s\\n" "missing --image input" >&2',
39+ " exit 2",
40+ "fi",
3041 "cat >/dev/null",
3142 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then',
3243 ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2',
@@ -48,6 +59,7 @@ function withFakeCodexEnv<A, E, R>( · 15 unmodified lines
4859 output: string;
4960 exitCode?: number;
5061 stderr?: string;
62+ requireImage?: boolean;
5163 },
5264 effect: Effect.Effect<A, E, R>,
5365 ) {
@@ -60,6 +72,7 @@ function withFakeCodexEnv<A, E, R>( · 6 unmodified lines
6072 const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64;
6173 const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;
6274 const previousStderr = process.env.T3_FAKE_CODEX_STDERR;
75+ const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
6376
6477 yield* Effect.sync(() => {
6578 process.env.PATH = `${binDir}:${previousPath ?? ""}`;
@@ -76,6 +89,12 @@ function withFakeCodexEnv<A, E, R>( · 10 unmodified lines
7689 } else {
7790 delete process.env.T3_FAKE_CODEX_STDERR;
7891 }
92+
93+ if (input.requireImage) {
94+ process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = "1";
95+ } else {
96+ delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
97+ }
7998 });
8099
81100 return {
@@ -83,6 +102,7 @@ function withFakeCodexEnv<A, E, R>( · 1 unmodified line
83102 previousOutput,
84103 previousExitCode,
85104 previousStderr,
105+ previousRequireImage,
86106 };
87107 }),
88108 () => effect,
@@ -107,6 +127,12 @@ function withFakeCodexEnv<A, E, R>( · 18 unmodified lines
107127 } else {
108128 process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr;
109129 }
130+
131+ if (previous.previousRequireImage === undefined) {
132+ delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
133+ } else {
134+ process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;
135+ }
110136 }),
111137 );
112138 }
@@ -170,6 +196,76 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 57 unmodified lines
170196 ),
171197 );
172198
199+ it.effect("generates branch names and normalizes branch fragments", () =>
200+ withFakeCodexEnv(
201+ {
202+ output: JSON.stringify({
203+ branch: " Feat/Session ",
204+ }),
205+ },
206+ Effect.gen(function* () {
207+ const textGeneration = yield* TextGeneration;
208+
209+ const generated = yield* textGeneration.generateBranchName({
210+ cwd: process.cwd(),
211+ message: "Please update session handling.",
212+ });
213+
214+ expect(generated.branch).toBe("feat/session");
215+ }),
216+ ),
217+ );
218+
219+ it.effect("passes image attachments through as codex image inputs", () =>
220+ withFakeCodexEnv(
221+ {
222+ output: JSON.stringify({
223+ branch: "fix/ui-regression",
224+ }),
225+ requireImage: true,
226+ },
227+ Effect.gen(function* () {
228+ const textGeneration = yield* TextGeneration;
229+
230+ const generated = yield* textGeneration.generateBranchName({
231+ cwd: process.cwd(),
232+ message: "Fix layout bug from screenshot.",
233+ attachments: [
234+ {
235+ type: "image",
236+ name: "bug.png",
237+ mimeType: "image/png",
238+ sizeBytes: 5,
239+ dataUrl: "data:image/png;base64,SGVsbG8=",
240+ },
241+ ],
242+ });
243+
244+ expect(generated.branch).toBe("fix/ui-regression");
245+ }),
246+ ),
247+ );
248+
249+ it.effect("returns null branch when codex returns the wrong object shape", () =>
250+ withFakeCodexEnv(
251+ {
252+ output: JSON.stringify({
253+ title: "This is not a branch payload",
254+ }),
255+ },
256+ Effect.gen(function* () {
257+ const textGeneration = yield* TextGeneration;
258+
259+ const generated = yield* textGeneration.generateBranchName({
260+ cwd: process.cwd(),
261+ message: "Fix websocket reconnect flake",
262+ });
263+
264+ expect(generated.branch).toBeNull();
265+ }),
266+ ),
267+ );
268+
173269 it.effect("returns typed TextGenerationError when codex exits non-zero", () =>
174270 withFakeCodexEnv(
175271 {
apps/server/src/git/Layers/CodexTextGeneration.ts+167−4
@@ -4,7 +4,10 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect" · 3 unmodified lines
44 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
55
66 import { TextGenerationError } from "../Errors.ts";
7+import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
78 import {
9+ type BranchNameGenerationInput,
10+ type BranchNameGenerationResult,
811 type CommitMessageGenerationResult,
912 type PrContentGenerationResult,
1013 type TextGenerationShape,
@@ -35,6 +38,15 @@ const PR_OUTPUT_SCHEMA_JSON = { · 24 unmodified lines
3538 additionalProperties: false,
3639 } as const;
3740
41+const BRANCH_NAME_OUTPUT_SCHEMA_JSON = {
42+ type: "object",
43+ properties: {
44+ branch: { type: "string" },
45+ },
46+ required: ["branch"],
47+ additionalProperties: false,
48+} as const;
49+
3850 function normalizeCodexError(
3951 operation: string,
4052 error: unknown,
@@ -97,6 +109,18 @@ function parsePrOutput(raw: unknown): { title: string; body: string } { · 56 unmodified lines
97109 return { title, body };
98110 }
99111
112+function parseBranchNameOutput(raw: unknown): { branch: string } {
113+ if (!raw || typeof raw !== "object") {
114+ throw new Error("Codex returned a non-object branch payload.");
115+ }
116+ const record = raw as Record<string, unknown>;
117+ const branch = typeof record.branch === "string" ? record.branch.trim() : "";
118+ if (branch.length === 0) {
119+ throw new Error("Codex returned an empty branch name payload.");
120+ }
121+ return { branch };
122+}
123+
100124 function limitSection(value: string, maxChars: number): string {
101125 if (value.length <= maxChars) return value;
102126 const truncated = value.slice(0, maxChars);
@@ -124,6 +148,24 @@ function sanitizePrTitle(raw: string): string { · 21 unmodified lines
124148 return "Update project changes";
125149 }
126150
151+function sanitizeBranchName(raw: string): string {
152+ const normalized = raw
153+ .trim()
154+ .toLowerCase()
155+ .replace(/['"`]/g, "")
156+ .replace(/^[./\s_-]+|[./\s_-]+$/g, "");
157+
158+ const branchFragment = normalized
159+ .replace(/[^a-z0-9/_-]+/g, "-")
160+ .replace(/\/+/g, "/")
161+ .replace(/-+/g, "-")
162+ .replace(/^[./_-]+|[./_-]+$/g, "")
163+ .slice(0, 64)
164+ .replace(/[./_-]+$/g, "");
165+
166+ return branchFragment.length > 0 ? branchFragment : "update";
167+}
168+
127169 const makeCodexTextGeneration = Effect.gen(function* () {
128170 const fileSystem = yield* FileSystem.FileSystem;
129171 const path = yield* Path.Path;
@@ -168,20 +210,86 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 38 unmodified lines
168210 );
169211 };
170212
213+ const writeTempBinaryFile = (
214+ operation: string,
215+ prefix: string,
216+ bytes: Uint8Array,
217+ extension: string,
218+ ): Effect.Effect<string, TextGenerationError> => {
219+ const normalizedExtension = extension.startsWith(".") ? extension : `.${extension}`;
220+ const filePath = path.join(
221+ tempDir,
222+ `t3code-${prefix}-${process.pid}-${randomUUID()}${normalizedExtension}`,
223+ );
224+ return fileSystem.writeFile(filePath, bytes).pipe(
225+ Effect.mapError(
226+ (cause) =>
227+ new TextGenerationError({
228+ operation,
229+ detail: `Failed to write temp file at ${filePath}.`,
230+ cause,
231+ }),
232+ ),
233+ Effect.as(filePath),
234+ );
235+ };
236+
171237 const safeUnlink = (filePath: string): Effect.Effect<void, never> =>
172238 fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));
173239
240+ const materializeImageAttachments = (
241+ operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName",
242+ attachments: BranchNameGenerationInput["attachments"],
243+ ): Effect.Effect<ReadonlyArray<string>, TextGenerationError> =>
244+ Effect.gen(function* () {
245+ if (!attachments || attachments.length === 0) {
246+ return [];
247+ }
248+
249+ const imagePaths: string[] = [];
250+ for (const [index, attachment] of attachments.entries()) {
251+ if (attachment.type !== "image") {
252+ continue;
253+ }
254+
255+ const parsed = parseBase64DataUrl(attachment.dataUrl);
256+ if (!parsed || !parsed.mimeType.startsWith("image/")) {
257+ continue;
258+ }
259+
260+ const bytes = Buffer.from(parsed.base64, "base64");
261+ if (bytes.byteLength === 0) {
262+ continue;
263+ }
264+
265+ const extension = inferImageExtension({
266+ mimeType: parsed.mimeType,
267+ fileName: attachment.name,
268+ });
269+ const imagePath = yield* writeTempBinaryFile(
270+ operation,
271+ `codex-image-${index}`,
272+ bytes,
273+ extension,
274+ );
275+ imagePaths.push(imagePath);
276+ }
277+ return imagePaths;
278+ });
279+
174280 const runCodexJson = <T>({
175281 operation,
176282 cwd,
177283 prompt,
178284 outputSchemaJson,
285+ imagePaths = [],
179286 parse,
180287 }: {
181− operation: "generateCommitMessage" | "generatePrContent";
288+ operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
182289 cwd: string;
183290 prompt: string;
184291 outputSchemaJson: object;
292+ imagePaths?: ReadonlyArray<string>;
185293 parse: (raw: unknown) => T;
186294 }): Effect.Effect<T, TextGenerationError> =>
187295 Effect.gen(function* () {
@@ -208,6 +316,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 20 unmodified lines
208316 schemaPath,
209317 "--output-last-message",
210318 outputPath,
319+ ...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
211320 "-",
212321 ],
213322 {
@@ -254,9 +363,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 40 unmodified lines
254363 }
255364 });
256365
257− const cleanup = Effect.all([safeUnlink(schemaPath), safeUnlink(outputPath)], {
258− concurrency: "unbounded",
259− }).pipe(Effect.asVoid);
366+ const cleanup = Effect.all(
367+ [schemaPath, outputPath, ...imagePaths].map((filePath) => safeUnlink(filePath)),
368+ {
369+ concurrency: "unbounded",
370+ },
371+ ).pipe(Effect.asVoid);
260372
261373 return yield* Effect.gen(function* () {
262374 yield* runCodexCommand.pipe(
@@ -384,9 +496,60 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 121 unmodified lines
384496 );
385497 };
386498
499+ const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
500+ return Effect.gen(function* () {
501+ const imagePaths = yield* materializeImageAttachments("generateBranchName", input.attachments);
502+ const attachmentLines = (input.attachments ?? []).map(
503+ (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
504+ );
505+
506+ const prompt = [
507+ "You generate concise git branch names.",
508+ "Return a JSON object with key: branch.",
509+ "Rules:",
510+ "- Branch should describe the requested work from the user message.",
511+ "- Keep it short and specific (2-6 words).",
512+ "- Use plain words only, no issue prefixes and no punctuation-heavy text.",
513+ "- If images are attached, use them as primary context for visual/UI issues.",
514+ "",
515+ `Image attachments supplied to the model: ${imagePaths.length}`,
516+ "",
517+ "User message:",
518+ limitSection(input.message, 8_000),
519+ "",
520+ "Attachment metadata:",
521+ attachmentLines.length > 0 ? limitSection(attachmentLines.join("\n"), 4_000) : "- none",
522+ ].join("\n");
523+
524+ const generated = yield* runCodexJson({
525+ operation: "generateBranchName",
526+ cwd: input.cwd,
527+ prompt,
528+ outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON,
529+ imagePaths,
530+ parse: (raw) => parseBranchNameOutput(raw),
531+ }).pipe(
532+ Effect.catch((error) =>
533+ Effect.gen(function* () {
534+ yield* Effect.logWarning("branch-name generation failed, skipping rename", {
535+ operation: "generateBranchName",
536+ reason: error instanceof Error ? error.message : String(error),
537+ });
538+ return { branch: null };
539+ }),
540+ ),
541+ );
542+
543+ return {
544+ branch: generated.branch ? sanitizeBranchName(generated.branch) : null,
545+ } satisfies BranchNameGenerationResult;
546+ });
547+ };
548+
387549 return {
388550 generateCommitMessage,
389551 generatePrContent,
552+ generateBranchName,
390553 } satisfies TextGenerationShape;
391554 });
392555
apps/server/src/git/Layers/GitCore.test.ts+96−0
@@ -106,6 +106,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => · 105 unmodified lines
106106 listBranches: (input) => core.listBranches(input),
107107 createWorktree: (input) => core.createWorktree(input),
108108 removeWorktree: (input) => core.removeWorktree(input),
109+ renameBranch: (input) => core.renameBranch(input),
109110 createBranch: (input) => core.createBranch(input),
110111 checkoutBranch: (input) => core.checkoutBranch(input),
111112 initRepo: (input) => core.initRepo(input),
@@ -154,6 +155,13 @@ function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) · 42 unmodified lines
154155 });
155156 }
156157
158+function renameGitBranch(input: Parameters<GitCoreShape["renameBranch"]>[0]) {
159+ return Effect.gen(function* () {
160+ const core = yield* GitCore;
161+ return yield* core.renameBranch(input);
162+ });
163+}
164+
157165 function pullGitBranch({ cwd }: { cwd: string }) {
158166 return Effect.gen(function* () {
159167 const core = yield* GitCore;
@@ -650,6 +658,94 @@ it.layer(TestLayer)("git integration", (it) => { · 490 unmodified lines
650658 );
651659 });
652660
661+ // ── renameGitBranch ──
662+
663+ describe("renameGitBranch", () => {
664+ it.effect("renames the current branch", () =>
665+ Effect.gen(function* () {
666+ const tmp = yield* makeTmpDir();
667+ yield* initRepoWithCommit(tmp);
668+ yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" });
669+ yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" });
670+
671+ const renamed = yield* renameGitBranch({
672+ cwd: tmp,
673+ oldBranch: "feature/old-name",
674+ newBranch: "feature/new-name",
675+ });
676+
677+ expect(renamed.branch).toBe("feature/new-name");
678+
679+ const branches = yield* listGitBranches({ cwd: tmp });
680+ expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false);
681+ const current = branches.branches.find((branch) => branch.current);
682+ expect(current?.name).toBe("feature/new-name");
683+ }),
684+ );
685+
686+ it.effect("returns success without git invocation when old/new names match", () =>
687+ Effect.gen(function* () {
688+ const tmp = yield* makeTmpDir();
689+ yield* initRepoWithCommit(tmp);
690+ const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!;
691+
692+ const renamed = yield* renameGitBranch({
693+ cwd: tmp,
694+ oldBranch: current.name,
695+ newBranch: current.name,
696+ });
697+
698+ expect(renamed.branch).toBe(current.name);
699+ }),
700+ );
701+
702+ it.effect("appends numeric suffix when target branch already exists", () =>
703+ Effect.gen(function* () {
704+ const tmp = yield* makeTmpDir();
705+ yield* initRepoWithCommit(tmp);
706+ yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });
707+ yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
708+ yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
709+
710+ const renamed = yield* renameGitBranch({
711+ cwd: tmp,
712+ oldBranch: "t3code/tmp-working",
713+ newBranch: "t3code/feat/session",
714+ });
715+
716+ expect(renamed.branch).toBe("t3code/feat/session-1");
717+ const branches = yield* listGitBranches({ cwd: tmp });
718+ expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe(
719+ true,
720+ );
721+ expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe(
722+ true,
723+ );
724+ const current = branches.branches.find((branch) => branch.current);
725+ expect(current?.name).toBe("t3code/feat/session-1");
726+ }),
727+ );
728+
729+ it.effect("increments suffix until it finds an available branch name", () =>
730+ Effect.gen(function* () {
731+ const tmp = yield* makeTmpDir();
732+ yield* initRepoWithCommit(tmp);
733+ yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });
734+ yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" });
735+ yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
736+ yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
737+
738+ const renamed = yield* renameGitBranch({
739+ cwd: tmp,
740+ oldBranch: "t3code/tmp-working",
741+ newBranch: "t3code/feat/session",
742+ });
743+
744+ expect(renamed.branch).toBe("t3code/feat/session-2");
745+ }),
746+ );
747+ });
748+
653749 // ── createGitWorktree + removeGitWorktree ──
654750
655751 describe("createGitWorktree", () => {
apps/server/src/git/Layers/GitCore.ts+58−0
@@ -158,6 +158,43 @@ const makeGitCore = Effect.gen(function* () { · 157 unmodified lines
158158 Effect.map((result) => result.stdout),
159159 );
160160
161+ const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>
162+ executeGit(
163+ "GitCore.branchExists",
164+ cwd,
165+ ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
166+ {
167+ allowNonZeroExit: true,
168+ timeoutMs: 5_000,
169+ },
170+ ).pipe(Effect.map((result) => result.code === 0));
171+
172+ const resolveAvailableBranchName = (
173+ cwd: string,
174+ desiredBranch: string,
175+ ): Effect.Effect<string, GitCommandError> =>
176+ Effect.gen(function* () {
177+ const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
178+ if (!isDesiredTaken) {
179+ return desiredBranch;
180+ }
181+
182+ for (let suffix = 1; suffix <= 100; suffix += 1) {
183+ const candidate = `${desiredBranch}-${suffix}`;
184+ const isCandidateTaken = yield* branchExists(cwd, candidate);
185+ if (!isCandidateTaken) {
186+ return candidate;
187+ }
188+ }
189+
190+ return yield* createGitCommandError(
191+ "GitCore.renameBranch",
192+ cwd,
193+ ["branch", "-m", desiredBranch],
194+ `Could not find an available branch name for '${desiredBranch}'.`,
195+ );
196+ });
197+
161198 const resolveCurrentUpstream = (
162199 cwd: string,
163200 ): Effect.Effect<
@@ -711,6 +748,26 @@ const makeGitCore = Effect.gen(function* () { · 547 unmodified lines
711748 );
712749 });
713750
751+ const renameBranch: GitCoreShape["renameBranch"] = (input) =>
752+ Effect.gen(function* () {
753+ if (input.oldBranch === input.newBranch) {
754+ return { branch: input.newBranch };
755+ }
756+ const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
757+
758+ yield* executeGit(
759+ "GitCore.renameBranch",
760+ input.cwd,
761+ ["branch", "-m", input.oldBranch, targetBranch],
762+ {
763+ timeoutMs: 10_000,
764+ fallbackErrorMessage: "git branch rename failed",
765+ },
766+ );
767+
768+ return { branch: targetBranch };
769+ });
770+
714771 const createBranch: GitCoreShape["createBranch"] = (input) =>
715772 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
716773 timeoutMs: 10_000,
@@ -750,6 +807,7 @@ const makeGitCore = Effect.gen(function* () { · 33 unmodified lines
750807 listBranches,
751808 createWorktree,
752809 removeWorktree,
810+ renameBranch,
753811 createBranch,
754812 checkoutBranch,
755813 initRepo,
apps/server/src/git/Layers/GitManager.test.ts+19−0
@@ -41,6 +41,10 @@ interface FakeGitTextGeneration { · 40 unmodified lines
4141 diffSummary: string;
4242 diffPatch: string;
4343 }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>;
44+ generateBranchName: (input: {
45+ cwd: string;
46+ message: string;
47+ }) => Effect.Effect<{ branch: string }, TextGenerationError>;
4448 }
4549
4650 function makeTempDir(
@@ -110,6 +114,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T · 63 unmodified lines
110114 title: "Add stacked git actions",
111115 body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run",
112116 }),
117+ generateBranchName: () =>
118+ Effect.succeed({
119+ branch: "update-workflow",
120+ }),
113121 ...overrides,
114122 };
115123
@@ -136,6 +144,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T · 20 unmodified lines
136144 }),
137145 ),
138146 ),
147+ generateBranchName: (input) =>
148+ implementation.generateBranchName(input).pipe(
149+ Effect.mapError(
150+ (cause) =>
151+ new TextGenerationError({
152+ operation: "generateBranchName",
153+ detail: "fake text generation failed",
154+ ...(cause !== undefined ? { cause } : {}),
155+ }),
156+ ),
157+ ),
139158 };
140159 }
141160
apps/server/src/git/Services/GitCore.ts+9−0
@@ -17,6 +17,8 @@ import type { · 16 unmodified lines
1717 GitListBranchesInput,
1818 GitListBranchesResult,
1919 GitPullResult,
20+ GitRenameBranchInput,
21+ GitRenameBranchResult,
2022 GitRemoveWorktreeInput,
2123 GitStatusInput,
2224 GitStatusResult,
@@ -124,6 +126,13 @@ export interface GitCoreShape { · 101 unmodified lines
124126 */
125127 readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
126128
129+ /**
130+ * Rename an existing local branch.
131+ */
132+ readonly renameBranch: (
133+ input: GitRenameBranchInput,
134+ ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
135+
127136 /**
128137 * Create a local branch.
129138 */
apps/server/src/git/Services/TextGeneration.ts+19−0
@@ -8,6 +8,7 @@ · 7 unmodified lines
88 */
99 import { ServiceMap } from "effect";
1010 import type { Effect } from "effect";
11+import type { ChatAttachment } from "@t3tools/contracts";
1112
1213 import type { TextGenerationError } from "../Errors.ts";
1314
@@ -37,11 +38,22 @@ export interface PrContentGenerationResult { · 23 unmodified lines
3738 body: string;
3839 }
3940
41+export interface BranchNameGenerationInput {
42+ cwd: string;
43+ message: string;
44+ attachments?: ReadonlyArray<ChatAttachment> | undefined;
45+}
46+
47+export interface BranchNameGenerationResult {
48+ branch: string | null;
49+}
50+
4051 export interface TextGenerationService {
4152 generateCommitMessage(
4253 input: CommitMessageGenerationInput,
4354 ): Promise<CommitMessageGenerationResult>;
4455 generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>;
56+ generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>;
4557 }
4658
4759 /**
@@ -61,6 +73,13 @@ export interface TextGenerationShape { · 13 unmodified lines
6173 readonly generatePrContent: (
6274 input: PrContentGenerationInput,
6375 ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>;
76+
77+ /**
78+ * Generate a concise branch name from a user message.
79+ */
80+ readonly generateBranchName: (
81+ input: BranchNameGenerationInput,
82+ ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;
6483 }
6584
6685 /**
apps/server/src/imageMime.ts+64−0
@@ -0,0 +1,64 @@
1+import Mime from "@effect/platform-node/Mime";
2+
3+export const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
4+ "image/avif": ".avif",
5+ "image/bmp": ".bmp",
6+ "image/gif": ".gif",
7+ "image/heic": ".heic",
8+ "image/heif": ".heif",
9+ "image/jpeg": ".jpg",
10+ "image/jpg": ".jpg",
11+ "image/png": ".png",
12+ "image/svg+xml": ".svg",
13+ "image/tiff": ".tiff",
14+ "image/webp": ".webp",
15+};
16+
17+export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
18+ ".avif",
19+ ".bmp",
20+ ".gif",
21+ ".heic",
22+ ".heif",
23+ ".ico",
24+ ".jpeg",
25+ ".jpg",
26+ ".png",
27+ ".svg",
28+ ".tiff",
29+ ".webp",
30+]);
31+
32+export function parseBase64DataUrl(
33+ dataUrl: string,
34+): { readonly mimeType: string; readonly base64: string } | null {
35+ const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim());
36+ if (!match) return null;
37+
38+ const mimeType = match[1]?.trim().toLowerCase();
39+ const base64 = match[2]?.replace(/\s+/g, "");
40+ if (!mimeType || !base64) return null;
41+
42+ return { mimeType, base64 };
43+}
44+
45+export function inferImageExtension(input: { mimeType: string; fileName?: string }): string {
46+ const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[input.mimeType.toLowerCase()];
47+ if (fromMime) {
48+ return fromMime;
49+ }
50+
51+ const fromMimeExtension = Mime.getExtension(input.mimeType);
52+ if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) {
53+ return fromMimeExtension;
54+ }
55+
56+ const fileName = input.fileName?.trim() ?? "";
57+ const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName);
58+ const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : "";
59+ if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) {
60+ return fileNameExtension;
61+ }
62+
63+ return ".bin";
64+}
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+2−28
@@ -1,4 +1,3 @@
1−import Mime from "@effect/platform-node/Mime";
21 import {
32 ApprovalRequestId,
43 type ChatAttachment,
@@ -40,9 +39,8 @@ import { · 35 unmodified lines
4039 } from "../Services/ProjectionPipeline.ts";
4140 import {
4241 ATTACHMENTS_ROUTE_PREFIX,
43− IMAGE_EXTENSION_BY_MIME_TYPE,
44− SAFE_IMAGE_FILE_EXTENSIONS,
4542 } from "../../projectFaviconRoute.ts";
43+import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
4644
4745 export const ORCHESTRATION_PROJECTOR_NAMES = {
4846 projects: "projection.projects",
@@ -92,30 +90,6 @@ function toSafeThreadAttachmentSegment(threadId: string): string | null { · 43 unmodified lines
9290 return segment;
9391 }
9492
95−function parseBase64DataUrl(
96− dataUrl: string,
97−): { readonly mimeType: string; readonly base64: string } | null {
98− const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim());
99− if (!match) return null;
100−
101− const mimeType = match[1]?.trim().toLowerCase();
102− const base64 = match[2]?.replace(/\s+/g, "");
103− if (!mimeType || !base64) return null;
104−
105− return { mimeType, base64 };
106−}
107−
108−function inferImageExtension(attachment: Extract<ChatAttachment, { type: "image" }>): string {
109− const normalizedMimeType = attachment.mimeType.toLowerCase();
110− if (normalizedMimeType.startsWith("image/")) {
111− const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[normalizedMimeType];
112− if (fromMime) return fromMime;
113− }
114− const ext = Mime.getExtension(attachment.mimeType);
115− if (ext && SAFE_IMAGE_FILE_EXTENSIONS.has(ext)) return ext;
116− return ".bin";
117−}
118−
11993 const materializeAttachmentsForProjection = Effect.fn(function* (input: {
12094 readonly threadId: string;
12195 readonly messageId: string;
@@ -159,8 +133,8 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: { · 37 unmodified lines
159133 if (bytes.byteLength === 0) return attachment;
160134
161135 const fileName = `${index}${inferImageExtension({
162− ...attachment,
163136 mimeType: parsed.mimeType,
137+ fileName: attachment.name,
164138 })}`;
165139 const uniqueFileName = `${messageSegment}-${fileName}`;
166140 const relativePath = `${threadSegment}/${uniqueFileName}`;
apps/server/src/projectFaviconRoute.ts+1−24
@@ -1,33 +1,10 @@
11 import fs from "node:fs";
22 import http from "node:http";
33 import path from "node:path";
4+export { IMAGE_EXTENSION_BY_MIME_TYPE, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts";
45
56 export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
67
7−export const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
8− "image/png": ".png",
9− "image/jpeg": ".jpg",
10− "image/jpg": ".jpg",
11− "image/gif": ".gif",
12− "image/webp": ".webp",
13− "image/svg+xml": ".svg",
14− "image/bmp": ".bmp",
15− "image/tiff": ".tiff",
16− "image/heic": ".heic",
17−};
18−
19−export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
20− ".jpg",
21− ".jpeg",
22− ".png",
23− ".gif",
24− ".webp",
25− ".bmp",
26− ".tiff",
27− ".svg",
28− ".ico",
29−]);
30−
318 const FAVICON_MIME_TYPES: Record<string, string> = {
329 ".png": "image/png",
3310 ".jpg": "image/jpeg",
apps/server/src/wsServer.test.ts+129−1
@@ -46,6 +46,7 @@ import { Open, type OpenShape } from "./open"; · 45 unmodified lines
4646 import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
4747 import type { GitCoreShape } from "./git/Services/GitCore.ts";
4848 import { GitCore } from "./git/Services/GitCore.ts";
49+import { TextGeneration, type TextGenerationShape } from "./git/Services/TextGeneration.ts";
4950 import { GitCommandError, GitManagerError } from "./git/Errors.ts";
5051 import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
5152
@@ -369,7 +370,11 @@ describe("WebSocket Server", () => { · 317 unmodified lines
369370 providerLayer?: Layer.Layer<ProviderService, never>;
370371 open?: OpenShape;
371372 gitManager?: GitManagerShape;
372− gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">;
373+ gitCore?: Pick<
374+ GitCoreShape,
375+ "listBranches" | "initRepo" | "pullCurrentBranch" | "renameBranch"
376+ >;
377+ textGeneration?: Pick<TextGenerationShape, "generateBranchName">;
373378 terminalManager?: TerminalManagerShape;
374379 } = {},
375380 ): Promise<Http.Server> {
@@ -402,6 +407,9 @@ describe("WebSocket Server", () => { · 26 unmodified lines
402407 options.gitCore
403408 ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape)
404409 : Layer.empty,
410+ options.textGeneration
411+ ? Layer.succeed(TextGeneration, options.textGeneration as unknown as TextGenerationShape)
412+ : Layer.empty,
405413 options.terminalManager
406414 ? Layer.succeed(TerminalManager, options.terminalManager)
407415 : Layer.empty,
@@ -1468,6 +1476,8 @@ describe("WebSocket Server", () => { · 1060 unmodified lines
14681476 }),
14691477 );
14701478 const initRepo = vi.fn(() => Effect.void);
1479+ const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" }));
1480+ const generateBranchName = vi.fn(() => Effect.succeed({ branch: "new-name" as const }));
14711481 const pullCurrentBranch = vi.fn(() =>
14721482 Effect.fail(
14731483 new GitCommandError({
@@ -1484,8 +1494,12 @@ describe("WebSocket Server", () => { · 10 unmodified lines
14841494 gitCore: {
14851495 listBranches,
14861496 initRepo,
1497+ renameBranch,
14871498 pullCurrentBranch,
14881499 },
1500+ textGeneration: {
1501+ generateBranchName,
1502+ },
14891503 });
14901504 const addr = server.address();
14911505 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
@@ -1507,6 +1521,120 @@ describe("WebSocket Server", () => { · 15 unmodified lines
15071521 expect(pullResponse.result).toBeUndefined();
15081522 expect(pullResponse.error?.message).toContain("No upstream configured");
15091523 expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
1524+
1525+ const generatedBranchResponse = await sendRequest(ws, WS_METHODS.gitGenerateBranchName, {
1526+ cwd: "/repo/path",
1527+ message: "Add retry logic for websocket reconnects",
1528+ attachments: [
1529+ {
1530+ type: "image",
1531+ name: "bug.png",
1532+ mimeType: "image/png",
1533+ sizeBytes: 5,
1534+ dataUrl: "data:image/png;base64,SGVsbG8=",
1535+ },
1536+ ],
1537+ });
1538+ expect(generatedBranchResponse.error).toBeUndefined();
1539+ expect(generatedBranchResponse.result).toEqual({ branch: "new-name" });
1540+ expect(generateBranchName).toHaveBeenCalledWith({
1541+ cwd: "/repo/path",
1542+ message: "Add retry logic for websocket reconnects",
1543+ attachments: [
1544+ {
1545+ type: "image",
1546+ name: "bug.png",
1547+ mimeType: "image/png",
1548+ sizeBytes: 5,
1549+ dataUrl: "data:image/png;base64,SGVsbG8=",
1550+ },
1551+ ],
1552+ });
1553+
1554+ const generateAndRenameResponse = await sendRequest(
1555+ ws,
1556+ WS_METHODS.gitGenerateAndRenameBranch,
1557+ {
1558+ cwd: "/repo/path",
1559+ oldBranch: "t3code/tmp-123",
1560+ message: "Add retry logic for websocket reconnects",
1561+ attachments: [
1562+ {
1563+ type: "image",
1564+ name: "bug.png",
1565+ mimeType: "image/png",
1566+ sizeBytes: 5,
1567+ dataUrl: "data:image/png;base64,SGVsbG8=",
1568+ },
1569+ ],
1570+ },
1571+ );
1572+ expect(generateAndRenameResponse.error).toBeUndefined();
1573+ expect(generateAndRenameResponse.result).toEqual({ branch: "t3code/new-name" });
1574+ expect(renameBranch).toHaveBeenCalledWith({
1575+ cwd: "/repo/path",
1576+ oldBranch: "t3code/tmp-123",
1577+ newBranch: "t3code/new-name",
1578+ });
1579+
1580+ const renameResponse = await sendRequest(ws, WS_METHODS.gitRenameBranch, {
1581+ cwd: "/repo/path",
1582+ oldBranch: "t3code/tmp-456",
1583+ newBranch: "t3code/new-name",
1584+ });
1585+ expect(renameResponse.error).toBeUndefined();
1586+ expect(renameResponse.result).toEqual({ branch: "t3code/new-name" });
1587+ expect(renameBranch).toHaveBeenCalledWith({
1588+ cwd: "/repo/path",
1589+ oldBranch: "t3code/tmp-456",
1590+ newBranch: "t3code/new-name",
1591+ });
1592+ });
1593+
1594+ it("skips rename in git.generateAndRenameBranch when branch generation returns null", async () => {
1595+ const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" }));
1596+ const generateBranchName = vi.fn(() => Effect.succeed({ branch: null }));
1597+
1598+ server = await createTestServer({
1599+ cwd: "/test",
1600+ gitCore: {
1601+ listBranches: () =>
1602+ Effect.succeed({
1603+ branches: [],
1604+ isRepo: false,
1605+ }),
1606+ initRepo: () => Effect.void,
1607+ renameBranch,
1608+ pullCurrentBranch: () =>
1609+ Effect.fail(
1610+ new GitCommandError({
1611+ operation: "GitCore.test.pullCurrentBranch",
1612+ detail: "No upstream configured",
1613+ command: "git pull",
1614+ cwd: "/repo/path",
1615+ }),
1616+ ),
1617+ },
1618+ textGeneration: {
1619+ generateBranchName,
1620+ },
1621+ });
1622+ const addr = server.address();
1623+ const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1624+ const ws = await connectWs(port);
1625+ connections.push(ws);
1626+ await waitForMessage(ws);
1627+
1628+ const response = await sendRequest(ws, WS_METHODS.gitGenerateAndRenameBranch, {
1629+ cwd: "/repo/path",
1630+ oldBranch: "t3code/tmp-123",
1631+ message: "Fix visual regression",
1632+ attachments: [],
1633+ });
1634+
1635+ expect(response.error).toBeUndefined();
1636+ expect(response.result).toEqual({ branch: null });
1637+ expect(renameBranch).not.toHaveBeenCalled();
15101638 });
15111639
15121640 it("supports git.status over websocket", async () => {
apps/server/src/wsServer.ts+59−0
@@ -54,6 +54,7 @@ import { clamp } from "effect/Number"; · 53 unmodified lines
5454 import { Open } from "./open";
5555 import { ServerConfig } from "./config";
5656 import { GitCore } from "./git/Services/GitCore.ts";
57+import { TextGeneration } from "./git/Services/TextGeneration.ts";
5758 import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
5859
5960 /**
@@ -135,6 +136,31 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { · 75 unmodified lines
135136 return Struct.omit(body, ["_tag"]);
136137 }
137138
139+const WORKTREE_BRANCH_PREFIX = "t3code";
140+
141+function buildGeneratedWorktreeBranchName(raw: string): string {
142+ const normalized = raw
143+ .trim()
144+ .toLowerCase()
145+ .replace(/^refs\/heads\//, "")
146+ .replace(/['"`]/g, "");
147+
148+ const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`)
149+ ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length)
150+ : normalized;
151+
152+ const branchFragment = withoutPrefix
153+ .replace(/[^a-z0-9/_-]+/g, "-")
154+ .replace(/\/+/g, "/")
155+ .replace(/-+/g, "-")
156+ .replace(/^[./_-]+|[./_-]+$/g, "")
157+ .slice(0, 64)
158+ .replace(/[./_-]+$/g, "");
159+
160+ const safeFragment = branchFragment.length > 0 ? branchFragment : "update";
161+ return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
162+}
163+
138164 export type ServerCoreRuntimeServices =
139165 | OrchestrationEngineService
140166 | ProjectionSnapshotQuery
@@ -146,6 +172,7 @@ export type ServerRuntimeServices = · 5 unmodified lines
146172 | ServerCoreRuntimeServices
147173 | GitManager
148174 | GitCore
175+ | TextGeneration
149176 | TerminalManager
150177 | Keybindings
151178 | Open;
@@ -184,6 +211,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 32 unmodified lines
184211 const terminalManager = yield* TerminalManager;
185212 const keybindingsManager = yield* Keybindings;
186213 const git = yield* GitCore;
214+ const textGeneration = yield* TextGeneration;
187215 const fileSystem = yield* FileSystem.FileSystem;
188216 const path = yield* Path.Path;
189217
@@ -572,6 +600,37 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 382 unmodified lines
572600 return yield* git.createWorktree(body);
573601 }
574602
603+ case WS_METHODS.gitGenerateBranchName: {
604+ const body = stripRequestTag(request.body);
605+ return yield* textGeneration.generateBranchName(body);
606+ }
607+
608+ case WS_METHODS.gitGenerateAndRenameBranch: {
609+ const body = stripRequestTag(request.body);
610+ const generated = yield* textGeneration.generateBranchName({
611+ cwd: body.cwd,
612+ message: body.message,
613+ ...(body.attachments !== undefined ? { attachments: body.attachments } : {}),
614+ });
615+ if (!generated.branch) {
616+ return { branch: null };
617+ }
618+ const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
619+ if (targetBranch === body.oldBranch) {
620+ return { branch: body.oldBranch };
621+ }
622+ return yield* git.renameBranch({
623+ cwd: body.cwd,
624+ oldBranch: body.oldBranch,
625+ newBranch: targetBranch,
626+ });
627+ }
628+
629+ case WS_METHODS.gitRenameBranch: {
630+ const body = stripRequestTag(request.body);
631+ return yield* git.renameBranch(body);
632+ }
633+
575634 case WS_METHODS.gitRemoveWorktree: {
576635 const body = stripRequestTag(request.body);
577636 return yield* git.removeWorktree(body);
apps/web/src/components/ChatView.tsx+108−20
@@ -129,6 +129,7 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; · 128 unmodified lines
129129 const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
130130 const SCRIPT_TERMINAL_COLS = 120;
131131 const SCRIPT_TERMINAL_ROWS = 30;
132+const WORKTREE_BRANCH_PREFIX = "t3code";
132133
133134 function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {
134135 const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
@@ -232,6 +233,57 @@ function readFileAsDataUrl(file: File): Promise<string> { · 97 unmodified lines
232233 });
233234 }
234235
236+function hashBranchSeed(value: string): string {
237+ let hash = 2166136261;
238+ for (let index = 0; index < value.length; index++) {
239+ hash ^= value.charCodeAt(index);
240+ hash = Math.imul(hash, 16777619);
241+ }
242+ return (hash >>> 0).toString(16).padStart(8, "0");
243+}
244+
245+function buildTemporaryWorktreeBranchName(message: string): string {
246+ const seed = `${Date.now()}:${message.trim().toLowerCase()}:${crypto.randomUUID()}`;
247+ return `${WORKTREE_BRANCH_PREFIX}/${hashBranchSeed(seed)}`;
248+}
249+
250+function imageReferenceForBranchNaming(image: ComposerImageAttachment): string {
251+ const maybePath = (image.file as File & { path?: unknown }).path;
252+ if (typeof maybePath === "string" && maybePath.trim().length > 0) {
253+ return maybePath.trim();
254+ }
255+ return image.name;
256+}
257+
258+function buildBranchNameGenerationMessage(
259+ text: string,
260+ images: ReadonlyArray<ComposerImageAttachment>,
261+): string {
262+ const trimmedText = text.trim();
263+ const imageReferences = Array.from(
264+ new Set(
265+ images
266+ .map((image) => imageReferenceForBranchNaming(image).trim())
267+ .filter((reference) => reference.length > 0),
268+ ),
269+ );
270+ const attachmentsSection =
271+ imageReferences.length > 0
272+ ? `Attached images:\n${imageReferences.map((reference) => `- ${reference}`).join("\n")}`
273+ : "";
274+
275+ if (trimmedText.length > 0 && attachmentsSection.length > 0) {
276+ return `${trimmedText}\n\n${attachmentsSection}`;
277+ }
278+ if (trimmedText.length > 0) {
279+ return trimmedText;
280+ }
281+ if (attachmentsSection.length > 0) {
282+ return attachmentsSection;
283+ }
284+ return "New worktree change";
285+}
286+
235287 const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
236288 pathValue: string;
237289 kind: "file" | "directory";
@@ -1478,6 +1530,26 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1240 unmodified lines
14781530 const composerImagesSnapshot = [...composerImages];
14791531 const messageIdForSend = newMessageId();
14801532 const messageCreatedAt = new Date().toISOString();
1533+ const branchNameMessage = buildBranchNameGenerationMessage(trimmed, composerImagesSnapshot);
1534+ const turnAttachmentsPromise = Promise.all(
1535+ composerImagesSnapshot.map(
1536+ async (
1537+ image,
1538+ ): Promise<{
1539+ type: "image";
1540+ name: string;
1541+ mimeType: string;
1542+ sizeBytes: number;
1543+ dataUrl: string;
1544+ }> => ({
1545+ type: "image",
1546+ name: image.name,
1547+ mimeType: image.mimeType,
1548+ sizeBytes: image.sizeBytes,
1549+ dataUrl: await readFileAsDataUrl(image.file),
1550+ }),
1551+ ),
1552+ );
14811553 const optimisticAttachments = composerImagesSnapshot.map((image) => ({
14821554 type: "image" as const,
14831555 id: image.id,
@@ -1506,11 +1578,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 22 unmodified lines
15061578 setComposerHighlightedItemId(null);
15071579
15081580 let attemptedTurnStart = false;
1581+ let pendingWorktreeBranchRename: Promise<void> | null = null;
15091582 try {
15101583 // On first message: lock in branch + create worktree if needed.
15111584 if (baseBranchForWorktree) {
15121585 setSendPhase("preparing-worktree");
1513− const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
1586+ const newBranch = buildTemporaryWorktreeBranchName(trimmed);
15141587 const result = await createWorktreeMutation.mutateAsync({
15151588 cwd: activeProject.cwd,
15161589 branch: baseBranchForWorktree,
@@ -1531,6 +1604,36 @@ export default function ChatView({ threadId }: ChatViewProps) { · 14 unmodified lines
15311604 branch: result.worktree.branch,
15321605 worktreePath: result.worktree.path,
15331606 });
1607+ pendingWorktreeBranchRename = (async () => {
1608+ const turnAttachments = await turnAttachmentsPromise;
1609+ const renamed = await api.git.generateAndRenameBranch({
1610+ cwd: result.worktree.path,
1611+ oldBranch: result.worktree.branch,
1612+ message: branchNameMessage,
1613+ attachments: turnAttachments,
1614+ });
1615+ if (!renamed.branch || renamed.branch === result.worktree.branch) {
1616+ return;
1617+ }
1618+ await api.orchestration.dispatchCommand({
1619+ type: "thread.meta.update",
1620+ commandId: newCommandId(),
1621+ threadId: threadIdForSend,
1622+ branch: renamed.branch,
1623+ worktreePath: result.worktree.path,
1624+ });
1625+ dispatch({
1626+ type: "SET_THREAD_BRANCH",
1627+ threadId: threadIdForSend,
1628+ branch: renamed.branch,
1629+ worktreePath: result.worktree.path,
1630+ });
1631+ })().catch((error) => {
1632+ console.warn("Failed to rename generated worktree branch", {
1633+ threadId: threadIdForSend,
1634+ error: error instanceof Error ? error.message : String(error),
1635+ });
1636+ });
15341637 const setupScript = setupProjectScript(activeProject.scripts);
15351638 if (setupScript) {
15361639 await runProjectScript(setupScript, {
@@ -1558,25 +1661,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 21 unmodified lines
15581661 }
15591662
15601663 setSendPhase("sending-turn");
1561− const turnAttachments = await Promise.all(
1562− composerImagesSnapshot.map(
1563− async (
1564− image,
1565− ): Promise<{
1566− type: "image";
1567− name: string;
1568− mimeType: string;
1569− sizeBytes: number;
1570− dataUrl: string;
1571− }> => ({
1572− type: "image",
1573− name: image.name,
1574− mimeType: image.mimeType,
1575− sizeBytes: image.sizeBytes,
1576− dataUrl: await readFileAsDataUrl(image.file),
1577− }),
1578− ),
1579− );
1664+ const turnAttachments = await turnAttachmentsPromise;
15801665 attemptedTurnStart = true;
15811666 const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
15821667 const sandboxMode =
@@ -1598,6 +1683,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 15 unmodified lines
15981683 sandboxMode,
15991684 createdAt: messageCreatedAt,
16001685 });
1686+ if (pendingWorktreeBranchRename) {
1687+ void pendingWorktreeBranchRename;
1688+ }
16011689 } catch (err) {
16021690 if (
16031691 !attemptedTurnStart &&
apps/web/src/worktreeCleanup.test.ts+4−4
@@ -88,16 +88,16 @@ describe("getOrphanedWorktreePathForThread", () => { · 87 unmodified lines
8888 describe("formatWorktreePathForDisplay", () => {
8989 it("shows only the last path segment for unix-like paths", () => {
9090 const result = formatWorktreePathForDisplay(
91− "/Users/julius/.t3/worktrees/codething-mvp/codething-4e609bb8",
91+ "/Users/julius/.t3/worktrees/t3code-mvp/t3code-4e609bb8",
9292 );
93− expect(result).toBe("codething-4e609bb8");
93+ expect(result).toBe("t3code-4e609bb8");
9494 });
9595
9696 it("normalizes windows separators before selecting the final segment", () => {
9797 const result = formatWorktreePathForDisplay(
98− "C:\\Users\\julius\\.t3\\worktrees\\codething-mvp\\codething-4e609bb8",
98+ "C:\\Users\\julius\\.t3\\worktrees\\t3code-mvp\\t3code-4e609bb8",
9999 );
100− expect(result).toBe("codething-4e609bb8");
100+ expect(result).toBe("t3code-4e609bb8");
101101 });
102102
103103 it("uses the final segment even when outside ~/.t3/worktrees", () => {
apps/web/src/wsNativeApi.ts+4−0
@@ -165,6 +165,10 @@ export function createWsNativeApi(): NativeApi { · 164 unmodified lines
165165 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
166166 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
167167 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
168+ generateBranchName: (input) => transport.request(WS_METHODS.gitGenerateBranchName, input),
169+ generateAndRenameBranch: (input) =>
170+ transport.request(WS_METHODS.gitGenerateAndRenameBranch, input),
171+ renameBranch: (input) => transport.request(WS_METHODS.gitRenameBranch, input),
168172 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
169173 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),
170174 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),
packages/contracts/src/git.ts+42−0
@@ -1,5 +1,6 @@
11 import { Schema } from "effect";
22 import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
3+import { ChatAttachment, PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "./orchestration";
34
45 const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
56
@@ -67,6 +68,32 @@ export const GitCreateWorktreeInput = Schema.Struct({ · 61 unmodified lines
6768 });
6869 export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;
6970
71+export const GitGenerateBranchNameInput = Schema.Struct({
72+ cwd: TrimmedNonEmptyStringSchema,
73+ message: TrimmedNonEmptyStringSchema,
74+ attachments: Schema.optional(
75+ Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
76+ ),
77+});
78+export type GitGenerateBranchNameInput = typeof GitGenerateBranchNameInput.Type;
79+
80+export const GitGenerateAndRenameBranchInput = Schema.Struct({
81+ cwd: TrimmedNonEmptyStringSchema,
82+ oldBranch: TrimmedNonEmptyStringSchema,
83+ message: TrimmedNonEmptyStringSchema,
84+ attachments: Schema.optional(
85+ Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
86+ ),
87+});
88+export type GitGenerateAndRenameBranchInput = typeof GitGenerateAndRenameBranchInput.Type;
89+
90+export const GitRenameBranchInput = Schema.Struct({
91+ cwd: TrimmedNonEmptyStringSchema,
92+ oldBranch: TrimmedNonEmptyStringSchema,
93+ newBranch: TrimmedNonEmptyStringSchema,
94+});
95+export type GitRenameBranchInput = typeof GitRenameBranchInput.Type;
96+
7097 export const GitRemoveWorktreeInput = Schema.Struct({
7198 cwd: TrimmedNonEmptyStringSchema,
7299 path: TrimmedNonEmptyStringSchema,
@@ -135,6 +162,21 @@ export const GitCreateWorktreeResult = Schema.Struct({ · 62 unmodified lines
135162 });
136163 export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;
137164
165+export const GitGenerateBranchNameResult = Schema.Struct({
166+ branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
167+});
168+export type GitGenerateBranchNameResult = typeof GitGenerateBranchNameResult.Type;
169+
170+export const GitGenerateAndRenameBranchResult = Schema.Struct({
171+ branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
172+});
173+export type GitGenerateAndRenameBranchResult = typeof GitGenerateAndRenameBranchResult.Type;
174+
175+export const GitRenameBranchResult = Schema.Struct({
176+ branch: TrimmedNonEmptyStringSchema,
177+});
178+export type GitRenameBranchResult = typeof GitRenameBranchResult.Type;
179+
138180 export const GitRunStackedActionResult = Schema.Struct({
139181 action: GitStackedAction,
140182 commit: Schema.Struct({
packages/contracts/src/ipc.ts+11−0
@@ -1,6 +1,12 @@
11 import type {
22 GitCheckoutInput,
33 GitCreateBranchInput,
4+ GitGenerateAndRenameBranchInput,
5+ GitGenerateAndRenameBranchResult,
6+ GitGenerateBranchNameInput,
7+ GitGenerateBranchNameResult,
8+ GitRenameBranchInput,
9+ GitRenameBranchResult,
410 GitCreateWorktreeInput,
511 GitCreateWorktreeResult,
612 GitInitInput,
@@ -74,6 +80,11 @@ export interface NativeApi { · 67 unmodified lines
7480 // Existing branch/worktree API
7581 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;
7682 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;
83+ generateBranchName: (input: GitGenerateBranchNameInput) => Promise<GitGenerateBranchNameResult>;
84+ generateAndRenameBranch: (
85+ input: GitGenerateAndRenameBranchInput,
86+ ) => Promise<GitGenerateAndRenameBranchResult>;
87+ renameBranch: (input: GitRenameBranchInput) => Promise<GitRenameBranchResult>;
7788 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;
7889 createBranch: (input: GitCreateBranchInput) => Promise<void>;
7990 checkout: (input: GitCheckoutInput) => Promise<void>;
packages/contracts/src/ws.ts+9−0
@@ -11,11 +11,14 @@ import { · 10 unmodified lines
1111 } from "./orchestration";
1212 import {
1313 GitCheckoutInput,
14+ GitGenerateAndRenameBranchInput,
1415 GitCreateBranchInput,
16+ GitGenerateBranchNameInput,
1517 GitCreateWorktreeInput,
1618 GitInitInput,
1719 GitListBranchesInput,
1820 GitPullInput,
21+ GitRenameBranchInput,
1922 GitRemoveWorktreeInput,
2023 GitRunStackedActionInput,
2124 GitStatusInput,
@@ -50,6 +53,9 @@ export const WS_METHODS = { · 28 unmodified lines
5053 gitRunStackedAction: "git.runStackedAction",
5154 gitListBranches: "git.listBranches",
5255 gitCreateWorktree: "git.createWorktree",
56+ gitGenerateBranchName: "git.generateBranchName",
57+ gitGenerateAndRenameBranch: "git.generateAndRenameBranch",
58+ gitRenameBranch: "git.renameBranch",
5359 gitRemoveWorktree: "git.removeWorktree",
5460 gitCreateBranch: "git.createBranch",
5561 gitCheckout: "git.checkout",
@@ -111,6 +117,9 @@ export const WebSocketRequestBody = Schema.Union([ · 55 unmodified lines
111117 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),
112118 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),
113119 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),
120+ tagRequestBody(WS_METHODS.gitGenerateBranchName, GitGenerateBranchNameInput),
121+ tagRequestBody(WS_METHODS.gitGenerateAndRenameBranch, GitGenerateAndRenameBranchInput),
122+ tagRequestBody(WS_METHODS.gitRenameBranch, GitRenameBranchInput),
114123 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),
115124 tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput),
116125 tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput),