Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 139 additions & 2 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ describe("ProviderCommandReactor", () => {
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
Layer.provideMerge(NodeServices.layer),
);
const runtime = ManagedRuntime.make(layer);
runtime = ManagedRuntime.make(layer);

const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
Expand Down Expand Up @@ -461,6 +461,143 @@ describe("ProviderCommandReactor", () => {
expect(thread?.title).toBe("Reconnect spinner resume bug");
});

it("preserves mixed-case worktree branch prefixes when renaming generated branches", async () => {
const harness = await createHarness();
const now = new Date().toISOString();
harness.generateBranchName.mockImplementationOnce(() =>
Effect.succeed({
branch: "T3code/Feature",
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.makeUnsafe("cmd-thread-meta-update-branch"),
threadId: ThreadId.makeUnsafe("thread-1"),
branch: "T3code/worktree-abcd1234",
worktreePath: "/tmp/provider-project",
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.makeUnsafe("cmd-turn-start-rename-prefix"),
threadId: ThreadId.makeUnsafe("thread-1"),
message: {
messageId: asMessageId("user-message-rename-prefix"),
role: "user",
text: "rename the branch",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.renameBranch.mock.calls.length === 1);
expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({
cwd: "/tmp/provider-project",
oldBranch: "T3code/worktree-abcd1234",
newBranch: "T3code/feature",
});

await waitFor(async () => {
const readModel = await Effect.runPromise(harness.engine.getReadModel());
const thread = readModel.threads.find(
(entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
);
return thread?.branch === "T3code/feature";
});
});

it("renames temporary worktree branches that start with a digit prefix", async () => {
const harness = await createHarness();
const now = new Date().toISOString();
harness.generateBranchName.mockImplementationOnce(() =>
Effect.succeed({
branch: "3code/Feature",
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.makeUnsafe("cmd-thread-meta-update-digit-branch"),
threadId: ThreadId.makeUnsafe("thread-1"),
branch: "3code/worktree-abcd1234",
worktreePath: "/tmp/provider-project",
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.makeUnsafe("cmd-turn-start-digit-prefix"),
threadId: ThreadId.makeUnsafe("thread-1"),
message: {
messageId: asMessageId("user-message-digit-prefix"),
role: "user",
text: "rename the branch",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.renameBranch.mock.calls.length === 1);
expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({
cwd: "/tmp/provider-project",
oldBranch: "3code/worktree-abcd1234",
newBranch: "3code/feature",
});
});

it("does not rename ordinary user branches that happen to end with eight hex characters", async () => {
const harness = await createHarness();
const now = new Date().toISOString();
harness.generateBranchName.mockImplementationOnce(() =>
Effect.succeed({
branch: "feature/session-cleanup",
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.makeUnsafe("cmd-thread-meta-update-user-branch"),
threadId: ThreadId.makeUnsafe("thread-1"),
branch: "feature/deadbeef",
worktreePath: "/tmp/provider-project",
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.makeUnsafe("cmd-turn-start-user-branch"),
threadId: ThreadId.makeUnsafe("thread-1"),
message: {
messageId: asMessageId("user-message-user-branch"),
role: "user",
text: "keep my branch name",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.sendTurn.mock.calls.length === 1);
expect(harness.generateBranchName).not.toHaveBeenCalled();
expect(harness.renameBranch).not.toHaveBeenCalled();
});

it("generates a worktree branch name for the first turn", async () => {
const harness = await createHarness();
const now = new Date().toISOString();
Expand All @@ -470,7 +607,7 @@ describe("ProviderCommandReactor", () => {
type: "thread.meta.update",
commandId: CommandId.makeUnsafe("cmd-thread-branch"),
threadId: ThreadId.makeUnsafe("thread-1"),
branch: "t3code/1234abcd",
branch: "t3code/worktree-1234abcd",
worktreePath: "/tmp/provider-project-worktree",
}),
);
Expand Down
28 changes: 21 additions & 7 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
type RuntimeMode,
type TurnId,
} from "@t3tools/contracts";
import {
DEFAULT_WORKTREE_BRANCH_PREFIX,
WORKTREE_BRANCH_PREFIX_PATTERN,
} from "@t3tools/contracts/settings";
import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect";
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";

Expand Down Expand Up @@ -71,8 +75,11 @@ const serverCommandId = (tag: string): CommandId =>
const HANDLED_TURN_START_KEY_MAX = 10_000;
const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
const WORKTREE_BRANCH_PREFIX = "t3code";
const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
const TEMP_WORKTREE_BRANCH_MARKER = "worktree";
const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(
`^${WORKTREE_BRANCH_PREFIX_PATTERN.source.slice(1, -1)}\\/${TEMP_WORKTREE_BRANCH_MARKER}-[0-9a-f]{8}$`,
"i",
);
const DEFAULT_THREAD_TITLE = "New thread";

function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean {
Expand Down Expand Up @@ -122,15 +129,21 @@ function isTemporaryWorktreeBranch(branch: string): boolean {
return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
}

function buildGeneratedWorktreeBranchName(raw: string): string {
function extractWorktreeBranchPrefix(branch: string): string {
const slashIdx = branch.indexOf("/");
return slashIdx > 0 ? branch.slice(0, slashIdx) : DEFAULT_WORKTREE_BRANCH_PREFIX;
}

function buildGeneratedWorktreeBranchName(raw: string, prefix: string): string {
const normalizedPrefix = prefix.trim().toLowerCase().replace(/['"`]/g, "");
const normalized = raw
.trim()
.toLowerCase()
.replace(/^refs\/heads\//, "")
.replace(/['"`]/g, "");

const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`)
? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length)
const withoutPrefix = normalized.startsWith(`${normalizedPrefix}/`)
? normalized.slice(`${normalizedPrefix}/`.length)
: normalized;

const branchFragment = withoutPrefix
Expand All @@ -142,7 +155,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string {
.replace(/[./_-]+$/g, "");

const safeFragment = branchFragment.length > 0 ? branchFragment : "update";
return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
return `${prefix}/${safeFragment}`;
}

const make = Effect.gen(function* () {
Expand Down Expand Up @@ -424,6 +437,7 @@ const make = Effect.gen(function* () {
}

const oldBranch = input.branch;
const branchPrefix = extractWorktreeBranchPrefix(oldBranch);
const cwd = input.worktreePath;
const attachments = input.attachments ?? [];
yield* Effect.gen(function* () {
Expand All @@ -438,7 +452,7 @@ const make = Effect.gen(function* () {
});
if (!generated) return;

const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
const targetBranch = buildGeneratedWorktreeBranchName(generated.branch, branchPrefix);
if (targetBranch === oldBranch) return;

const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch });
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts";
import { WorktreeBranchPrefix } from "@t3tools/contracts/settings";
import { type ChatMessage, type Thread } from "../types";
import { randomUUID } from "~/lib/utils";
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
Expand All @@ -10,7 +11,6 @@ import {
} from "../lib/terminalContext";

export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
const WORKTREE_BRANCH_PREFIX = "t3code";

export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);

Expand Down Expand Up @@ -99,10 +99,11 @@ export function readFileAsDataUrl(file: File): Promise<string> {
});
}

export function buildTemporaryWorktreeBranchName(): string {
// Keep the 8-hex suffix shape for backend temporary-branch detection.
export function buildTemporaryWorktreeBranchName(prefix: string): string {
// Use an explicit temporary marker so backend rename logic does not match user branches.
const resolvedPrefix = Schema.decodeUnknownSync(WorktreeBranchPrefix)(prefix);
const token = randomUUID().slice(0, 8).toLowerCase();
return `${WORKTREE_BRANCH_PREFIX}/${token}`;
return `${resolvedPrefix}/worktree-${token}`;
}

export function cloneComposerImageForRetry(
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2604,7 +2604,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
// On first message: lock in branch + create worktree if needed.
if (baseBranchForWorktree) {
beginSendPhase("preparing-worktree");
const newBranch = buildTemporaryWorktreeBranchName();
const newBranch = buildTemporaryWorktreeBranchName(settings.worktreeBranchPrefix);
const result = await createWorktreeMutation.mutateAsync({
cwd: activeProject.cwd,
branch: baseBranchForWorktree,
Expand Down
80 changes: 78 additions & 2 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ import {
type ServerProviderModel,
ThreadId,
} from "@t3tools/contracts";
import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
import {
DEFAULT_UNIFIED_SETTINGS,
DEFAULT_WORKTREE_BRANCH_PREFIX,
WorktreeBranchPrefix,
WORKTREE_BRANCH_PREFIX_PATTERN,
} from "@t3tools/contracts/settings";
import { normalizeModelSlug } from "@t3tools/shared/model";
import { Equal } from "effect";
import { Equal, Schema } from "effect";
import { APP_VERSION } from "../../branding";
import {
canCheckForUpdate,
Expand Down Expand Up @@ -539,6 +544,10 @@ export function GeneralSettingsPanel() {
const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
Partial<Record<ProviderKind, string | null>>
>({});
const [worktreeBranchPrefixInput, setWorktreeBranchPrefixInput] = useState(
settings.worktreeBranchPrefix,
);
const [worktreeBranchPrefixError, setWorktreeBranchPrefixError] = useState<string | null>(null);
const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
const refreshingRef = useRef(false);
const queryClient = useQueryClient();
Expand All @@ -563,6 +572,33 @@ export function GeneralSettingsPanel() {
const availableEditors = serverConfigQuery.data?.availableEditors;
const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS;
const codexHomePath = settings.providers.codex.homePath;
const saveWorktreeBranchPrefix = useCallback(
(rawValue: string) => {
const trimmed = rawValue.trim();
if (trimmed.length === 0) {
setWorktreeBranchPrefixError(null);
setWorktreeBranchPrefixInput(DEFAULT_WORKTREE_BRANCH_PREFIX);
updateSettings({ worktreeBranchPrefix: DEFAULT_WORKTREE_BRANCH_PREFIX });
return;
}

if (!WORKTREE_BRANCH_PREFIX_PATTERN.test(trimmed)) {
setWorktreeBranchPrefixError("Use 1-64 letters, numbers, dots, hyphens, or underscores.");
return;
}

const normalized = Schema.decodeUnknownSync(WorktreeBranchPrefix)(trimmed);
setWorktreeBranchPrefixError(null);
setWorktreeBranchPrefixInput(normalized);
updateSettings({ worktreeBranchPrefix: normalized });
},
[updateSettings],
);

useEffect(() => {
setWorktreeBranchPrefixInput(settings.worktreeBranchPrefix);
setWorktreeBranchPrefixError(null);
}, [settings.worktreeBranchPrefix]);

const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
const textGenProvider = textGenerationModelSelection.provider;
Expand Down Expand Up @@ -905,6 +941,46 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
title="Worktree branch prefix"
description={`Prefix used for worktree branch names (e.g., ${settings.worktreeBranchPrefix}/feature-name).`}
status={worktreeBranchPrefixError}
resetAction={
settings.worktreeBranchPrefix !== DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix ? (
<SettingResetButton
label="worktree branch prefix"
onClick={() =>
updateSettings({
worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix,
})
}
/>
) : null
}
control={
<Input
id="worktree-branch-prefix"
className="w-full sm:w-44"
value={worktreeBranchPrefixInput}
aria-invalid={worktreeBranchPrefixError ? true : undefined}
pattern={WORKTREE_BRANCH_PREFIX_PATTERN.source.slice(1, -1)}
title="Use 1-64 letters, numbers, dots, hyphens, or underscores."
onBlur={(event) => saveWorktreeBranchPrefix(event.target.value)}
onChange={(event) => {
setWorktreeBranchPrefixInput(event.target.value);
if (
worktreeBranchPrefixError &&
WORKTREE_BRANCH_PREFIX_PATTERN.test(event.target.value.trim())
) {
setWorktreeBranchPrefixError(null);
}
}}
placeholder="t3code"
spellCheck={false}
/>
}
/>

<SettingsRow
title="Archive confirmation"
description="Require a second click on the inline archive action before a thread is archived."
Expand Down
Loading
Loading