* feat(linear): start sessions from Linear issues Authorize a Linear workspace on this OpenChamber server, map teams to projects, attach an issue from chat, start a session or worktree from an issue, and post started/completed/failed comments that open the session. Hidden in VS Code. * feat(linear): connect more than one Linear workspace Store each OAuth grant on this OpenChamber server and keep one current, so Settings can add and switch workspaces without dropping the others. Project mapping is per workspace. Remove the Linear button next to New Chat; start-from-issue stays on New Worktree. * feat(linear): add a right-hand issues panel Browse and filter issues in the rail, open a card to change status or start a session, and collapse search plus most filters to icons on a narrow panel. * feat(linear): open issues in the rail and filter by Linear status The rail icon only shows after Linear is connected. Clicking a Linear row on work status opens the panel. Status options match the card, including Done, Canceled, and Duplicate. The Integrations experimental warning sits under Third-party integrations. * fix(linear): use stable OAuth callback broker * fix(chat): preview Linear issue attachments The context switch missed linear-issue, so tsc treated the preview helpers as incomplete. * fix(ui): restore Linear i18n parity and the #2903 sync harness Turkish was missing the Linear dictionaries, and the subagent test still wrapped only SyncContext after reads moved to SyncRuntimeContext. * fix(linear): drop changelog hunks and close review races Keep changelogs out of this PR, restore CodeMirror ranges, ignore stale Linear list pages, and leave a persisted Linear tab open until auth has actually resolved. * fix(linear): tint active issue filters and clear them in one click * fix(markdown): read escaped brackets as text, not display math `\[...\]` is display math in LaTeX and an escaped bracket pair in CommonMark. The block tokenizer claimed every `\[`, so prose like `[title \[Bug\] more](url)` was handed to KaTeX: "Bug" rendered as a centered formula and the block token split the paragraph, tearing the link into three pieces. Linear, GitHub and any other source that escapes brackets the way CommonMark requires hit this. Display math now has to own its line — `\[` starts one and `\]` ends one. A formula on its own line still renders; `\[` mid-sentence stays an escape, which is what CommonMark says it is and what prose almost always means. Inline `\(...\)` keeps the same ambiguity, but inline math is legitimately mid-sentence, so there is no position to judge it by. Covered by regression tests, including the verbatim comment body that surfaced this. * feat(linear): make session status comments opt-in and public-only A status comment lands in a Linear workspace the whole team reads, and the link it carried pointed at whatever origin started the session — usually loopback or a LAN address. Everyone but its author got a dead link, and nobody had agreed to the comments in the first place. Comments are now off until the user turns them on in Settings -> Integrations -> Linear, and the check lives on the server: the event hub posts completed and failure without going through the interface, so a client-side gate would not hold. When the resolved origin is not publicly reachable the server posts nothing at all rather than a link only its author can open; `isPublicSessionOrigin` rejects loopback, private LAN, carrier-grade NAT, link-local and single-label hosts. The desktop deep-link origin is gone with it, since no one else can follow one either. The comment body also dropped the session title. It repeated the issue the comment already sits on, and issue titles routinely carry brackets ("[Bug] ...") that broke the markdown link. The body is now one short link, and `sessionTitle` is gone from the route, client and types. Also caps the dedupe file at the newest 500 sessions; it grew forever. * fix(linear): match the pull request panel and clear review findings Comments in the Linear panel now render as the same avatar timeline the pull request panel uses, with the shared time-format preference instead of a raw locale string. Comment authors carry `avatarUrl`, which the GraphQL selection was not requesting. Review findings from the same pass: - `status-runtime.js` hand-rolled `typeof` narrowing and failed the vendored anti-slop lint; it now parses through `parse.js` like every other file in the module. - `useLinearAuthStore` turned any failed request into `connected: false` with `hasChecked: true`. Since the rail icon, the composer entry and the worktree option all gate on `connected === true`, one network blip hid Linear for the rest of the session, and Settings only re-checked when it had never checked. It now keeps the last known status and leaves `hasChecked` false so the next caller retries. - `LinearIssuesView` (1096 lines) was a static import in `ContextPanel`, shipping in the main bundle although its rail icon stays hidden until a workspace is connected. It is lazy now, like `GitView`. - Dropped dead code: the unused port helpers left over from the loopback callback, two re-exported default values nothing read, and a redundant export in `linkedIssues`. - Integrations is no longer badged beta.
2260 lines
96 KiB
TypeScript
2260 lines
96 KiB
TypeScript
import * as React from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { toast } from '@/components/ui';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
CommandSeparator,
|
|
} from '@/components/ui/command';
|
|
import { cn } from '@/lib/utils';
|
|
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
|
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useSelectionStore } from '@/sync/selection-store';
|
|
import * as sessionActions from '@/sync/session-actions';
|
|
import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
|
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
|
import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap';
|
|
import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/openchamberConfig';
|
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
|
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
|
import { postLinearSessionStarted } from '@/lib/linearSessionStatus';
|
|
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
|
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
|
|
import {
|
|
LAST_WORKTREE_SOURCE_BRANCH_KEY,
|
|
resolveWorktreeSourceBranchPreference,
|
|
resolveWorktreeSourceBranchToPersist,
|
|
} from '@/lib/worktrees/worktreeSourceBranchPreference';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore';
|
|
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
|
import { LinearIssuePickerDialog } from './LinearIssuePickerDialog';
|
|
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import type {
|
|
GitHubIssue,
|
|
GitHubIssueComment,
|
|
GitHubIssuesListResult,
|
|
GitHubPullRequestContextResult,
|
|
GitHubPullRequestSummary,
|
|
LinearIssue,
|
|
LinearIssueComment,
|
|
} from '@/lib/api/types';
|
|
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
type Mode = 'new-branch' | 'existing-branch';
|
|
|
|
interface ValidationState {
|
|
isValidating: boolean;
|
|
branchError: string | null;
|
|
worktreeError: string | null;
|
|
touched: boolean;
|
|
}
|
|
|
|
type LinkedLinearWorktreeIssue = {
|
|
identifier: string;
|
|
title: string;
|
|
url: string;
|
|
author?: { login: string; avatarUrl?: string };
|
|
};
|
|
|
|
// State for New Branch mode
|
|
interface NewBranchState {
|
|
branchName: string;
|
|
worktreeName: string;
|
|
isSyncingWorktreeName: boolean;
|
|
sourceBranch: string;
|
|
linkedIssue: GitHubIssue | null;
|
|
linkedPr: GitHubPullRequestSummary | null;
|
|
linkedLinearIssue: LinkedLinearWorktreeIssue | null;
|
|
includePrDiff: boolean;
|
|
}
|
|
|
|
// State for Existing Branch mode
|
|
interface ExistingBranchState {
|
|
selectedBranch: string;
|
|
worktreeName: string;
|
|
}
|
|
|
|
const normalizeBranchName = (value: string): string => {
|
|
return value
|
|
.trim()
|
|
.replace(/^refs\/heads\//, '')
|
|
.replace(/^heads\//, '')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/^\/+|\/+$/g, '');
|
|
};
|
|
|
|
const sanitizeRemoteName = (value: string): string => {
|
|
const normalized = String(value || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
return normalized || 'pr-head';
|
|
};
|
|
|
|
const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: string[], remoteBranches: string[]) => {
|
|
const headBranch = normalizeBranchName(pr.head || '');
|
|
if (!headBranch) {
|
|
throw new Error('PR head branch is missing');
|
|
}
|
|
|
|
if (localBranches.includes(headBranch)) {
|
|
return {
|
|
existingBranch: headBranch,
|
|
setUpstream: undefined,
|
|
upstreamRemote: undefined,
|
|
upstreamBranch: undefined,
|
|
ensureRemoteName: undefined,
|
|
ensureRemoteUrl: undefined,
|
|
sourceLabel: headBranch,
|
|
};
|
|
}
|
|
|
|
const availableRemoteBranch = remoteBranches.find((remoteBranch) => {
|
|
const slashIndex = remoteBranch.indexOf('/');
|
|
if (slashIndex <= 0 || slashIndex >= remoteBranch.length - 1) {
|
|
return false;
|
|
}
|
|
return remoteBranch.slice(slashIndex + 1) === headBranch;
|
|
});
|
|
|
|
if (availableRemoteBranch) {
|
|
const slashIndex = availableRemoteBranch.indexOf('/');
|
|
const remoteName = availableRemoteBranch.slice(0, slashIndex);
|
|
return {
|
|
existingBranch: `remotes/${availableRemoteBranch}`,
|
|
setUpstream: true as const,
|
|
upstreamRemote: remoteName,
|
|
upstreamBranch: headBranch,
|
|
ensureRemoteName: undefined,
|
|
ensureRemoteUrl: undefined,
|
|
sourceLabel: `${remoteName}/${headBranch}`,
|
|
};
|
|
}
|
|
|
|
const ownerFromLabel = String(pr.headLabel || '').split(':')[0]?.trim();
|
|
const remoteSeed = pr.headRepo?.owner || ownerFromLabel || 'pr-head';
|
|
const remoteName = `pr-${sanitizeRemoteName(remoteSeed)}`;
|
|
// Prefer HTTPS so anonymous public fetches do not require SSH agent setup.
|
|
const remoteUrl = pr.headRepo?.cloneUrl || pr.headRepo?.sshUrl || '';
|
|
|
|
if (!remoteUrl) {
|
|
throw new Error(
|
|
'PR head repository URL is unavailable. The fork may have been deleted; '
|
|
+ 'push the branch to a reachable repository and try again.'
|
|
);
|
|
}
|
|
|
|
return {
|
|
existingBranch: `remotes/${remoteName}/${headBranch}`,
|
|
setUpstream: true as const,
|
|
upstreamRemote: remoteName,
|
|
upstreamBranch: headBranch,
|
|
ensureRemoteName: remoteName,
|
|
ensureRemoteUrl: remoteUrl,
|
|
sourceLabel: `${remoteName}/${headBranch}`,
|
|
};
|
|
};
|
|
|
|
const slugifyWorktreeName = (value: string): string => {
|
|
return value
|
|
.trim()
|
|
.replace(/^refs\/heads\//, '')
|
|
.replace(/^heads\//, '')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/^\/+|\/+$/g, '')
|
|
.split('/').join('-')
|
|
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 80);
|
|
};
|
|
|
|
interface NewWorktreeDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onWorktreeCreated?: (worktreePath: string, options?: { sessionId?: string }) => void;
|
|
}
|
|
|
|
const buildIssueContextText = (args: {
|
|
repo: GitHubIssuesListResult['repo'] | undefined;
|
|
issue: GitHubIssue;
|
|
comments: GitHubIssueComment[];
|
|
}) => {
|
|
const payload = {
|
|
repo: args.repo ?? null,
|
|
issue: args.issue,
|
|
comments: args.comments,
|
|
};
|
|
return `GitHub issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
|
};
|
|
|
|
const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => {
|
|
return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
|
};
|
|
|
|
const buildLinearIssueContextText = (args: {
|
|
issue: LinearIssue;
|
|
comments: LinearIssueComment[];
|
|
}) => {
|
|
const payload = {
|
|
issue: args.issue,
|
|
comments: args.comments,
|
|
};
|
|
return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
|
};
|
|
|
|
export function NewWorktreeDialog({
|
|
open,
|
|
onOpenChange,
|
|
onWorktreeCreated,
|
|
}: NewWorktreeDialogProps) {
|
|
const { t } = useI18n();
|
|
const { github, git, linear } = useRuntimeAPIs();
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
|
const linearAuthStatus = useLinearAuthStore((state) => state.status);
|
|
const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
|
|
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
|
|
|
const projectDirectory = activeProject?.path ?? null;
|
|
const projectRef: ProjectRef | null = React.useMemo(() => {
|
|
if (projectDirectory && activeProject) {
|
|
return { id: activeProject.id, path: projectDirectory };
|
|
}
|
|
return null;
|
|
}, [activeProject, projectDirectory]);
|
|
|
|
// Mode state
|
|
const [mode, setMode] = React.useState<Mode>('new-branch');
|
|
|
|
// Separate state for each mode (persisted when switching tabs)
|
|
const [newBranchState, setNewBranchState] = React.useState<NewBranchState>({
|
|
branchName: '',
|
|
worktreeName: '',
|
|
isSyncingWorktreeName: true,
|
|
sourceBranch: '',
|
|
linkedIssue: null,
|
|
linkedPr: null,
|
|
linkedLinearIssue: null,
|
|
includePrDiff: false,
|
|
});
|
|
|
|
const [existingBranchState, setExistingBranchState] = React.useState<ExistingBranchState>({
|
|
selectedBranch: '',
|
|
worktreeName: '',
|
|
});
|
|
|
|
// Use cached branches from Git store (instant if already fetched)
|
|
const branches = useGitBranches(projectDirectory);
|
|
const isLoadingBranches = useGitLoadingBranches(projectDirectory);
|
|
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
|
|
|
// Compute local and remote branch lists (same pattern as GitView)
|
|
const localBranches = React.useMemo(() => {
|
|
if (!branches?.all) return [];
|
|
return branches.all
|
|
.filter((branchName: string) => !branchName.startsWith('remotes/'))
|
|
.sort();
|
|
}, [branches]);
|
|
|
|
const remoteBranches = React.useMemo(() => {
|
|
if (!branches?.all) return [];
|
|
return branches.all
|
|
.filter((branchName: string) => branchName.startsWith('remotes/'))
|
|
.map((branchName: string) => branchName.replace(/^remotes\//, ''))
|
|
.sort();
|
|
}, [branches]);
|
|
|
|
// Get existing worktrees for the current project to avoid conflicts
|
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
|
const existingWorktreeNames = React.useMemo(() => {
|
|
if (!projectDirectory) return new Set<string>();
|
|
const worktrees = availableWorktreesByProject.get(projectDirectory) ?? [];
|
|
return new Set(worktrees.map(wt => wt.name));
|
|
}, [availableWorktreesByProject, projectDirectory]);
|
|
|
|
// Generate a unique slug that doesn't conflict with existing worktrees
|
|
const generateUniqueSlug = React.useCallback((maxAttempts = 10): string => {
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
const slug = generateBranchSlug();
|
|
if (!existingWorktreeNames.has(slug)) {
|
|
return slug;
|
|
}
|
|
}
|
|
// Fallback: add timestamp if all attempts failed
|
|
return `${generateBranchSlug()}-${Date.now().toString(36).slice(-4)}`;
|
|
}, [existingWorktreeNames]);
|
|
|
|
const [githubDialogOpen, setGithubDialogOpen] = React.useState(false);
|
|
const [linearDialogOpen, setLinearDialogOpen] = React.useState(false);
|
|
|
|
// Desktop branch picker states
|
|
const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false);
|
|
const [sourceBranchDropdownOpen, setSourceBranchDropdownOpen] = React.useState(false);
|
|
|
|
// Mobile branch picker states
|
|
const [existingBranchPickerOpen, setExistingBranchPickerOpen] = React.useState(false);
|
|
const [sourceBranchPickerOpen, setSourceBranchPickerOpen] = React.useState(false);
|
|
|
|
// Shared query state per picker (desktop + mobile)
|
|
const [existingBranchQuery, setExistingBranchQuery] = React.useState('');
|
|
const [sourceBranchQuery, setSourceBranchQuery] = React.useState('');
|
|
const existingBranchDropdownContentRef = React.useRef<HTMLDivElement | null>(null);
|
|
const sourceBranchDropdownContentRef = React.useRef<HTMLDivElement | null>(null);
|
|
const existingBranchMobileListWrapperRef = React.useRef<HTMLDivElement | null>(null);
|
|
const sourceBranchMobileListWrapperRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
event.stopPropagation();
|
|
}, []);
|
|
|
|
const findScrollableContainer = React.useCallback((startNode: HTMLElement | null): HTMLElement | null => {
|
|
let node: HTMLElement | null = startNode;
|
|
while (node && node !== document.body) {
|
|
const { overflowY } = window.getComputedStyle(node);
|
|
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
|
|
return node;
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
return null;
|
|
}, []);
|
|
|
|
const resetScrollToTop = React.useCallback((container: HTMLElement | null) => {
|
|
if (!container) {
|
|
return;
|
|
}
|
|
container.scrollTop = 0;
|
|
}, []);
|
|
|
|
const resetDesktopPickerScroll = React.useCallback((contentRef: React.RefObject<HTMLDivElement | null>) => {
|
|
const list = contentRef.current?.querySelector<HTMLElement>('[data-slot="command-list"]') ?? null;
|
|
resetScrollToTop(list);
|
|
}, [resetScrollToTop]);
|
|
|
|
const resetMobilePickerScroll = React.useCallback((wrapperRef: React.RefObject<HTMLDivElement | null>) => {
|
|
const scrollContainer = findScrollableContainer(wrapperRef.current);
|
|
resetScrollToTop(scrollContainer);
|
|
}, [findScrollableContainer, resetScrollToTop]);
|
|
|
|
const existingBranchRankedGroups = React.useMemo(() => {
|
|
return rankBranchesForQuery({
|
|
localBranches,
|
|
remoteBranches,
|
|
query: existingBranchQuery,
|
|
});
|
|
}, [localBranches, remoteBranches, existingBranchQuery]);
|
|
|
|
const sourceBranchRankedGroups = React.useMemo(() => {
|
|
return rankBranchesForQuery({
|
|
localBranches,
|
|
remoteBranches,
|
|
query: sourceBranchQuery,
|
|
});
|
|
}, [localBranches, remoteBranches, sourceBranchQuery]);
|
|
|
|
const hasExistingBranchQuery = existingBranchQuery.trim().length > 0;
|
|
const hasSourceBranchQuery = sourceBranchQuery.trim().length > 0;
|
|
const hasExistingBranchMatches = existingBranchRankedGroups.matching.length > 0;
|
|
const hasSourceBranchMatches = sourceBranchRankedGroups.matching.length > 0;
|
|
const canFetchBranches = Boolean(projectDirectory && git);
|
|
|
|
const handleFetchBranches = React.useCallback(() => {
|
|
if (!projectDirectory || !git) {
|
|
return;
|
|
}
|
|
void fetchBranches(projectDirectory, git);
|
|
}, [projectDirectory, git, fetchBranches]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open || !projectDirectory || !git) return;
|
|
if (branches?.all) return;
|
|
void fetchBranches(projectDirectory, git);
|
|
}, [open, projectDirectory, git, branches?.all, fetchBranches]);
|
|
|
|
React.useEffect(() => {
|
|
if (!existingBranchDropdownOpen && !existingBranchPickerOpen) {
|
|
setExistingBranchQuery('');
|
|
}
|
|
}, [existingBranchDropdownOpen, existingBranchPickerOpen]);
|
|
|
|
React.useEffect(() => {
|
|
if (!sourceBranchDropdownOpen && !sourceBranchPickerOpen) {
|
|
setSourceBranchQuery('');
|
|
}
|
|
}, [sourceBranchDropdownOpen, sourceBranchPickerOpen]);
|
|
|
|
React.useEffect(() => {
|
|
if (existingBranchDropdownOpen) {
|
|
resetDesktopPickerScroll(existingBranchDropdownContentRef);
|
|
}
|
|
if (existingBranchPickerOpen) {
|
|
resetMobilePickerScroll(existingBranchMobileListWrapperRef);
|
|
}
|
|
}, [
|
|
existingBranchDropdownOpen,
|
|
existingBranchPickerOpen,
|
|
existingBranchQuery,
|
|
resetDesktopPickerScroll,
|
|
resetMobilePickerScroll,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
if (sourceBranchDropdownOpen) {
|
|
resetDesktopPickerScroll(sourceBranchDropdownContentRef);
|
|
}
|
|
if (sourceBranchPickerOpen) {
|
|
resetMobilePickerScroll(sourceBranchMobileListWrapperRef);
|
|
}
|
|
}, [
|
|
sourceBranchDropdownOpen,
|
|
sourceBranchPickerOpen,
|
|
sourceBranchQuery,
|
|
resetDesktopPickerScroll,
|
|
resetMobilePickerScroll,
|
|
]);
|
|
|
|
// Validation state
|
|
const [validation, setValidation] = React.useState<ValidationState>({
|
|
isValidating: false,
|
|
branchError: null,
|
|
worktreeError: null,
|
|
touched: false,
|
|
});
|
|
|
|
// Creation state
|
|
const [isCreating, setIsCreating] = React.useState(false);
|
|
const [validationAbortController, setValidationAbortController] = React.useState<AbortController | null>(null);
|
|
|
|
const resolveDefaultAgentName = React.useCallback((): string | undefined => {
|
|
const configState = useConfigStore.getState();
|
|
const visibleAgents = configState.getVisibleAgents();
|
|
|
|
if (configState.settingsDefaultAgent) {
|
|
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
|
|
if (settingsAgent) {
|
|
return settingsAgent.name;
|
|
}
|
|
}
|
|
|
|
return visibleAgents.find((agent) => agent.name === 'build')?.name || visibleAgents[0]?.name;
|
|
}, []);
|
|
|
|
const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => {
|
|
const configState = useConfigStore.getState();
|
|
const settingsDefaultModel = configState.settingsDefaultModel;
|
|
if (!settingsDefaultModel) return null;
|
|
|
|
const parsed = parseModelIdentifier(settingsDefaultModel);
|
|
if (!parsed) return null;
|
|
const { providerId: providerID, modelId: modelID } = parsed;
|
|
|
|
const modelMetadata = configState.getModelMetadata(providerID, modelID);
|
|
if (!modelMetadata) return null;
|
|
return { providerID, modelID };
|
|
}, []);
|
|
|
|
const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => {
|
|
const configState = useConfigStore.getState();
|
|
const settingsDefaultVariant = configState.settingsDefaultVariant;
|
|
const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID
|
|
? configState.currentVariant
|
|
: undefined;
|
|
|
|
const provider = configState.providers.find((p) => p.id === providerID);
|
|
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelID) as
|
|
| { variants?: Record<string, unknown> }
|
|
| undefined;
|
|
const variants = model?.variants;
|
|
if (!variants) return settingsDefaultVariant || currentVariant || undefined;
|
|
if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return settingsDefaultVariant;
|
|
if (currentVariant && Object.prototype.hasOwnProperty.call(variants, currentVariant)) return currentVariant;
|
|
return undefined;
|
|
}, []);
|
|
|
|
const sendLinkedContextMessage = React.useCallback(async (args: {
|
|
sessionId: string;
|
|
directory: string;
|
|
issue: GitHubIssue | null;
|
|
pr: GitHubPullRequestSummary | null;
|
|
linearIssue: LinkedLinearWorktreeIssue | null;
|
|
includeDiff: boolean;
|
|
}) => {
|
|
const configState = useConfigStore.getState();
|
|
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
|
|
const defaultModel = resolveDefaultModelSelection();
|
|
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
|
|
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
|
|
const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined;
|
|
|
|
if (!providerID || !modelID) {
|
|
toast.error(t('session.newWorktree.error.noModelSelected'));
|
|
return;
|
|
}
|
|
|
|
const variant = resolveDefaultVariant(providerID, modelID);
|
|
|
|
if (args.linearIssue) {
|
|
if (!linear?.issueGet) {
|
|
return;
|
|
}
|
|
|
|
const issueRes = await linear.issueGet(args.linearIssue.identifier);
|
|
if (issueRes.connected === false || !issueRes.issue) {
|
|
throw new Error('Failed to load issue context');
|
|
}
|
|
|
|
const issue = issueRes.issue;
|
|
const comments = issue.comments ?? [];
|
|
const login = issue.assignee?.displayName || issue.assignee?.name;
|
|
const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', {
|
|
identifier: issue.identifier,
|
|
});
|
|
const instructionsText = await renderMagicPrompt('linear.issue.review.instructions');
|
|
const contextText = buildLinearIssueContextText({ issue, comments });
|
|
|
|
postLinearSessionStarted(linear, {
|
|
sessionId: args.sessionId,
|
|
issueIdentifier: issue.identifier,
|
|
});
|
|
|
|
await useSessionUIStore.getState().sendMessage(
|
|
visiblePromptText,
|
|
providerID,
|
|
modelID,
|
|
agentName,
|
|
undefined,
|
|
undefined,
|
|
[
|
|
{ text: instructionsText, synthetic: true },
|
|
{ text: contextText, synthetic: true },
|
|
],
|
|
variant,
|
|
undefined,
|
|
{ sessionId: args.sessionId, directory: args.directory },
|
|
);
|
|
|
|
void sessionActions.setLinkedIssue(
|
|
args.sessionId,
|
|
args.directory,
|
|
buildLinkedLinearIssue({
|
|
identifier: issue.identifier,
|
|
title: issue.title,
|
|
url: issue.url,
|
|
author: login
|
|
? { login, avatarUrl: issue.assignee?.avatarUrl || undefined }
|
|
: args.linearIssue.author,
|
|
linkedAt: Date.now(),
|
|
}),
|
|
true,
|
|
).catch(() => undefined);
|
|
|
|
toast.success(t('session.newWorktree.toast.sessionFromIssue'));
|
|
return;
|
|
}
|
|
|
|
if (!projectDirectory || !github) {
|
|
return;
|
|
}
|
|
|
|
if (args.issue) {
|
|
if (!github.issueGet || !github.issueComments) {
|
|
return;
|
|
}
|
|
|
|
const issueRes = await github.issueGet(projectDirectory, args.issue.number, { sourceRepo: args.issue.sourceRepo ?? null });
|
|
if (issueRes.connected === false || !issueRes.repo || !issueRes.issue) {
|
|
throw new Error('Failed to load issue context');
|
|
}
|
|
|
|
const commentsRes = await github.issueComments(projectDirectory, args.issue.number, { sourceRepo: args.issue.sourceRepo ?? null });
|
|
if (commentsRes.connected === false) {
|
|
throw new Error('Failed to load issue comments');
|
|
}
|
|
|
|
const visiblePromptText = await renderMagicPrompt('github.issue.review.visible', {
|
|
issue_number: String(args.issue.number),
|
|
});
|
|
const instructionsText = await renderMagicPrompt('github.issue.review.instructions');
|
|
const contextText = buildIssueContextText({
|
|
repo: issueRes.repo,
|
|
issue: issueRes.issue,
|
|
comments: commentsRes.comments ?? [],
|
|
});
|
|
|
|
await useSessionUIStore.getState().sendMessage(
|
|
visiblePromptText,
|
|
providerID,
|
|
modelID,
|
|
agentName,
|
|
undefined,
|
|
undefined,
|
|
[
|
|
{ text: instructionsText, synthetic: true },
|
|
{ text: contextText, synthetic: true },
|
|
],
|
|
variant,
|
|
undefined,
|
|
{ sessionId: args.sessionId },
|
|
);
|
|
|
|
// Record the thread this worktree session was created for, so it stays
|
|
// visible as a context source after the opening message scrolls away.
|
|
void sessionActions.setLinkedIssue(
|
|
args.sessionId,
|
|
args.directory,
|
|
buildLinkedIssue({
|
|
url: issueRes.issue.url,
|
|
number: issueRes.issue.number,
|
|
title: issueRes.issue.title,
|
|
kind: 'issue',
|
|
author: issueRes.issue.author,
|
|
linkedAt: Date.now(),
|
|
}),
|
|
true,
|
|
).catch(() => undefined);
|
|
|
|
toast.success(t('session.newWorktree.toast.sessionFromIssue'));
|
|
return;
|
|
}
|
|
|
|
if (args.pr) {
|
|
if (!github.prContext) {
|
|
return;
|
|
}
|
|
|
|
const prContext = await github.prContext(projectDirectory, args.pr.number, {
|
|
sourceRepo: args.pr.sourceRepo ?? null,
|
|
includeDiff: args.includeDiff,
|
|
includeCheckDetails: false,
|
|
});
|
|
if (prContext.connected === false || !prContext.repo || !prContext.pr) {
|
|
throw new Error('Failed to load PR context');
|
|
}
|
|
|
|
const visiblePromptText = await renderMagicPrompt('github.pr.review.visible', {
|
|
pr_number: String(args.pr.number),
|
|
});
|
|
const instructionsText = await renderMagicPrompt('github.pr.review.instructions');
|
|
const contextText = buildPullRequestContextText(prContext);
|
|
|
|
await useSessionUIStore.getState().sendMessage(
|
|
visiblePromptText,
|
|
providerID,
|
|
modelID,
|
|
agentName,
|
|
undefined,
|
|
undefined,
|
|
[
|
|
{ text: instructionsText, synthetic: true },
|
|
{ text: contextText, synthetic: true },
|
|
],
|
|
variant,
|
|
undefined,
|
|
{ sessionId: args.sessionId },
|
|
);
|
|
|
|
void sessionActions.setLinkedIssue(
|
|
args.sessionId,
|
|
args.directory,
|
|
buildLinkedIssue({
|
|
url: prContext.pr.url,
|
|
number: prContext.pr.number,
|
|
title: prContext.pr.title,
|
|
kind: 'pull',
|
|
author: prContext.pr.author,
|
|
linkedAt: Date.now(),
|
|
}),
|
|
true,
|
|
).catch(() => undefined);
|
|
|
|
toast.success(t('session.newWorktree.toast.sessionFromPr'));
|
|
}
|
|
}, [
|
|
github,
|
|
linear,
|
|
projectDirectory,
|
|
resolveDefaultAgentName,
|
|
resolveDefaultModelSelection,
|
|
resolveDefaultVariant,
|
|
t,
|
|
]);
|
|
|
|
// Get current state based on mode
|
|
const currentState = mode === 'new-branch' ? newBranchState : existingBranchState;
|
|
|
|
// Set default source branch when the dialog opens and branches become available
|
|
React.useEffect(() => {
|
|
if (!open || !branches?.all || !projectDirectory) return;
|
|
if (newBranchState.sourceBranch) return;
|
|
|
|
const currentSourceBranch = newBranchState.sourceBranch;
|
|
let cancelled = false;
|
|
|
|
const loadDefaultSourceBranch = async () => {
|
|
try {
|
|
const rootBranch = await getRootBranch(projectDirectory).catch(() => null);
|
|
if (cancelled) return;
|
|
|
|
const savedSourceBranch = localStorage.getItem(LAST_WORKTREE_SOURCE_BRANCH_KEY);
|
|
const {
|
|
sourceBranch: defaultSourceBranch,
|
|
shouldClearSavedSourceBranch,
|
|
} = resolveWorktreeSourceBranchPreference({
|
|
branches: branches.all,
|
|
savedSourceBranch,
|
|
rootBranch,
|
|
});
|
|
|
|
if (shouldClearSavedSourceBranch) {
|
|
localStorage.removeItem(LAST_WORKTREE_SOURCE_BRANCH_KEY);
|
|
}
|
|
|
|
if (cancelled || currentSourceBranch) return;
|
|
|
|
if (defaultSourceBranch) {
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
sourceBranch: defaultSourceBranch,
|
|
}));
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
void loadDefaultSourceBranch();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [open, branches?.all, projectDirectory, newBranchState.sourceBranch]);
|
|
|
|
// Reset state on each open. Resetting on close would empty the form during
|
|
// the close animation, causing visible flicker.
|
|
React.useLayoutEffect(() => {
|
|
if (!open) return;
|
|
|
|
setMode('new-branch');
|
|
setExistingBranchState({
|
|
selectedBranch: '',
|
|
worktreeName: '',
|
|
});
|
|
setExistingBranchDropdownOpen(false);
|
|
setSourceBranchDropdownOpen(false);
|
|
setExistingBranchPickerOpen(false);
|
|
setSourceBranchPickerOpen(false);
|
|
setExistingBranchQuery('');
|
|
setSourceBranchQuery('');
|
|
setValidation({
|
|
isValidating: false,
|
|
branchError: null,
|
|
worktreeError: null,
|
|
touched: false,
|
|
});
|
|
|
|
const uniqueSlug = generateUniqueSlug();
|
|
setNewBranchState({
|
|
branchName: uniqueSlug,
|
|
worktreeName: uniqueSlug,
|
|
isSyncingWorktreeName: true,
|
|
sourceBranch: '',
|
|
linkedIssue: null,
|
|
linkedPr: null,
|
|
linkedLinearIssue: null,
|
|
includePrDiff: false,
|
|
});
|
|
}, [open, generateUniqueSlug]);
|
|
|
|
// Sync worktree name with branch name for new-branch mode
|
|
React.useEffect(() => {
|
|
if (mode !== 'new-branch' || !newBranchState.isSyncingWorktreeName) return;
|
|
|
|
const normalizedBranch = normalizeBranchName(newBranchState.branchName);
|
|
const newWorktreeName = slugifyWorktreeName(normalizedBranch);
|
|
setNewBranchState(prev => ({ ...prev, worktreeName: newWorktreeName }));
|
|
}, [mode, newBranchState.branchName, newBranchState.isSyncingWorktreeName]);
|
|
|
|
// Validation - only runs after fields are touched
|
|
const validateInputs = React.useCallback(async () => {
|
|
if (!projectRef || !validation.touched || isCreating) return;
|
|
|
|
// Cancel previous validation
|
|
if (validationAbortController) {
|
|
validationAbortController.abort();
|
|
}
|
|
|
|
const abortController = new AbortController();
|
|
setValidationAbortController(abortController);
|
|
|
|
setValidation(prev => ({ ...prev, isValidating: true }));
|
|
|
|
try {
|
|
const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch;
|
|
const worktreeName = currentState.worktreeName;
|
|
const normalizedBranch = normalizeBranchName(branchName);
|
|
const normalizedWorktree = slugifyWorktreeName(worktreeName);
|
|
|
|
let branchError: string | null = null;
|
|
let worktreeError: string | null = null;
|
|
|
|
if (!normalizedBranch) {
|
|
branchError = t('session.newWorktree.error.branchNameRequired');
|
|
}
|
|
|
|
if (!normalizedWorktree) {
|
|
worktreeError = t('session.newWorktree.error.worktreeDirectoryRequired');
|
|
}
|
|
|
|
// Only run server validation if we have values
|
|
if (normalizedBranch && normalizedWorktree) {
|
|
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
|
const prConfig = linkedPr ? resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches) : null;
|
|
const result = await validateWorktreeCreate(projectRef, {
|
|
mode: mode === 'existing-branch' || prConfig ? 'existing' : 'new',
|
|
branchName: normalizedBranch,
|
|
worktreeName: normalizedWorktree,
|
|
existingBranch: prConfig?.existingBranch ?? (mode === 'existing-branch' ? normalizedBranch : undefined),
|
|
...(prConfig?.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}),
|
|
...(prConfig?.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}),
|
|
});
|
|
|
|
if (abortController.signal.aborted) return;
|
|
|
|
if (!result.ok) {
|
|
result.errors.forEach((error) => {
|
|
if (error.code === 'worktree_exists') {
|
|
worktreeError = worktreeError ?? error.message;
|
|
return;
|
|
}
|
|
|
|
if (error.code.startsWith('branch_')) {
|
|
branchError = branchError ?? error.message;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!abortController.signal.aborted) {
|
|
setValidation(prev => ({
|
|
...prev,
|
|
isValidating: false,
|
|
branchError,
|
|
worktreeError,
|
|
}));
|
|
}
|
|
} catch {
|
|
if (!abortController.signal.aborted) {
|
|
setValidation(prev => ({
|
|
...prev,
|
|
isValidating: false,
|
|
}));
|
|
}
|
|
}
|
|
}, [
|
|
projectRef,
|
|
mode,
|
|
newBranchState.branchName,
|
|
newBranchState.linkedPr,
|
|
existingBranchState.selectedBranch,
|
|
currentState.worktreeName,
|
|
localBranches,
|
|
remoteBranches,
|
|
validation.touched,
|
|
validationAbortController,
|
|
isCreating,
|
|
t,
|
|
]);
|
|
|
|
// Extract branch name for dependency array
|
|
const currentBranchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch;
|
|
|
|
// Trigger validation on input changes (only after touched)
|
|
React.useEffect(() => {
|
|
if (!open || !projectRef || !validation.touched || isCreating) return;
|
|
|
|
const timer = setTimeout(() => {
|
|
void validateInputs();
|
|
}, 300);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [currentState.worktreeName, currentBranchName, open, projectRef, validateInputs, validation.touched, isCreating]);
|
|
|
|
// Handle worktree creation
|
|
const handleCreate = async () => {
|
|
if (!projectRef || !projectDirectory) {
|
|
toast.error(t('session.newWorktree.error.noActiveProject'));
|
|
return;
|
|
}
|
|
|
|
// Mark as touched and validate immediately
|
|
setValidation(prev => ({ ...prev, touched: true }));
|
|
|
|
const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch;
|
|
const worktreeName = currentState.worktreeName;
|
|
const normalizedBranch = normalizeBranchName(branchName);
|
|
const normalizedWorktree = slugifyWorktreeName(worktreeName);
|
|
|
|
if (!normalizedBranch) {
|
|
toast.error(t('session.newWorktree.error.branchNameRequired'));
|
|
return;
|
|
}
|
|
|
|
if (!normalizedWorktree) {
|
|
toast.error(t('session.newWorktree.error.worktreeDirectoryRequired'));
|
|
return;
|
|
}
|
|
|
|
if (validationAbortController) {
|
|
validationAbortController.abort();
|
|
setValidationAbortController(null);
|
|
}
|
|
|
|
setValidation((prev) => ({
|
|
...prev,
|
|
isValidating: false,
|
|
branchError: null,
|
|
worktreeError: null,
|
|
}));
|
|
|
|
setIsCreating(true);
|
|
|
|
try {
|
|
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
|
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null;
|
|
const linkedLinearIssue = mode === 'new-branch' ? newBranchState.linkedLinearIssue : null;
|
|
const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
|
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
|
|
const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedLinearIssue);
|
|
|
|
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
|
const sourceBranch = newBranchState.sourceBranch;
|
|
|
|
let sourceLabel = '';
|
|
const args = (() => {
|
|
if (linkedPr) {
|
|
const prConfig = resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches);
|
|
sourceLabel = prConfig.sourceLabel;
|
|
return {
|
|
preferredName: normalizedBranch || normalizedWorktree,
|
|
mode: 'existing' as const,
|
|
branchName: normalizedBranch,
|
|
worktreeName: normalizedWorktree,
|
|
existingBranch: prConfig.existingBranch,
|
|
setupCommands,
|
|
setUpstream: prConfig.setUpstream,
|
|
upstreamRemote: prConfig.upstreamRemote,
|
|
upstreamBranch: prConfig.upstreamBranch,
|
|
returnAfterDirectoryCreated: true,
|
|
...(prConfig.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}),
|
|
...(prConfig.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}),
|
|
};
|
|
}
|
|
|
|
sourceLabel = mode === 'new-branch' ? sourceBranch : '';
|
|
return {
|
|
preferredName: normalizedBranch || normalizedWorktree,
|
|
mode: mode === 'existing-branch' ? 'existing' as const : 'new' as const,
|
|
branchName: mode === 'existing-branch' ? undefined : normalizedBranch,
|
|
worktreeName: normalizedWorktree,
|
|
existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined,
|
|
setupCommands,
|
|
returnAfterDirectoryCreated: true,
|
|
...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}),
|
|
};
|
|
})();
|
|
|
|
const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args);
|
|
|
|
const metadata = await createWorktree(projectRef, resolvedArgs);
|
|
|
|
let createdSessionId: string | null = null;
|
|
|
|
if (shouldCreateSession) {
|
|
if (await getWorktreeSetupWaitEnabled(projectRef)) {
|
|
await waitForWorktreeBootstrap(metadata.path);
|
|
}
|
|
|
|
const sessionTitle = linkedLinearIssue
|
|
? `${linkedLinearIssue.identifier} ${linkedLinearIssue.title}`.trim()
|
|
: linkedIssue
|
|
? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
|
|
: linkedPrState
|
|
? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
|
|
: t('session.newWorktree.newSessionTitle');
|
|
|
|
const session = await sessionActions.createSession(sessionTitle, metadata.path, null);
|
|
if (!session?.id) {
|
|
throw new Error('Failed to create session');
|
|
}
|
|
|
|
createdSessionId = session.id;
|
|
onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId });
|
|
onOpenChange(false);
|
|
setIsCreating(false);
|
|
|
|
void sessionActions.updateSessionTitle(session.id, sessionTitle).catch(() => undefined);
|
|
|
|
try {
|
|
useSessionUIStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
} else {
|
|
onOpenChange(false);
|
|
setIsCreating(false);
|
|
}
|
|
|
|
// Save the last source-branch choice for the next open.
|
|
const lastSourceBranch = resolveWorktreeSourceBranchToPersist({
|
|
mode,
|
|
sourceBranch: newBranchState.sourceBranch,
|
|
linkedPr: !!newBranchState.linkedPr,
|
|
selectedBranch: existingBranchState.selectedBranch,
|
|
});
|
|
|
|
if (lastSourceBranch) {
|
|
localStorage.setItem(LAST_WORKTREE_SOURCE_BRANCH_KEY, lastSourceBranch);
|
|
}
|
|
|
|
toast.success(t('session.newWorktree.toast.worktreeCreated'), {
|
|
description: t('session.newWorktree.toast.worktreeCreatedDescription', {
|
|
target: `${metadata.branch || metadata.name}${sourceLabel ? ` ${t('session.newWorktree.fromSource', { source: sourceLabel })}` : ''}`,
|
|
}),
|
|
});
|
|
|
|
if (createdSessionId) {
|
|
void sendLinkedContextMessage({
|
|
sessionId: createdSessionId,
|
|
directory: metadata.path,
|
|
issue: linkedIssue,
|
|
pr: linkedPrState,
|
|
linearIssue: linkedLinearIssue,
|
|
includeDiff: includePrDiff,
|
|
}).catch((error) => {
|
|
const fallback = linkedLinearIssue
|
|
? t('session.newWorktree.error.sendLinearContextFailed')
|
|
: t('session.newWorktree.error.sendGitHubContextFailed');
|
|
const message = error instanceof Error ? error.message : fallback;
|
|
toast.error(fallback, { description: message });
|
|
});
|
|
} else {
|
|
onWorktreeCreated?.(metadata.path);
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('session.newWorktree.error.createWorktreeFailed');
|
|
toast.error(t('session.newWorktree.error.createWorktreeFailed'), { description: message });
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
// Handle mode change
|
|
const handleModeChange = (newMode: Mode) => {
|
|
setMode(newMode);
|
|
setValidation(prev => ({ ...prev, touched: false, branchError: null, worktreeError: null }));
|
|
};
|
|
|
|
// Handle GitHub selection
|
|
const handleGitHubSelect = (result: {
|
|
type: 'issue' | 'pr';
|
|
item: GitHubIssue | GitHubPullRequestSummary;
|
|
includeDiff?: boolean;
|
|
} | null) => {
|
|
if (!result) {
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
linkedIssue: null,
|
|
linkedPr: null,
|
|
linkedLinearIssue: null,
|
|
includePrDiff: false,
|
|
branchName: '',
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (result.type === 'issue') {
|
|
const issue = result.item as GitHubIssue;
|
|
const newBranchName = `issue-${issue.number}-${generateBranchSlug()}`;
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
linkedIssue: issue,
|
|
linkedPr: null,
|
|
linkedLinearIssue: null,
|
|
includePrDiff: false,
|
|
branchName: newBranchName,
|
|
worktreeName: slugifyWorktreeName(newBranchName),
|
|
isSyncingWorktreeName: true,
|
|
}));
|
|
} else if (result.type === 'pr') {
|
|
const pr = result.item as GitHubPullRequestSummary;
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
linkedPr: pr,
|
|
linkedIssue: null,
|
|
linkedLinearIssue: null,
|
|
includePrDiff: result.includeDiff ?? false,
|
|
branchName: pr.head,
|
|
worktreeName: slugifyWorktreeName(pr.head),
|
|
isSyncingWorktreeName: true,
|
|
}));
|
|
}
|
|
};
|
|
|
|
const handleLinearSelect = (issue: {
|
|
identifier: string;
|
|
title: string;
|
|
url: string;
|
|
author?: { login: string; avatarUrl?: string };
|
|
}) => {
|
|
const newBranchName = `issue-${issue.identifier}-${generateBranchSlug()}`;
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
linkedLinearIssue: {
|
|
identifier: issue.identifier,
|
|
title: issue.title,
|
|
url: issue.url,
|
|
author: issue.author,
|
|
},
|
|
linkedIssue: null,
|
|
linkedPr: null,
|
|
includePrDiff: false,
|
|
branchName: newBranchName,
|
|
worktreeName: slugifyWorktreeName(newBranchName),
|
|
isSyncingWorktreeName: true,
|
|
}));
|
|
};
|
|
|
|
// GitHub connection check
|
|
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
|
|
const isLinearConnected = Boolean(linear) && linearAuthChecked && linearAuthStatus?.connected === true;
|
|
|
|
// Check if form is valid for submission
|
|
const isFormValid = mode === 'existing-branch'
|
|
? !!existingBranchState.selectedBranch && !!existingBranchState.worktreeName && !validation.branchError && !validation.worktreeError
|
|
: !!normalizeBranchName(newBranchState.branchName) && !!newBranchState.worktreeName && !validation.branchError && !validation.worktreeError;
|
|
|
|
const canCreate = isFormValid && !isCreating;
|
|
|
|
const handleClearLinkedItem = () => {
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
linkedIssue: null,
|
|
linkedPr: null,
|
|
linkedLinearIssue: null,
|
|
branchName: '',
|
|
includePrDiff: false,
|
|
isSyncingWorktreeName: true,
|
|
}));
|
|
};
|
|
|
|
const startFromIssueButtons = mode === 'new-branch' && (isGitHubConnected || isLinearConnected) ? (
|
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
{isGitHubConnected && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setGithubDialogOpen(true)}
|
|
className="h-8 w-8 px-0"
|
|
title={t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
|
aria-label={t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
|
>
|
|
<Icon name="github" className="size-4 text-status-success" />
|
|
</Button>
|
|
)}
|
|
{isLinearConnected && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setLinearDialogOpen(true)}
|
|
className="h-8 w-8 px-0"
|
|
title={t('session.newWorktree.actions.startFromLinearIssue')}
|
|
aria-label={t('session.newWorktree.actions.startFromLinearIssue')}
|
|
>
|
|
<Icon name="linear" className="size-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : null;
|
|
|
|
// Footer content
|
|
const footerContent = (
|
|
<div className={cn('flex gap-2', isMobile ? 'flex-col w-full' : 'flex-row items-center')}>
|
|
{/* Validation error */}
|
|
<div className={cn('flex items-center gap-1.5 text-destructive', isMobile ? 'w-full justify-center order-first' : 'mr-auto')}>
|
|
{validation.touched && (validation.branchError || validation.worktreeError) && (
|
|
<>
|
|
<Icon name="error-warning" className="h-3.5 w-3.5" />
|
|
<span className="typography-micro">
|
|
{validation.branchError || validation.worktreeError}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Buttons */}
|
|
<div className={cn('flex gap-2', isMobile && 'w-full')}>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={isCreating}
|
|
className={cn(isMobile && 'flex-1')}
|
|
>
|
|
{t('session.newWorktree.actions.cancel')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleCreate}
|
|
disabled={!canCreate || isCreating}
|
|
className={cn('gap-1.5', isMobile && 'flex-1')}
|
|
>
|
|
{isCreating && <Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />}
|
|
{isCreating ? t('session.newWorktree.actions.creating') : t('session.newWorktree.actions.createWorktree')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{isMobile ? (
|
|
<MobileOverlayPanel
|
|
open={open}
|
|
title={t('session.newWorktree.title')}
|
|
onClose={() => onOpenChange(false)}
|
|
footer={footerContent}
|
|
>
|
|
{/* Mode Selection - using SortableTabsStrip */}
|
|
<div className="w-full mb-4">
|
|
<SortableTabsStrip
|
|
items={[
|
|
{ id: 'new-branch', label: t('session.newWorktree.mode.newBranch'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
|
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <Icon name="git-repository" className="h-3.5 w-3.5" /> },
|
|
]}
|
|
activeId={mode}
|
|
onSelect={(id) => handleModeChange(id as Mode)}
|
|
variant="active-pill"
|
|
layoutMode="fit"
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{/* Branch Name / Existing Branch Selection */}
|
|
{mode === 'existing-branch' ? (
|
|
<div className="space-y-1.5">
|
|
<label className="typography-ui-label text-foreground block font-semibold">
|
|
{t('session.newWorktree.selectBranch')}
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setExistingBranchPickerOpen(true)}
|
|
className="flex-1 justify-between h-9"
|
|
>
|
|
<span className={existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground'}>
|
|
{existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')}
|
|
</span>
|
|
<Icon name="git-branch" className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 px-0 shrink-0"
|
|
onClick={handleFetchBranches}
|
|
disabled={!canFetchBranches || isLoadingBranches}
|
|
title={t('session.newWorktree.fetchBranches')}
|
|
>
|
|
{isLoadingBranches ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="refresh" className="size-4" />}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Mobile Branch Picker Overlay */}
|
|
<MobileOverlayPanel
|
|
open={existingBranchPickerOpen}
|
|
title={t('session.newWorktree.selectBranch')}
|
|
onClose={() => setExistingBranchPickerOpen(false)}
|
|
>
|
|
<div className="space-y-4" ref={existingBranchMobileListWrapperRef}>
|
|
<Input
|
|
value={existingBranchQuery}
|
|
onChange={(e) => setExistingBranchQuery(e.target.value)}
|
|
placeholder={t('session.newWorktree.searchBranches')}
|
|
className="h-8"
|
|
/>
|
|
{isLoadingBranches ? (
|
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
|
{t('session.newWorktree.loadingBranches')}
|
|
</div>
|
|
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
|
{t('session.newWorktree.noBranchesFound')}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{hasExistingBranchQuery && hasExistingBranchMatches && (
|
|
<div className="space-y-2">
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
|
{t('session.newWorktree.matchingBranches')}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{existingBranchRankedGroups.matching.map((branch) => (
|
|
<button
|
|
key={`${branch.source}-${branch.value}`}
|
|
onClick={() => {
|
|
setExistingBranchState(prev => ({
|
|
...prev,
|
|
selectedBranch: branch.value,
|
|
worktreeName: slugifyWorktreeName(branch.label),
|
|
}));
|
|
setValidation(prev => ({ ...prev, touched: true }));
|
|
setExistingBranchPickerOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
existingBranchState.selectedBranch === branch.value
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
: 'hover:bg-interactive-hover'
|
|
)}
|
|
>
|
|
<span className="typography-small break-all">{branch.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{hasExistingBranchQuery && !hasExistingBranchMatches && (
|
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
|
{t('session.newWorktree.noMatchingBranches')}
|
|
</div>
|
|
)}
|
|
|
|
{!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
|
{t('session.newWorktree.localBranches')}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{existingBranchRankedGroups.otherLocal.map((branch) => (
|
|
<button
|
|
key={branch}
|
|
onClick={() => {
|
|
setExistingBranchState(prev => ({
|
|
...prev,
|
|
selectedBranch: branch,
|
|
worktreeName: slugifyWorktreeName(branch),
|
|
}));
|
|
setValidation(prev => ({ ...prev, touched: true }));
|
|
setExistingBranchPickerOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
existingBranchState.selectedBranch === branch
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
: 'hover:bg-interactive-hover'
|
|
)}
|
|
>
|
|
<span className="typography-small break-all">{branch}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
|
{t('session.newWorktree.remoteBranches')}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{existingBranchRankedGroups.otherRemote.map((branch) => (
|
|
<button
|
|
key={`remotes/${branch}`}
|
|
onClick={() => {
|
|
setExistingBranchState(prev => ({
|
|
...prev,
|
|
selectedBranch: `remotes/${branch}`,
|
|
worktreeName: slugifyWorktreeName(branch),
|
|
}));
|
|
setValidation(prev => ({ ...prev, touched: true }));
|
|
setExistingBranchPickerOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
existingBranchState.selectedBranch === `remotes/${branch}`
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
: 'hover:bg-interactive-hover'
|
|
)}
|
|
>
|
|
<span className="typography-small break-all">{branch}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</MobileOverlayPanel>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<label className="typography-ui-label text-foreground font-semibold shrink-0">
|
|
{t('session.newWorktree.branchName')}
|
|
</label>
|
|
{startFromIssueButtons}
|
|
</div>
|
|
<Input
|
|
value={newBranchState.branchName}
|
|
onChange={(e) => {
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
branchName: e.target.value,
|
|
isSyncingWorktreeName: true,
|
|
linkedIssue: null,
|
|
linkedPr: null,
|
|
linkedLinearIssue: null,
|
|
}));
|
|
}}
|
|
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
|
placeholder={t('session.newWorktree.branchNamePlaceholder')}
|
|
disabled={!!newBranchState.linkedPr}
|
|
className={cn(
|
|
'h-8',
|
|
validation.touched && validation.branchError && 'border-destructive',
|
|
newBranchState.linkedPr && 'bg-muted text-muted-foreground'
|
|
)}
|
|
/>
|
|
{newBranchState.linkedPr && (
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
|
<span className="typography-micro">
|
|
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedPr.head })}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
|
<span className="typography-micro">
|
|
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedIssue.number, title: newBranchState.linkedIssue.title })}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{newBranchState.linkedLinearIssue && (
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
|
<span className="typography-micro">
|
|
{t('session.newWorktree.fromLinearIssue', {
|
|
identifier: newBranchState.linkedLinearIssue.identifier,
|
|
title: newBranchState.linkedLinearIssue.title,
|
|
})}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Worktree Directory */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="typography-ui-label text-foreground font-semibold">
|
|
{t('session.newWorktree.worktreeDirectory')}
|
|
</label>
|
|
{mode !== 'existing-branch' && (
|
|
<button
|
|
onClick={() => {
|
|
const syncedName = slugifyWorktreeName(mode === 'new-branch' ? newBranchState.branchName : '');
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
worktreeName: syncedName,
|
|
isSyncingWorktreeName: true,
|
|
}));
|
|
}}
|
|
disabled={!newBranchState.branchName || newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName)}
|
|
className={cn(
|
|
'flex items-center gap-1 typography-micro transition-colors px-1.5 py-0.5 rounded',
|
|
newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName) || !newBranchState.branchName
|
|
? 'text-muted-foreground/40 cursor-not-allowed'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
)}
|
|
title={t('session.newWorktree.resetToMatchBranchName')}
|
|
>
|
|
<Icon name="refresh" className="h-3 w-3" />
|
|
<span>{t('session.newWorktree.actions.reset')}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Input
|
|
value={currentState.worktreeName}
|
|
onChange={(e) => {
|
|
if (mode === 'new-branch') {
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
worktreeName: e.target.value,
|
|
isSyncingWorktreeName: false,
|
|
}));
|
|
} else {
|
|
setExistingBranchState(prev => ({
|
|
...prev,
|
|
worktreeName: e.target.value,
|
|
}));
|
|
}
|
|
}}
|
|
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
|
placeholder={t('session.newWorktree.worktreeDirectoryPlaceholder')}
|
|
className={cn(
|
|
'h-8',
|
|
validation.touched && validation.worktreeError && 'border-destructive'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Source Branch - Only for New Branch mode, hide when PR is selected */}
|
|
{mode === 'new-branch' && !newBranchState.linkedPr && (
|
|
<div className="space-y-1.5">
|
|
<label className="typography-ui-label text-foreground block font-semibold">
|
|
{t('session.newWorktree.sourceBranch')}
|
|
</label>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setSourceBranchPickerOpen(true)}
|
|
className="w-full justify-between h-9"
|
|
>
|
|
<span className={newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground'}>
|
|
{newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')}
|
|
</span>
|
|
<Icon name="git-branch" className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
{newBranchState.sourceBranch && (
|
|
<div className="typography-micro text-muted-foreground">
|
|
{t('session.newWorktree.newBranchFromSource', { source: newBranchState.sourceBranch })}
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile Source Branch Picker Overlay */}
|
|
<MobileOverlayPanel
|
|
open={sourceBranchPickerOpen}
|
|
title={t('session.newWorktree.selectSourceBranch')}
|
|
onClose={() => setSourceBranchPickerOpen(false)}
|
|
>
|
|
<div className="space-y-4" ref={sourceBranchMobileListWrapperRef}>
|
|
<Input
|
|
value={sourceBranchQuery}
|
|
onChange={(e) => setSourceBranchQuery(e.target.value)}
|
|
placeholder={t('session.newWorktree.searchBranches')}
|
|
className="h-8"
|
|
/>
|
|
{isLoadingBranches ? (
|
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
|
{t('session.newWorktree.loadingBranches')}
|
|
</div>
|
|
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
|
{t('session.newWorktree.noBranchesFound')}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{hasSourceBranchQuery && hasSourceBranchMatches && (
|
|
<div className="space-y-2">
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
|
{t('session.newWorktree.matchingBranches')}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{sourceBranchRankedGroups.matching.map((branch) => (
|
|
<button
|
|
key={`${branch.source}-${branch.value}`}
|
|
onClick={() => {
|
|
setNewBranchState(prev => ({ ...prev, sourceBranch: branch.value }));
|
|
setSourceBranchPickerOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
newBranchState.sourceBranch === branch.value
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
: 'hover:bg-interactive-hover'
|
|
)}
|
|
>
|
|
<span className="typography-small break-all">{branch.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{hasSourceBranchQuery && !hasSourceBranchMatches && (
|
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
|
{t('session.newWorktree.noMatchingBranches')}
|
|
</div>
|
|
)}
|
|
|
|
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
|
{t('session.newWorktree.localBranches')}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{sourceBranchRankedGroups.otherLocal.map((branch) => (
|
|
<button
|
|
key={branch}
|
|
onClick={() => {
|
|
setNewBranchState(prev => ({ ...prev, sourceBranch: branch }));
|
|
setSourceBranchPickerOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
newBranchState.sourceBranch === branch
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
: 'hover:bg-interactive-hover'
|
|
)}
|
|
>
|
|
<span className="typography-small break-all">{branch}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
|
{t('session.newWorktree.remoteBranches')}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{sourceBranchRankedGroups.otherRemote.map((branch) => (
|
|
<button
|
|
key={`remotes/${branch}`}
|
|
onClick={() => {
|
|
setNewBranchState(prev => ({ ...prev, sourceBranch: `remotes/${branch}` }));
|
|
setSourceBranchPickerOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
newBranchState.sourceBranch === `remotes/${branch}`
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
: 'hover:bg-interactive-hover'
|
|
)}
|
|
>
|
|
<span className="typography-small break-all">{branch}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</MobileOverlayPanel>
|
|
</div>
|
|
)}
|
|
|
|
{/* Linked Item Preview - Two row minimal display */}
|
|
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
|
|
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
|
|
{/* Row 1: Type, number, title, actions */}
|
|
<div className="flex items-center gap-2">
|
|
<Icon
|
|
name={newBranchState.linkedLinearIssue ? 'linear' : 'github'}
|
|
className={cn(
|
|
'h-3.5 w-3.5 shrink-0',
|
|
newBranchState.linkedLinearIssue ? 'text-foreground' : 'text-status-success',
|
|
)}
|
|
/>
|
|
|
|
{newBranchState.linkedLinearIssue && (
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
|
{newBranchState.linkedLinearIssue.identifier}
|
|
</span>
|
|
)}
|
|
{newBranchState.linkedIssue && (
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
|
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
|
|
</span>
|
|
)}
|
|
{newBranchState.linkedPr && (
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
|
{t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })}
|
|
</span>
|
|
)}
|
|
|
|
<span className="typography-micro text-foreground truncate flex-1">
|
|
{newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
|
</span>
|
|
|
|
<a
|
|
href={newBranchState.linkedLinearIssue?.url || newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-muted-foreground hover:text-foreground shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Icon name="external-link" className="h-3 w-3" />
|
|
</a>
|
|
|
|
<button
|
|
onClick={handleClearLinkedItem}
|
|
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
|
|
>
|
|
<Icon name="close" className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Row 2: PR branch info + diff indicator */}
|
|
{newBranchState.linkedPr && (
|
|
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
|
<span className="typography-micro text-muted-foreground">
|
|
{newBranchState.linkedPr.head} → {newBranchState.linkedPr.base}
|
|
</span>
|
|
{newBranchState.includePrDiff && (
|
|
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
|
|
{t('session.newWorktree.includeDiffBadge')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</MobileOverlayPanel>
|
|
) : (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-lg max-h-[80vh] flex flex-col">
|
|
<DialogHeader className="flex flex-row items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<DialogTitle className="flex items-center gap-2 shrink-0">
|
|
<Icon name="git-branch" className="h-5 w-5" />
|
|
{t('session.newWorktree.title')}
|
|
</DialogTitle>
|
|
|
|
{/* Mode Selection - using SortableTabsStrip */}
|
|
<div className="w-[280px] shrink-0">
|
|
<SortableTabsStrip
|
|
items={[
|
|
{ id: 'new-branch', label: t('session.newWorktree.mode.newBranch'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
|
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <Icon name="git-repository" className="h-3.5 w-3.5" /> },
|
|
]}
|
|
activeId={mode}
|
|
onSelect={(id) => handleModeChange(id as Mode)}
|
|
variant="active-pill"
|
|
layoutMode="fit"
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto mt-2 space-y-6">
|
|
{/* Branch Name / Existing Branch Selection */}
|
|
{mode === 'existing-branch' ? (
|
|
<div className="space-y-1.5">
|
|
<label className="typography-ui-label text-foreground block font-semibold">
|
|
{t('session.newWorktree.selectBranch')}
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<DropdownMenu open={existingBranchDropdownOpen} onOpenChange={setExistingBranchDropdownOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(dropdownTriggerVariants({ size: 'default' }), 'min-w-[220px] max-w-full')}
|
|
>
|
|
<span className={cn('truncate', existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground')}>
|
|
{existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')}
|
|
</span>
|
|
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" sideOffset={6} portalToBody className="w-[min(42rem,calc(100vw-2rem))] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" ref={existingBranchDropdownContentRef}>
|
|
<Command shouldFilter={false}>
|
|
<CommandInput
|
|
placeholder={t('session.newWorktree.searchBranches')}
|
|
value={existingBranchQuery}
|
|
onValueChange={setExistingBranchQuery}
|
|
onKeyDown={stopDropdownTypeahead}
|
|
/>
|
|
<CommandList disableHorizontal>
|
|
{isLoadingBranches ? (
|
|
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
|
{t('session.newWorktree.loadingBranches')}
|
|
</div>
|
|
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
|
<CommandEmpty>{t('session.newWorktree.noBranchesFound')}</CommandEmpty>
|
|
) : (
|
|
<>
|
|
{hasExistingBranchQuery && hasExistingBranchMatches && (
|
|
<CommandGroup heading={t('session.newWorktree.matchingBranches')}>
|
|
{existingBranchRankedGroups.matching.map((branch) => (
|
|
<CommandItem
|
|
key={`${branch.source}-${branch.value}`}
|
|
value={branch.value}
|
|
onSelect={() => {
|
|
setExistingBranchState((prev) => ({
|
|
...prev,
|
|
selectedBranch: branch.value,
|
|
worktreeName: slugifyWorktreeName(branch.label),
|
|
}));
|
|
setValidation((prev) => ({ ...prev, touched: true }));
|
|
setExistingBranchDropdownOpen(false);
|
|
}}
|
|
>
|
|
<span className="typography-small break-all">{branch.label}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{hasExistingBranchQuery && !hasExistingBranchMatches && (
|
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
|
{t('session.newWorktree.noMatchingBranches')}
|
|
</div>
|
|
)}
|
|
|
|
{!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
|
|
<>
|
|
<CommandGroup heading={t('session.newWorktree.localBranches')}>
|
|
{existingBranchRankedGroups.otherLocal.map((branch) => (
|
|
<CommandItem
|
|
key={`local-${branch}`}
|
|
value={branch}
|
|
onSelect={() => {
|
|
setExistingBranchState((prev) => ({
|
|
...prev,
|
|
selectedBranch: branch,
|
|
worktreeName: slugifyWorktreeName(branch),
|
|
}));
|
|
setValidation((prev) => ({ ...prev, touched: true }));
|
|
setExistingBranchDropdownOpen(false);
|
|
}}
|
|
>
|
|
<span className="typography-small break-all">{branch}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</>
|
|
)}
|
|
|
|
{!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
|
|
<>
|
|
{existingBranchRankedGroups.otherLocal.length > 0 && (
|
|
<CommandSeparator />
|
|
)}
|
|
<CommandGroup heading={t('session.newWorktree.remoteBranches')}>
|
|
{existingBranchRankedGroups.otherRemote.map((branch) => (
|
|
<CommandItem
|
|
key={`remote-${branch}`}
|
|
value={`remotes/${branch}`}
|
|
onSelect={() => {
|
|
setExistingBranchState((prev) => ({
|
|
...prev,
|
|
selectedBranch: `remotes/${branch}`,
|
|
worktreeName: slugifyWorktreeName(branch),
|
|
}));
|
|
setValidation((prev) => ({ ...prev, touched: true }));
|
|
setExistingBranchDropdownOpen(false);
|
|
}}
|
|
>
|
|
<span className="typography-small break-all">{branch}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 px-0 shrink-0"
|
|
onClick={handleFetchBranches}
|
|
disabled={!canFetchBranches || isLoadingBranches}
|
|
title={t('session.newWorktree.fetchBranches')}
|
|
>
|
|
{isLoadingBranches ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="refresh" className="size-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<label className="typography-ui-label text-foreground font-semibold shrink-0">
|
|
{t('session.newWorktree.branchName')}
|
|
</label>
|
|
{startFromIssueButtons}
|
|
</div>
|
|
<Input
|
|
value={newBranchState.branchName}
|
|
onChange={(e) => {
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
branchName: e.target.value,
|
|
isSyncingWorktreeName: true,
|
|
linkedIssue: null,
|
|
linkedPr: null,
|
|
linkedLinearIssue: null,
|
|
}));
|
|
}}
|
|
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
|
placeholder={t('session.newWorktree.branchNamePlaceholder')}
|
|
disabled={!!newBranchState.linkedPr}
|
|
className={cn(
|
|
'h-8',
|
|
validation.touched && validation.branchError && 'border-destructive',
|
|
newBranchState.linkedPr && 'bg-muted text-muted-foreground'
|
|
)}
|
|
/>
|
|
{newBranchState.linkedPr && (
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
|
<span className="typography-micro">
|
|
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedPr.head })}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
|
<span className="typography-micro">
|
|
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedIssue.number, title: newBranchState.linkedIssue.title })}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{newBranchState.linkedLinearIssue && (
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
|
<span className="typography-micro">
|
|
{t('session.newWorktree.fromLinearIssue', {
|
|
identifier: newBranchState.linkedLinearIssue.identifier,
|
|
title: newBranchState.linkedLinearIssue.title,
|
|
})}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Worktree Directory */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="typography-ui-label text-foreground font-semibold">
|
|
{t('session.newWorktree.worktreeDirectory')}
|
|
</label>
|
|
{mode !== 'existing-branch' && (
|
|
<button
|
|
onClick={() => {
|
|
const syncedName = slugifyWorktreeName(mode === 'new-branch' ? newBranchState.branchName : '');
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
worktreeName: syncedName,
|
|
isSyncingWorktreeName: true,
|
|
}));
|
|
}}
|
|
disabled={!newBranchState.branchName || newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName)}
|
|
className={cn(
|
|
'flex items-center gap-1 typography-micro transition-colors px-1.5 py-0.5 rounded',
|
|
newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName) || !newBranchState.branchName
|
|
? 'text-muted-foreground/40 cursor-not-allowed'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
)}
|
|
title={t('session.newWorktree.resetToMatchBranchName')}
|
|
>
|
|
<Icon name="refresh" className="h-3 w-3" />
|
|
<span>{t('session.newWorktree.actions.reset')}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Input
|
|
value={currentState.worktreeName}
|
|
onChange={(e) => {
|
|
if (mode === 'new-branch') {
|
|
setNewBranchState(prev => ({
|
|
...prev,
|
|
worktreeName: e.target.value,
|
|
isSyncingWorktreeName: false,
|
|
}));
|
|
} else {
|
|
setExistingBranchState(prev => ({
|
|
...prev,
|
|
worktreeName: e.target.value,
|
|
}));
|
|
}
|
|
}}
|
|
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
|
placeholder={t('session.newWorktree.worktreeDirectoryPlaceholder')}
|
|
className={cn(
|
|
'h-8',
|
|
validation.touched && validation.worktreeError && 'border-destructive'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Source Branch - Only for New Branch mode, hide when PR is selected */}
|
|
{mode === 'new-branch' && !newBranchState.linkedPr && (
|
|
<div className="space-y-1.5">
|
|
<label className="typography-ui-label text-foreground block font-semibold">
|
|
{t('session.newWorktree.sourceBranch')}
|
|
</label>
|
|
<DropdownMenu open={sourceBranchDropdownOpen} onOpenChange={setSourceBranchDropdownOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(dropdownTriggerVariants({ size: 'default' }), 'min-w-[220px] max-w-full')}
|
|
>
|
|
<span className={cn('truncate', newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground')}>
|
|
{newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')}
|
|
</span>
|
|
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" portalToBody className="w-[min(42rem,calc(100vw-2rem))] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" ref={sourceBranchDropdownContentRef}>
|
|
<Command shouldFilter={false}>
|
|
<CommandInput
|
|
placeholder={t('session.newWorktree.searchBranches')}
|
|
value={sourceBranchQuery}
|
|
onValueChange={setSourceBranchQuery}
|
|
onKeyDown={stopDropdownTypeahead}
|
|
/>
|
|
<CommandList disableHorizontal>
|
|
{isLoadingBranches ? (
|
|
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
|
{t('session.newWorktree.loadingBranches')}
|
|
</div>
|
|
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
|
<CommandEmpty>{t('session.newWorktree.noBranchesFound')}</CommandEmpty>
|
|
) : (
|
|
<>
|
|
{hasSourceBranchQuery && hasSourceBranchMatches && (
|
|
<CommandGroup heading={t('session.newWorktree.matchingBranches')}>
|
|
{sourceBranchRankedGroups.matching.map((branch) => (
|
|
<CommandItem
|
|
key={`${branch.source}-${branch.value}`}
|
|
value={branch.value}
|
|
onSelect={() => {
|
|
setNewBranchState((prev) => ({ ...prev, sourceBranch: branch.value }));
|
|
setSourceBranchDropdownOpen(false);
|
|
}}
|
|
>
|
|
<span className="typography-small break-all">{branch.label}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{hasSourceBranchQuery && !hasSourceBranchMatches && (
|
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
|
{t('session.newWorktree.noMatchingBranches')}
|
|
</div>
|
|
)}
|
|
|
|
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
|
|
<>
|
|
<CommandGroup heading={t('session.newWorktree.localBranches')}>
|
|
{sourceBranchRankedGroups.otherLocal.map((branch) => (
|
|
<CommandItem
|
|
key={`local-${branch}`}
|
|
value={branch}
|
|
onSelect={() => {
|
|
setNewBranchState((prev) => ({ ...prev, sourceBranch: branch }));
|
|
setSourceBranchDropdownOpen(false);
|
|
}}
|
|
>
|
|
<span className="typography-small break-all">{branch}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</>
|
|
)}
|
|
|
|
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
|
|
<>
|
|
{sourceBranchRankedGroups.otherLocal.length > 0 && (
|
|
<CommandSeparator />
|
|
)}
|
|
<CommandGroup heading={t('session.newWorktree.remoteBranches')}>
|
|
{sourceBranchRankedGroups.otherRemote.map((branch) => (
|
|
<CommandItem
|
|
key={`remote-${branch}`}
|
|
value={`remotes/${branch}`}
|
|
onSelect={() => {
|
|
setNewBranchState((prev) => ({ ...prev, sourceBranch: `remotes/${branch}` }));
|
|
setSourceBranchDropdownOpen(false);
|
|
}}
|
|
>
|
|
<span className="typography-small break-all">{branch}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{newBranchState.sourceBranch && (
|
|
<div className="typography-micro text-muted-foreground">
|
|
{t('session.newWorktree.newBranchFromSource', { source: newBranchState.sourceBranch })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Linked Item Preview - Two row minimal display */}
|
|
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
|
|
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
|
|
{/* Row 1: Type, number, title, actions */}
|
|
<div className="flex items-center gap-2">
|
|
<Icon
|
|
name={newBranchState.linkedLinearIssue ? 'linear' : 'github'}
|
|
className={cn(
|
|
'h-3.5 w-3.5 shrink-0',
|
|
newBranchState.linkedLinearIssue ? 'text-foreground' : 'text-status-success',
|
|
)}
|
|
/>
|
|
|
|
{newBranchState.linkedLinearIssue && (
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
|
{newBranchState.linkedLinearIssue.identifier}
|
|
</span>
|
|
)}
|
|
{newBranchState.linkedIssue && (
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
|
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
|
|
</span>
|
|
)}
|
|
{newBranchState.linkedPr && (
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
|
{t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })}
|
|
</span>
|
|
)}
|
|
|
|
<span className="typography-micro text-foreground truncate flex-1">
|
|
{newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
|
</span>
|
|
|
|
<a
|
|
href={newBranchState.linkedLinearIssue?.url || newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-muted-foreground hover:text-foreground shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Icon name="external-link" className="h-3 w-3" />
|
|
</a>
|
|
|
|
<button
|
|
onClick={handleClearLinkedItem}
|
|
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
|
|
>
|
|
<Icon name="close" className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Row 2: PR branch info + diff indicator */}
|
|
{newBranchState.linkedPr && (
|
|
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
|
<span className="typography-micro text-muted-foreground">
|
|
{newBranchState.linkedPr.head} → {newBranchState.linkedPr.base}
|
|
</span>
|
|
{newBranchState.includePrDiff && (
|
|
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
|
|
{t('session.newWorktree.includeDiffBadge')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<DialogFooter className="mt-1 flex items-center justify-between">
|
|
{/* Validation error - inline with buttons */}
|
|
<div className="flex items-center gap-1.5 text-destructive">
|
|
{validation.touched && (validation.branchError || validation.worktreeError) && (
|
|
<>
|
|
<Icon name="error-warning" className="h-3.5 w-3.5" />
|
|
<span className="typography-micro">
|
|
{validation.branchError || validation.worktreeError}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={isCreating}
|
|
>
|
|
{t('session.newWorktree.actions.cancel')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleCreate}
|
|
disabled={!canCreate || isCreating}
|
|
className="gap-1.5"
|
|
>
|
|
{isCreating && <Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />}
|
|
{isCreating ? t('session.newWorktree.actions.creating') : t('session.newWorktree.actions.createWorktree')}
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
|
|
<GitHubIntegrationDialog
|
|
open={githubDialogOpen}
|
|
onOpenChange={setGithubDialogOpen}
|
|
onSelect={handleGitHubSelect}
|
|
/>
|
|
<LinearIssuePickerDialog
|
|
open={linearDialogOpen}
|
|
onOpenChange={setLinearDialogOpen}
|
|
mode="select"
|
|
onSelect={handleLinearSelect}
|
|
/>
|
|
</>
|
|
);
|
|
}
|