-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Feat/custom worktree branch name #6428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
80dd94d
e14def9
4932a30
efd6128
5285085
0eadfa0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||||||||||||||
| }, | ||||||||||||||
| }), | ||||||||||||||
| ); | ||||||||||||||
| // 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, | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High Conflict detection marks names as
Suggested change
🤖 Copy this AI Prompt to have your agent fix this: |
||||||||||||||
| ) ?? | ||||||||||||||
| false); | ||||||||||||||
| const state = checked ? (conflict ? "conflict" : "available") : "checking"; | ||||||||||||||
|
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" | ||||||||||||||
|
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", | ||||||||||||||
| )} | ||||||||||||||
| /> | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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 { | ||
|
|
@@ -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, | ||
| {}, | ||
|
|
@@ -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, | ||
|
|
@@ -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) { | ||
|
|
@@ -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), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High
Also found in 1 other location(s)
🤖 Copy this AI Prompt to have your agent fix this: |
||
| ...(startFromOrigin ? { startFromOrigin: true } : {}), | ||
| }, | ||
| runSetupScript: true, | ||
|
|
@@ -6422,6 +6458,7 @@ function ChatViewContent(props: ChatViewProps) { | |
| onEnvModeChange={onEnvModeChange} | ||
| startFromOrigin={startFromOrigin} | ||
| onStartFromOriginChange={onStartFromOriginChange} | ||
| onWorktreeBranchNameStatusChange={setWorktreeBranchNameStatus} | ||
| {...(canOverrideServerThreadEnvMode | ||
| ? { effectiveEnvModeOverride: envMode } | ||
| : {})} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
components/BranchToolbarWorktreeNameInput.tsx:55When 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.listRefspaginates the substring-filtered results, so usenextCursorto inspect all pages or perform a server-side exact-match check.🤖 Copy this AI Prompt to have your agent fix this: