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
76 changes: 59 additions & 17 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import {
import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
import {
BranchToolbarWorktreeNameInput,
type WorktreeBranchNameStatus,
} from "./BranchToolbarWorktreeNameInput";
import { Button } from "./ui/button";
import {
Menu,
Expand All @@ -52,6 +56,7 @@ interface BranchToolbarProps {
onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
startFromOrigin: boolean;
onStartFromOriginChange: (startFromOrigin: boolean) => void;
onWorktreeBranchNameStatusChange?: (status: WorktreeBranchNameStatus | null) => void;
envLocked: boolean;
onCheckoutPullRequestRequest?: (reference: string) => void;
onComposerFocusRequest?: () => void;
Expand Down Expand Up @@ -126,7 +131,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({

if (isLocked) {
return (
<span className="inline-flex h-7 min-w-0 max-w-[48%] flex-1 items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 md:hidden">
<span className="inline-flex h-7 min-w-0 max-w-[48%] shrink items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 md:hidden">
{triggerContent}
</span>
);
Expand All @@ -136,7 +141,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
<Menu>
<MenuTrigger
render={<Button variant="ghost" size="xs" />}
className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden"
className="min-w-0 max-w-[48%] shrink justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden"
>
{triggerContent}
<ChevronDownIcon className="size-3 shrink-0 opacity-50" />
Expand Down Expand Up @@ -384,6 +389,7 @@ export const BranchToolbar = memo(function BranchToolbar({
onActiveThreadBranchOverrideChange,
startFromOrigin,
onStartFromOriginChange,
onWorktreeBranchNameStatusChange,
envLocked,
onCheckoutPullRequestRequest,
onComposerFocusRequest,
Expand Down Expand Up @@ -463,29 +469,64 @@ export const BranchToolbar = memo(function BranchToolbar({
const [stripElement, setStripElement] = useState<HTMLDivElement | null>(null);
const labelsOverflow = useLabelsOverflow(stripElement);

// Naming the next worktree's branch only makes sense before the first send,
// when a brand-new worktree (not a reused one) is about to be created.
const showWorktreeNameInput =
showGitControls &&
serverThread === null &&
draftThread !== null &&
effectiveEnvMode === "worktree" &&
activeWorktreePath === null &&
!envLocked;
const onWorktreeBranchNameChange = useCallback(
(value: string) => {
setDraftThreadContext(draftId ?? threadRef, {
worktreeBranchName: value.length > 0 ? value : null,
});
},
[draftId, setDraftThreadContext, threadRef],
);

if (!hasActiveThread || !activeProject) return null;

// Rendered in both layouts: a stored name the user can't see is a name they
// can't clear, and the send path ignores it while the input is unmounted.
const worktreeNameInput = showWorktreeNameInput ? (
<BranchToolbarWorktreeNameInput
environmentId={environmentId}
cwd={activeProject.workspaceRoot}
value={draftThread?.worktreeBranchName ?? ""}
onValueChange={onWorktreeBranchNameChange}
{...(onWorktreeBranchNameStatusChange
? { onStatusChange: onWorktreeBranchNameStatusChange }
: {})}
/>
) : null;

return (
<div
ref={setStripElement}
data-compact={labelsOverflow ? "" : undefined}
className="chat-composer-context-strip group/composer-context -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 overflow-x-clip overflow-y-visible ps-1 pe-2 pt-5 pb-1"
>
{isMobile && showGitControls ? (
<MobileRunContextSelector
envLocked={envLocked}
envModeLocked={envModeLocked}
environmentId={environmentId}
availableEnvironments={availableEnvironments}
showEnvironmentPicker={showEnvironmentPicker}
showEnvironmentIndicator={showEnvironmentIndicator}
onEnvironmentChange={onEnvironmentChange}
effectiveEnvMode={effectiveEnvMode}
activeWorktreePath={activeWorktreePath}
onEnvModeChange={onEnvModeChange}
previousWorktreeLabel={previousWorktreeLabel}
onUsePreviousWorktree={onUsePreviousWorktree}
/>
<>
<MobileRunContextSelector
envLocked={envLocked}
envModeLocked={envModeLocked}
environmentId={environmentId}
availableEnvironments={availableEnvironments}
showEnvironmentPicker={showEnvironmentPicker}
showEnvironmentIndicator={showEnvironmentIndicator}
onEnvironmentChange={onEnvironmentChange}
effectiveEnvMode={effectiveEnvMode}
activeWorktreePath={activeWorktreePath}
onEnvModeChange={onEnvModeChange}
previousWorktreeLabel={previousWorktreeLabel}
onUsePreviousWorktree={onUsePreviousWorktree}
/>
{worktreeNameInput}
</>
) : (
<div className="flex min-w-0 flex-1 items-center gap-1">
{showEnvironmentIndicator && availableEnvironments && (
Expand Down Expand Up @@ -515,12 +556,13 @@ export const BranchToolbar = memo(function BranchToolbar({
onUsePreviousWorktree={onUsePreviousWorktree}
/>
) : null}
{worktreeNameInput}
</div>
)}

{showGitControls ? (
<BranchToolbarBranchSelector
className="min-w-0 flex-1 justify-end md:ml-auto md:flex-none"
className="min-w-0 flex-auto justify-end md:ml-auto md:flex-none"
environmentId={environmentId}
threadId={threadId}
{...(draftId ? { draftId } : {})}
Expand Down
103 changes: 103 additions & 0 deletions apps/web/src/components/BranchToolbarWorktreeNameInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { type EnvironmentId, GIT_LIST_BRANCHES_MAX_LIMIT } from "@t3tools/contracts";
import { normalizeWorktreeBranchName, sanitizeWorktreeBranchNameInput } from "@t3tools/shared/git";
import { useDeferredValue, useEffect, useRef } from "react";

import { cn } from "../lib/utils";
import { useEnvironmentQuery } from "../state/query";
import { vcsEnvironment } from "../state/vcs";

/**
* What the send path knows about the typed name. `null` means no name is in
* play (empty input, or the input isn't mounted to validate one), so the send
* path must fall back to the generated branch name.
*/
export interface WorktreeBranchNameStatus {
/** The normalized name — the branch that would actually be created. */
name: string;
state: "checking" | "available" | "conflict";
}

interface BranchToolbarWorktreeNameInputProps {
environmentId: EnvironmentId;
/** Project root the refs are checked against (the worktree doesn't exist yet). */
cwd: string;
value: string;
onValueChange: (value: string) => void;
onStatusChange?: (status: WorktreeBranchNameStatus | null) => void;
}

/**
* Low-profile input naming the branch the next worktree is created with.
* Left empty, the branch name is generated from the first message instead.
* Marks itself invalid when a local branch with that name already exists.
*/
export function BranchToolbarWorktreeNameInput({
environmentId,
cwd,
value,
onValueChange,
onStatusChange,
}: BranchToolbarWorktreeNameInputProps) {
const normalizedValue = normalizeWorktreeBranchName(value);
const deferredNormalizedValue = useDeferredValue(normalizedValue);
const conflictRefsQuery = useEnvironmentQuery(
deferredNormalizedValue === null
? null
: vcsEnvironment.listRefs({
environmentId,
input: {
cwd,
query: deferredNormalizedValue,
// The server matches the query as a substring and pages the
// result, so an exact match can fall off a short page. Locals
// only (remotes can't collide) at the max page size.
refKind: "local",
limit: GIT_LIST_BRANCHES_MAX_LIMIT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High components/BranchToolbarWorktreeNameInput.tsx:55

When more than 200 local refs contain the typed text, an exact duplicate after the first page is omitted and the input is marked available; sending then fails when Git rejects creation of the existing branch. listRefs paginates the substring-filtered results, so use nextCursor to inspect all pages or perform a server-side exact-match check.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/BranchToolbarWorktreeNameInput.tsx around line 55:

When more than 200 local refs contain the typed text, an exact duplicate after the first page is omitted and the input is marked `available`; sending then fails when Git rejects creation of the existing branch. `listRefs` paginates the substring-filtered results, so use `nextCursor` to inspect all pages or perform a server-side exact-match check.

},
}),
);
// Only a settled lookup for the value currently typed can clear a name for
// send; while the deferred value lags or the query is in flight the answer
// belongs to a different name. A failed lookup counts as settled — the
// server rejects a duplicate branch anyway.
const checked =
deferredNormalizedValue === normalizedValue &&
(conflictRefsQuery.data !== null || conflictRefsQuery.error !== null);
const conflict =
checked &&
(conflictRefsQuery.data?.refs.some(
(refName) => !refName.isRemote && refName.name === normalizedValue,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High components/BranchToolbarWorktreeNameInput.tsx:69

Conflict detection marks names as available when they have a /-boundary prefix relationship with an existing local branch, but Git rejects both feature vs feature/foo combinations. Worktree creation therefore fails on send; treat either name being the other name plus / as a conflict.

Suggested change
(refName) => !refName.isRemote && refName.name === normalizedValue,
(refName) =>
!refName.isRemote &&
(refName.name === normalizedValue ||
refName.name.startsWith(`${normalizedValue}/`) ||
normalizedValue.startsWith(`${refName.name}/`)),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/BranchToolbarWorktreeNameInput.tsx around line 69:

Conflict detection marks names as `available` when they have a `/`-boundary prefix relationship with an existing local branch, but Git rejects both `feature` vs `feature/foo` combinations. Worktree creation therefore fails on send; treat either name being the other name plus `/` as a conflict.

) ??
false);
const state = checked ? (conflict ? "conflict" : "available") : "checking";
Comment thread
cursor[bot] marked this conversation as resolved.

const onStatusChangeRef = useRef(onStatusChange);
onStatusChangeRef.current = onStatusChange;
useEffect(() => {
onStatusChangeRef.current?.(normalizedValue === null ? null : { name: normalizedValue, state });
}, [normalizedValue, state]);
// The send gate must not outlive the input (e.g. switching back to
// "Current checkout"), or it would block sends it no longer applies to.
useEffect(() => () => onStatusChangeRef.current?.(null), []);

return (
<input
type="text"
value={value}
onChange={(event) => onValueChange(sanitizeWorktreeBranchNameInput(event.target.value))}
placeholder="custom branch name"
spellCheck={false}
autoComplete="off"
aria-label="Branch name for the new worktree"
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
data-composer-context-control
aria-invalid={conflict || undefined}
title={conflict ? `Branch "${normalizedValue}" already exists.` : undefined}
className={cn(
"h-7 w-44 min-w-0 shrink rounded-md bg-transparent px-2 font-mono text-xs outline-none transition-colors sm:h-6",
"placeholder:font-sans placeholder:text-muted-foreground/50",
"hover:bg-muted/40 focus:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring",
conflict ? "text-destructive" : "text-muted-foreground/70 focus:text-foreground/80",
)}
/>
);
}
41 changes: 39 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ import {
import { useTheme } from "../hooks/useTheme";
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
import { isCommandPaletteOpen } from "../commandPaletteBus";
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
import { buildTemporaryWorktreeBranchName, normalizeWorktreeBranchName } from "@t3tools/shared/git";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
import {
Expand Down Expand Up @@ -156,6 +156,7 @@ import {
} from "@t3tools/client-runtime/state/subagentRuntime";
import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
import { BranchToolbar } from "./BranchToolbar";
import type { WorktreeBranchNameStatus } from "./BranchToolbarWorktreeNameInput";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
import {
Expand Down Expand Up @@ -1375,6 +1376,8 @@ function ChatViewContent(props: ChatViewProps) {
pendingServerThreadStartFromOriginByThreadId,
setPendingServerThreadStartFromOriginByThreadId,
] = useState<Record<string, boolean>>({});
const [worktreeBranchNameStatus, setWorktreeBranchNameStatus] =
useState<WorktreeBranchNameStatus | null>(null);
const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
{},
Expand Down Expand Up @@ -4080,6 +4083,15 @@ function ChatViewContent(props: ChatViewProps) {
? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ??
primaryServerSettings.newWorktreesStartFromOrigin)
: false;
const draftWorktreeBranchName = isLocalDraftThread
? normalizeWorktreeBranchName(draftThread?.worktreeBranchName ?? "")
: null;
// Only a name the toolbar input is currently showing (and has checked for
// conflicts) can name the worktree branch; otherwise it stays generated.
const customWorktreeBranchName =
draftWorktreeBranchName !== null && worktreeBranchNameStatus?.name === draftWorktreeBranchName
? draftWorktreeBranchName
: null;
const sendEnvMode = resolveSendEnvMode({
requestedEnvMode: envMode,
isGitRepo,
Expand Down Expand Up @@ -5037,6 +5049,27 @@ function ChatViewContent(props: ChatViewProps) {
setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
return;
}
// A typed name is never silently dropped: sending waits for its conflict
// lookup to settle instead of trusting the last reported answer.
if (shouldCreateWorktree && draftWorktreeBranchName !== null && worktreeBranchNameStatus) {
if (
worktreeBranchNameStatus.name !== draftWorktreeBranchName ||
worktreeBranchNameStatus.state === "checking"
) {
setThreadError(
threadIdForSend,
`Still checking whether branch "${draftWorktreeBranchName}" is available. Try again in a moment.`,
);
return;
}
if (worktreeBranchNameStatus.state === "conflict") {
setThreadError(
threadIdForSend,
`Branch "${draftWorktreeBranchName}" already exists. Pick a different worktree branch name.`,
);
return;
}
}

sendInFlightRef.current = true;
if (isDraftHeroState && activeThreadKey) {
Expand Down Expand Up @@ -5231,7 +5264,10 @@ function ChatViewContent(props: ChatViewProps) {
prepareWorktree: {
projectCwd: activeProject.workspaceRoot,
baseBranch: baseBranchForWorktree,
branch: buildTemporaryWorktreeBranchName(randomHex),
// A custom name skips the server's LLM branch naming:
// only temporary-pattern branches get renamed.
branch:
customWorktreeBranchName ?? buildTemporaryWorktreeBranchName(randomHex),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High components/ChatView.tsx:5248

customWorktreeBranchName can pass Git-invalid names such as feature.lock or foo.lock/bar to prepareWorktree.branch, so the conflict check passes and the first send fails during worktree creation. Validate the final branch name against Git's full ref-format rules, including slash-separated components ending in .lock, before dispatching it.

Also found in 1 other location(s)

packages/shared/src/git.ts:133

normalizeWorktreeBranchName can return names Git rejects because it never removes or rejects a slash-separated component ending in .lock (for example, feature.lock or team.lock/topic). Git's ref-format rules explicitly forbid such components. The conflict query will not flag a nonexistent invalid ref, so send proceeds with this custom name and worktree/branch creation fails instead of creating the new thread.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatView.tsx around line 5248:

`customWorktreeBranchName` can pass Git-invalid names such as `feature.lock` or `foo.lock/bar` to `prepareWorktree.branch`, so the conflict check passes and the first send fails during worktree creation. Validate the final branch name against Git's full ref-format rules, including slash-separated components ending in `.lock`, before dispatching it.

Also found in 1 other location(s):
- packages/shared/src/git.ts:133 -- `normalizeWorktreeBranchName` can return names Git rejects because it never removes or rejects a slash-separated component ending in `.lock` (for example, `feature.lock` or `team.lock/topic`). Git's ref-format rules explicitly forbid such components. The conflict query will not flag a nonexistent invalid ref, so send proceeds with this custom name and worktree/branch creation fails instead of creating the new thread.

...(startFromOrigin ? { startFromOrigin: true } : {}),
},
runSetupScript: true,
Expand Down Expand Up @@ -6422,6 +6458,7 @@ function ChatViewContent(props: ChatViewProps) {
onEnvModeChange={onEnvModeChange}
startFromOrigin={startFromOrigin}
onStartFromOriginChange={onStartFromOriginChange}
onWorktreeBranchNameStatusChange={setWorktreeBranchNameStatus}
{...(canOverrideServerThreadEnvMode
? { effectiveEnvModeOverride: envMode }
: {})}
Expand Down
Loading
Loading