From bdad912ea5b2a69a82b3a55b464850b52fffd364 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 3 Mar 2026 00:20:15 +0200 Subject: [PATCH] feat: streamline worktree session flow (#577) * feat: add unified worktree creation dialog with GitHub integration Unified dialog for creating worktrees with branch selection and GitHub integration New GitHub picker dialog for selecting issues and PRs with validation Persistent state when switching between new branch and existing branch modes * refactor: minimalistic compact design for worktree dialogs Redesigned dialogs with minimalistic compact layout Moved mode tabs inline with dialog header Inline validation errors with action buttons * fix: maintain consistent dialog height in all states Fixed dialog height fluctuations when loading or switching tabs Consistent 300px height for loading, empty, and populated states Nested container structure prevents layout shifts * refactor: minimalistic compact UI redesign for worktree dialogs Minimalistic compact layouts with reduced spacing and inline tabs Standardized SortableTabsStrip with pills variant for mode selection Two-row linked item display and inline validation with action buttons * feat: mobile overlays for worktree dialogs with branch pickers Mobile overlay panels replace dropdowns for branch selection Fixed footer button overlapping with proper flex layout Responsive design with conditional mobile/desktop rendering * refactor: relocate selected item badge to footer with proper alignment Selected item badge moved to footer left side on desktop Proper alignment with Cancel/Select buttons using h-8 height Cleaner header layout removing the selected item display * perf: use cached branches from git store for instant branch selection Branch list now loads instantly from git store cache No more loading delay when opening new worktree dialog Removes redundant API calls by using existing cached data * refactor: improve branch selection UI with grouped headers and compact layout Group branches into Local and Remote sections with clear headers Compact dropdowns with better sizing and text wrapping Bold labels and smaller issue/PR titles for cleaner layout * feat: add prefilled slugs and improve branch selection UX in new worktree dialog Prefilled unique branch name when opening new worktree dialog Group branches into Local and Remote sections with bold headers Instant branch list from cache with compact, wrapped dropdowns * feat: update GitHub button text to be more descriptive * feat: add GitHub issue linking to draft sessions Link GitHub issues to draft sessions with full context including body, labels and comments View issue author avatar, number and title in session input area Open linked issues in browser or remove link with inline action buttons * style: redesign queued messages with text-only row layout * style: redesign attached files with text-only row layout and file type icons Redesigned attached files with clean text-only rows matching linked issue style Split display into two rows: image previews and file list with proper type icons Removed internal Server/Local indicators to reduce visual clutter * refactor: remove New from PR and Manage branches from sessions UI Remove New from PR and Manage branches buttons from sessions sidebar Delete GitHubPullRequestPickerDialog component and related code Simplify GitView to sidebar-only mode with no mode prop * fix: simplify new session shortcuts and remove worktree toggle * fix: restore issue and PR context when creating worktree sessions Worktrees created from GitHub issues and PRs now seed the new session with visible and synthetic context messages Sidebar now opens the created linked session directly instead of creating an extra draft Visible prompts are cleaner and include issue or PR numbers for clarity --- packages/desktop/src-tauri/src/main.rs | 61 +- packages/ui/src/App.tsx | 21 +- packages/ui/src/components/chat/ChatInput.tsx | 103 +- .../ui/src/components/chat/FileAttachment.tsx | 607 +++--- .../components/chat/QueuedMessageChips.tsx | 70 +- .../ui/src/components/layout/MainLayout.tsx | 2 +- .../components/layout/RightSidebarTabs.tsx | 2 +- .../sections/openchamber/DefaultsSettings.tsx | 60 +- .../session/GitHubIntegrationDialog.tsx | 618 ++++++ .../session/GitHubIssuePickerDialog.tsx | 76 +- .../session/GitHubPullRequestPickerDialog.tsx | 881 --------- .../components/session/NewWorktreeDialog.tsx | 1667 +++++++++++++++++ .../src/components/session/SessionSidebar.tsx | 137 +- .../ui/src/components/ui/CommandPalette.tsx | 7 +- packages/ui/src/components/ui/HelpDialog.tsx | 10 +- packages/ui/src/components/views/GitView.tsx | 52 +- .../ui/src/components/views/git/GitHeader.tsx | 115 +- packages/ui/src/hooks/useKeyboardShortcuts.ts | 19 +- 18 files changed, 2850 insertions(+), 1658 deletions(-) create mode 100644 packages/ui/src/components/session/GitHubIntegrationDialog.tsx delete mode 100644 packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx create mode 100644 packages/ui/src/components/session/NewWorktreeDialog.tsx diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 4275a8cd..9f1af657 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -151,21 +151,8 @@ fn build_macos_menu( let pkg_info = app.package_info(); - let auto_worktree = app - .try_state::() - .map(|state| *state.auto_worktree.lock().expect("menu state mutex")) - .unwrap_or(false); - - let new_session_shortcut = if auto_worktree { - "Cmd+Shift+N" - } else { - "Cmd+N" - }; - let new_worktree_shortcut = if auto_worktree { - "Cmd+N" - } else { - "Cmd+Shift+N" - }; + let new_session_shortcut = "Cmd+N"; + let new_worktree_shortcut = "Cmd+Shift+N"; let about = MenuItem::with_id( app, @@ -442,43 +429,6 @@ fn build_macos_menu( ) } -#[tauri::command] -fn desktop_set_auto_worktree_menu(app: tauri::AppHandle, enabled: bool) -> Result<(), String> { - let Some(state) = app.try_state::() else { - return Ok(()); - }; - - { - let mut guard = state.auto_worktree.lock().expect("menu state mutex"); - *guard = enabled; - } - - #[cfg(target_os = "macos")] - { - use tauri::menu::MenuItemKind; - - let new_session_shortcut = if enabled { "Cmd+Shift+N" } else { "Cmd+N" }; - let new_worktree_shortcut = if enabled { "Cmd+N" } else { "Cmd+Shift+N" }; - - if let Some(menu) = app.menu() { - if let Some(MenuItemKind::MenuItem(item)) = menu.get(MENU_ITEM_NEW_SESSION_ID) { - item.set_accelerator(Some(new_session_shortcut)) - .map_err(|err| err.to_string())?; - } - if let Some(MenuItemKind::MenuItem(item)) = menu.get(MENU_ITEM_WORKTREE_CREATOR_ID) { - item.set_accelerator(Some(new_worktree_shortcut)) - .map_err(|err| err.to_string())?; - } - } else { - // Should not happen on macOS, but keep as fallback. - let menu = build_macos_menu(&app).map_err(|err| err.to_string())?; - app.set_menu(menu).map_err(|err| err.to_string())?; - } - } - - Ok(()) -} - #[tauri::command] fn desktop_clear_cache(app: tauri::AppHandle) -> Result<(), String> { #[cfg(target_os = "macos")] @@ -1102,11 +1052,6 @@ impl WindowFocusState { } } -#[derive(Default)] -struct MenuRuntimeState { - auto_worktree: Mutex, -} - #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct DesktopHost { @@ -2502,7 +2447,6 @@ fn main() { .manage(DesktopUiInjectionState::default()) .manage(WindowFocusState::default()) .manage(WindowGeometryDebounceState::default()) - .manage(MenuRuntimeState::default()) .manage(DesktopSshManagerState::default()) .manage(PendingUpdate(Mutex::new(None))) .plugin(tauri_plugin_shell::init()) @@ -2709,7 +2653,6 @@ fn main() { desktop_restart, desktop_new_window, desktop_new_window_at_url, - desktop_set_auto_worktree_menu, desktop_clear_cache, desktop_open_path, desktop_filter_installed_apps, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 4a68b79a..e662a3bb 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -20,7 +20,7 @@ import { useWindowTitle } from '@/hooks/useWindowTitle'; import { GitPollingProvider } from '@/hooks/useGitPolling'; import { useConfigStore } from '@/stores/useConfigStore'; import { hasModifier } from '@/lib/utils'; -import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop'; +import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop'; import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -332,25 +332,6 @@ function App({ apis }: AppProps) { useMenuActions(handleToggleMemoryDebug); - const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); - React.useEffect(() => { - if (embeddedSessionChat) { - return; - } - - if (!isTauriShell()) { - return; - } - const tauri = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record) => Promise } } }).__TAURI__; - if (typeof tauri?.core?.invoke !== 'function') { - return; - } - - void tauri.core.invoke('desktop_set_auto_worktree_menu', { enabled: settingsAutoCreateWorktree }); - }, [embeddedSessionChat, settingsAutoCreateWorktree]); - - - useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled }); useSessionAutoCleanup({ enabled: embeddedBackgroundWorkEnabled }); useQueuedMessageAutoSend({ enabled: embeddedBackgroundWorkEnabled }); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index cab3f1da..ea748ff0 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -4,9 +4,12 @@ import { RiAddCircleLine, RiAiAgentLine, RiAttachment2, + RiCloseLine, RiCommandLine, + RiExternalLinkLine, RiFileUploadLine, RiFullscreenLine, + RiGithubLine, RiSendPlane2Line, } from '@remixicon/react'; import { BrowserVoiceButton } from '@/components/voice'; @@ -48,6 +51,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog'; const MAX_VISIBLE_TEXTAREA_LINES = 8; const EMPTY_QUEUE: QueuedMessage[] = []; @@ -157,6 +161,16 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const abortTimeoutRef = React.useRef | null>(null); const prevWasAbortedRef = React.useRef(false); + // Issue linking state (for draft sessions) + const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); + const [linkedIssue, setLinkedIssue] = React.useState<{ + number: number; + title: string; + url: string; + contextText: string; + author?: { login: string; avatarUrl?: string }; + } | null>(null); + // Message queue const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled); const queuedMessages = useMessageQueueStore( @@ -554,6 +568,15 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } } + // Add linked issue as synthetic part (only the parts with synthetic: true) + // The text part (synthetic: false) is completely dropped per requirements + if (linkedIssue && newSessionDraftOpen) { + additionalParts.push({ + text: linkedIssue.contextText, + synthetic: true, + }); + } + if (!primaryText && additionalParts.length === 0) return; // Clear queue and input @@ -625,7 +648,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo additionalParts.length > 0 ? additionalParts : undefined, currentVariant, inputMode - ).catch((error: unknown) => { + ).then(() => { + // Clear linked issue after successful message send in draft mode + if (linkedIssue && newSessionDraftOpen) { + setLinkedIssue(null); + } + }).catch((error: unknown) => { const rawMessage = error instanceof Error ? error.message @@ -674,6 +702,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }; + // Update ref with latest handleSubmit on every render handleSubmitRef.current = handleSubmit; // Primary action for send button - respects queue mode setting @@ -2135,6 +2164,70 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo )} + + {/* Linked Issue Button - only in draft mode */} + {newSessionDraftOpen && ( +
+ {linkedIssue ? ( + + ) : ( + + )} +
+ )}
= ({ onOpenSettings, scrollToBo
+ + {/* Issue Picker Dialog */} + setLinkedIssue(issue)} + /> ); }; diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 4860d0cb..254dbb1b 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -1,11 +1,12 @@ import React, { useRef, memo } from 'react'; -import { RiAttachment2, RiCloseLine, RiComputerLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiHardDrive3Line } from '@remixicon/react'; +import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine } from '@remixicon/react'; import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import type { ToolPopupContent } from './message/types'; @@ -98,142 +99,210 @@ export const FileAttachmentButton = memo(() => { multiple className="hidden" onChange={handleFileSelect} - accept="*/*" /> - + + + + + +

Attach files

+
+
); }); +FileAttachmentButton.displayName = 'FileAttachmentButton'; + +interface ImagePreviewProps { + file: AttachedFile; + onRemove: () => void; +} + +const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => { + const isLocalImagePreview = + file.source !== 'server' && + file.mimeType.startsWith('image/') && + typeof file.dataUrl === 'string' && + file.dataUrl.startsWith('data:image/'); + + const imageUrl = isLocalImagePreview ? file.dataUrl : (file.serverPath || ''); + + const extractFilename = (path: string): string => { + const normalized = path.replace(/\\/g, '/'); + const parts = normalized.split('/'); + return parts[parts.length - 1] || path; + }; + + const getFileExtension = (filename: string): string => { + const parts = filename.split('.'); + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; + }; + + const displayName = extractFilename(file.filename); + const extension = getFileExtension(file.filename); + + if (!imageUrl) { + // Fallback to text-only for server images without preview + return ( + + ); + } + + return ( +
+ {displayName} + +
+ ); +}); + +ImagePreview.displayName = 'ImagePreview'; + interface FileChipProps { file: AttachedFile; onRemove: () => void; } const FileChip = memo(({ file, onRemove }: FileChipProps) => { - const getFileIcon = () => { - if (file.mimeType.startsWith('image/')) { - return ; - } - if (file.mimeType.includes('text') || file.mimeType.includes('code')) { - return ; - } - if (file.mimeType.includes('json') || file.mimeType.includes('xml')) { - return ; - } - return ; + const getFileExtension = (filename: string): string => { + const parts = filename.split('.'); + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; }; const formatFileSize = (bytes: number) => { - if (!Number.isFinite(bytes) || bytes <= 0) return '...'; + if (!Number.isFinite(bytes) || bytes <= 0) return ''; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }; const extractFilename = (path: string): string => { - const normalized = path.replace(/\\/g, '/'); - const parts = normalized.split('/'); const filename = parts[parts.length - 1]; - return filename || path; }; const displayName = extractFilename(file.filename); - const isLocalImagePreview = - file.source !== 'server' && - file.mimeType.startsWith('image/') && - typeof file.dataUrl === 'string' && - file.dataUrl.startsWith('data:image/'); - - if (isLocalImagePreview) { - return ( -
- {displayName} - -
- ); - } + const fileSize = formatFileSize(file.size); + const extension = getFileExtension(file.filename); return ( -
-
- {file.source === 'server' ? ( - - ) : ( - - )} -
- {getFileIcon()} -
- - {displayName} - -
- - {formatFileSize(file.size)} + -
+ + + ); }); +FileChip.displayName = 'FileChip'; + export const AttachedFilesList = memo(() => { const { attachedFiles, removeAttachedFile } = useSessionStore(); if (attachedFiles.length === 0) return null; + const images = attachedFiles.filter(f => f.mimeType.startsWith('image/')); + const otherFiles = attachedFiles.filter(f => !f.mimeType.startsWith('image/')); + return ( -
-
- {attachedFiles.map((file) => ( - removeAttachedFile(file.id)} - /> - ))} -
+
+ {/* Images row - inline with previews */} + {images.length > 0 && ( +
+ {images.map((file) => ( + removeAttachedFile(file.id)} + /> + ))} +
+ )} + + {/* Other files row - inline text-only */} + {otherFiles.length > 0 && ( +
+ {otherFiles.map((file) => ( + removeAttachedFile(file.id)} + /> + ))} +
+ )}
); }); +AttachedFilesList.displayName = 'AttachedFilesList'; + interface FilePart { type: string; mime?: string; @@ -257,258 +326,152 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false } const normalized = path.replace(/\\/g, '/'); const parts = normalized.split('/'); - return parts[parts.length - 1] || path; + const filename = parts[parts.length - 1]; + + return filename || path; }; - const getFileIcon = (mimeType?: string) => { - if (!mimeType) return ; - - if (mimeType.startsWith('image/')) { - return ; - } - if (mimeType.includes('text') || mimeType.includes('code')) { - return ; - } - if (mimeType.includes('json') || mimeType.includes('xml')) { - return ; - } - return ; + const formatFileSize = (bytes?: number) => { + if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; - const extractExtension = (value?: string): string => { - if (!value) return ''; - const normalized = value.replace(/\\/g, '/').trim(); - const filename = normalized.split('/').pop() || normalized; - const dotIndex = filename.lastIndexOf('.'); - if (dotIndex < 0) return ''; - return filename.slice(dotIndex).toLowerCase(); - }; - - const isMermaidMimeType = (mimeType?: string): boolean => { - if (!mimeType) return false; - const normalized = mimeType.toLowerCase(); - return normalized === 'text/vnd.mermaid' - || normalized === 'application/vnd.mermaid' - || normalized === 'text/x-mermaid' - || normalized === 'application/x-mermaid'; - }; - - const isGenericTextOrUnknownMime = (mimeType?: string): boolean => { - if (!mimeType) return true; - const normalized = mimeType.toLowerCase(); - return normalized === 'text/plain' || normalized === 'application/octet-stream'; - }; - - const isExplicitBinaryMime = (mimeType?: string): boolean => { - if (!mimeType) return false; - const normalized = mimeType.toLowerCase(); - return normalized.startsWith('image/') - || normalized.startsWith('video/') - || normalized.startsWith('audio/') - || normalized === 'application/pdf'; - }; - - const isMermaidFile = (file: FilePart): boolean => { - const normalizedMime = file.mime?.toLowerCase(); - if (isMermaidMimeType(normalizedMime)) { - return true; - } - - if (isExplicitBinaryMime(normalizedMime)) { - return false; - } - - const extension = extractExtension(file.filename || file.url); - const isMermaidExtension = extension === '.mmd' || extension === '.mermaid'; - return isMermaidExtension && isGenericTextOrUnknownMime(normalizedMime); - }; - - const imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url); - const mermaidFiles = fileItems.filter((file) => isMermaidFile(file) && file.url); - const otherFiles = fileItems.filter((file) => { - if (file.mime?.startsWith('image/')) { - return false; - } - return !isMermaidFile(file); - }); - - const imageGallery = React.useMemo( - () => - imageFiles.flatMap((file) => { - if (!file.url) return []; - const filename = extractFilename(file.filename) || 'Image'; - return [{ - url: file.url, - mimeType: file.mime, - filename, - size: file.size, - }]; - }), - [imageFiles] - ); - - const handleImageClick = React.useCallback((index: number) => { - if (!onShowPopup) { - return; - } - - const file = imageGallery[index]; - if (!file?.url) return; - - const filename = file.filename || 'Image'; - - const popupPayload: ToolPopupContent = { - open: true, - title: filename, - content: '', - metadata: { - tool: 'image-preview', - filename, - mime: file.mimeType, - size: file.size, - }, - image: { - url: file.url, - mimeType: file.mimeType, - filename, - size: file.size, - gallery: imageGallery, - index, - }, - }; - - onShowPopup(popupPayload); - }, [imageGallery, onShowPopup]); - - const handleMermaidClick = React.useCallback((file: FilePart) => { - if (!onShowPopup || !file.url) { - return; - } - - const filename = extractFilename(file.filename) || 'Diagram'; - onShowPopup({ - open: true, - title: filename, - content: '', - metadata: { - tool: 'mermaid-preview', - filename, - mime: file.mime, - size: file.size, - }, - mermaid: { - url: file.url, - mimeType: file.mime, - filename, - }, - }); - }, [onShowPopup]); - if (fileItems.length === 0) return null; return ( -
- {} - {otherFiles.length > 0 && ( -
- {otherFiles.map((file, index) => ( +
+ {fileItems.map((file, index) => { + const fileName = extractFilename(file.filename || file.url); + const isImage = file.mime?.startsWith('image/'); + const sizeText = formatFileSize(file.size); + + if (isImage && file.url) { + return (
- {getFileIcon(file.mime)} -
- - {extractFilename(file.filename)} - + {fileName} +
+
+

{fileName}

+ {sizeText &&

{sizeText}

}
- ))} -
- )} + ); + } - {mermaidFiles.length > 0 && ( -
- {mermaidFiles.map((file, index) => { - const filename = extractFilename(file.filename) || 'Diagram'; - - return ( + return ( + + - ); - })} -
- )} + + +

{fileName}{sizeText ? ` (${sizeText})` : ''}

+
+ + ); + })} +
+ ); +}); - {} - {imageFiles.length > 0 && ( -
-
- {imageFiles.map((file, index) => { - const filename = extractFilename(file.filename) || 'Image'; +MessageFilesDisplay.displayName = 'MessageFilesDisplay'; - return ( - - - - - - {filename} - - - ); +interface ImageGalleryProps { + urls: string[]; + caption?: string; + onShowPopup?: (content: ToolPopupContent) => void; +} + +export const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryProps) => { + if (urls.length === 0) return null; + + const getGridCols = () => { + if (urls.length === 1) return 'grid-cols-1'; + if (urls.length === 2) return 'grid-cols-2'; + if (urls.length <= 4) return 'grid-cols-2'; + return 'grid-cols-3'; + }; + + return ( +
+
+ {urls.map((url, index) => ( +
-
+ className="relative aspect-square rounded-lg border border-border/40 bg-muted/10 overflow-hidden group" + > + {caption +
+ + ))} +
+ {caption && ( +

{caption}

)}
); }); + +ImageGallery.displayName = 'ImageGallery'; diff --git a/packages/ui/src/components/chat/QueuedMessageChips.tsx b/packages/ui/src/components/chat/QueuedMessageChips.tsx index 6fabcb02..b36ca389 100644 --- a/packages/ui/src/components/chat/QueuedMessageChips.tsx +++ b/packages/ui/src/components/chat/QueuedMessageChips.tsx @@ -17,7 +17,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi const firstLine = React.useMemo(() => { const lines = message.content.split('\n'); const first = lines[0] || ''; - const maxLength = 50; + const maxLength = 100; if (first.length > maxLength) { return first.substring(0, maxLength) + '...'; } @@ -27,32 +27,34 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi const attachmentCount = message.attachments?.length ?? 0; return ( -
- - - -
+ + + ); }); @@ -98,17 +100,15 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro } return ( -
-
- {queuedMessages.map((message) => ( - - ))} -
+
+ {queuedMessages.map((message) => ( + + ))}
); }); diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 18e421e5..d5f47bbc 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -714,7 +714,7 @@ export const MainLayout: React.FC = () => { >
- +
diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index 6fa182bb..1b7d7b82 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -39,7 +39,7 @@ export const RightSidebarTabs: React.FC = () => {
- {rightSidebarTab === 'git' ? : } + {rightSidebarTab === 'git' ? : }
); diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx index 4af506a0..58246704 100644 --- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx @@ -1,16 +1,13 @@ import React from 'react'; -import { RiInformationLine } from '@remixicon/react'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ModelSelector } from '@/components/sections/agents/ModelSelector'; import { AgentSelector } from '@/components/sections/commands/AgentSelector'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; import { updateDesktopSettings } from '@/lib/persistence'; -import { isVSCodeRuntime } from '@/lib/desktop'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { getModifierLabel, cn } from '@/lib/utils'; +import { cn } from '@/lib/utils'; const getDisplayModel = ( storedModel: string | undefined @@ -33,8 +30,6 @@ export const DefaultsSettings: React.FC = () => { const setSettingsDefaultModel = useConfigStore((state) => state.setSettingsDefaultModel); const setSettingsDefaultVariant = useConfigStore((state) => state.setSettingsDefaultVariant); const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent); - const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); - const setSettingsAutoCreateWorktree = useConfigStore((state) => state.setSettingsAutoCreateWorktree); const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const providers = useConfigStore((state) => state.providers); @@ -45,7 +40,6 @@ export const DefaultsSettings: React.FC = () => { const [isLoading, setIsLoading] = React.useState(true); const parsedModel = React.useMemo(() => getDisplayModel(defaultModel), [defaultModel]); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); React.useEffect(() => { const loadSettings = async () => { @@ -210,18 +204,6 @@ export const DefaultsSettings: React.FC = () => { } }, [defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]); - const handleAutoWorktreeChange = React.useCallback( - async (enabled: boolean) => { - setSettingsAutoCreateWorktree(enabled); - try { - await updateDesktopSettings({ autoCreateWorktree: enabled }); - } catch (error) { - console.warn('Failed to save auto create worktree setting:', error); - } - }, - [setSettingsAutoCreateWorktree] - ); - if (isLoading) { return null; } @@ -309,46 +291,6 @@ export const DefaultsSettings: React.FC = () => { Show Deletion Dialog
- {!isVSCode && ( -
{ - void handleAutoWorktreeChange(!settingsAutoCreateWorktree); - }} - onKeyDown={(event) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - void handleAutoWorktreeChange(!settingsAutoCreateWorktree); - } - }} - > - { - void handleAutoWorktreeChange(checked); - }} - ariaLabel="Always create worktree" - /> -
-
- Always Create Worktree - - - - - - {settingsAutoCreateWorktree - ? `New session (Worktree): ${getModifierLabel()}+N\nStandard: Shift+${getModifierLabel()}+N` - : `New session (Standard): ${getModifierLabel()}+N\nWorktree: Shift+${getModifierLabel()}+N`} - - -
-
-
- )}
); diff --git a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx new file mode 100644 index 00000000..36428906 --- /dev/null +++ b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx @@ -0,0 +1,618 @@ +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 { Checkbox } from '@/components/ui/checkbox'; +import { + RiGithubLine, + RiLoader4Line, + RiSearchLine, + RiErrorWarningLine, + RiCheckLine, + RiGitPullRequestLine, + RiGitBranchLine, + RiCloseLine, +} from '@remixicon/react'; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager'; +import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import type { + GitHubIssue, + GitHubIssueSummary, + GitHubPullRequestSummary, +} from '@/lib/api/types'; +import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; + +type GitHubTab = 'issues' | 'prs'; + +interface GitHubIntegrationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (result: { + type: 'issue' | 'pr'; + item: GitHubIssue | GitHubPullRequestSummary; + includeDiff?: boolean; + } | null) => void; +} + +interface ValidationResult { + isValid: boolean; + error: string | null; +} + +export function GitHubIntegrationDialog({ + open, + onOpenChange, + onSelect, +}: GitHubIntegrationDialogProps) { + const isMobile = useUIStore((state) => state.isMobile); + const { github } = useRuntimeAPIs(); + const githubAuthStatus = useGitHubAuthStore((state) => state.status); + const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + 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]); + + // State + const [activeTab, setActiveTab] = React.useState('issues'); + const [searchQuery, setSearchQuery] = React.useState(''); + const [issues, setIssues] = React.useState([]); + const [prs, setPrs] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [loadingMore, setLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + const [selectedIssue, setSelectedIssue] = React.useState(null); + const [selectedPr, setSelectedPr] = React.useState(null); + const [includeDiff, setIncludeDiff] = React.useState(false); + const [validations, setValidations] = React.useState>(new Map()); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + + // Load GitHub data + const loadData = React.useCallback(async () => { + if (!projectDirectory || !github) return; + if (githubAuthChecked && githubAuthStatus?.connected === false) return; + + setLoading(true); + setError(null); + setPage(1); + setHasMore(false); + + try { + if (activeTab === 'issues' && github.issuesList) { + const result = await github.issuesList(projectDirectory, { page: 1 }); + if (result.connected === false) { + setError('GitHub not connected'); + setIssues([]); + } else { + setIssues(result.issues ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } else if (activeTab === 'prs' && github.prsList) { + const result = await github.prsList(projectDirectory, { page: 1 }); + if (result.connected === false) { + setError('GitHub not connected'); + setPrs([]); + } else { + setPrs(result.prs ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load data'); + } finally { + setLoading(false); + } + }, [projectDirectory, github, githubAuthChecked, githubAuthStatus, activeTab]); + + // Load more data + const loadMore = React.useCallback(async () => { + if (!projectDirectory || !github) return; + if (loading || loadingMore) return; + if (!hasMore) return; + + setLoadingMore(true); + + try { + const nextPage = page + 1; + + if (activeTab === 'issues' && github.issuesList) { + const result = await github.issuesList(projectDirectory, { page: nextPage }); + if (result.connected !== false) { + setIssues(prev => [...prev, ...(result.issues ?? [])]); + setPage(result.page ?? nextPage); + setHasMore(Boolean(result.hasMore)); + } + } else if (activeTab === 'prs' && github.prsList) { + const result = await github.prsList(projectDirectory, { page: nextPage }); + if (result.connected !== false) { + setPrs(prev => [...prev, ...(result.prs ?? [])]); + setPage(result.page ?? nextPage); + setHasMore(Boolean(result.hasMore)); + } + } + } catch { + // Silently fail on load more errors + } finally { + setLoadingMore(false); + } + }, [projectDirectory, github, activeTab, page, hasMore, loading, loadingMore]); + + // Reset state when dialog opens/closes + React.useEffect(() => { + if (!open) { + setActiveTab('issues'); + setSearchQuery(''); + setIssues([]); + setPrs([]); + setSelectedIssue(null); + setSelectedPr(null); + setIncludeDiff(false); + setError(null); + setValidations(new Map()); + setPage(1); + setHasMore(false); + return; + } + + void loadData(); + }, [open, loadData]); + + // Validate branches for worktree creation + const validateBranch = React.useCallback(async (branchName: string) => { + if (!projectRef || !branchName) return; + + // Check cache first + if (validations.has(branchName)) return; + + try { + const result = await validateWorktreeCreate(projectRef, { + mode: 'new', + branchName, + worktreeName: branchName, + }); + + const isBlocked = result.errors.some( + (entry) => entry.code === 'branch_in_use' || entry.code === 'branch_exists' + ); + + setValidations(prev => new Map(prev).set(branchName, { + isValid: !isBlocked, + error: isBlocked ? 'Branch is already checked out in a worktree' : null, + })); + } catch { + setValidations(prev => new Map(prev).set(branchName, { + isValid: false, + error: 'Validation failed', + })); + } + }, [projectRef, validations]); + + // Validate PR branches when loaded + React.useEffect(() => { + if (!open || activeTab !== 'prs') return; + + prs.forEach(pr => { + if (pr.head) { + void validateBranch(pr.head); + } + }); + }, [open, activeTab, prs, validateBranch]); + + // Filtered results + const filteredIssues = React.useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return issues; + return issues.filter(issue => { + if (String(issue.number) === q.replace(/^#/, '')) return true; + return issue.title.toLowerCase().includes(q); + }); + }, [issues, searchQuery]); + + const filteredPrs = React.useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return prs; + return prs.filter(pr => { + if (String(pr.number) === q.replace(/^#/, '')) return true; + return pr.title.toLowerCase().includes(q); + }); + }, [prs, searchQuery]); + + // GitHub connection check + const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true; + + const openGitHubSettings = () => { + setSettingsPage('github'); + setSettingsDialogOpen(true); + }; + + // Handle selection + const handleSelectIssue = (issue: GitHubIssueSummary) => { + setSelectedIssue(issue as GitHubIssue); + setSelectedPr(null); + }; + + const handleSelectPr = (pr: GitHubPullRequestSummary) => { + setSelectedPr(pr); + setSelectedIssue(null); + }; + + const handleConfirm = () => { + if (selectedIssue) { + onSelect({ + type: 'issue', + item: selectedIssue, + }); + } else if (selectedPr) { + onSelect({ + type: 'pr', + item: selectedPr, + includeDiff, + }); + } + onOpenChange(false); + }; + + const handleClear = () => { + setSelectedIssue(null); + setSelectedPr(null); + setIncludeDiff(false); + }; + + // Check if selection is valid + const canConfirm = selectedIssue || (selectedPr && validations.get(selectedPr.head ?? '')?.isValid !== false); + + // Check if PR is blocked + const isPrBlocked = (pr: GitHubPullRequestSummary): boolean => { + if (!pr.head) return true; + const validation = validations.get(pr.head); + return validation?.isValid === false; + }; + + // Content for the dialog (shared between mobile and desktop) + const dialogContent = ( + <> + {!isGitHubConnected ? ( +
+ +
+

Connect to GitHub

+

+ Link issues or pull requests to auto-fill worktree details +

+
+ +
+ ) : ( + <> + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder={activeTab === 'issues' ? "Search issues or enter #123..." : "Search PRs or enter #456..."} + className="h-8 pl-9" + /> +
+ + {/* List Content */} +
+
+ {/* Loading */} + {loading && ( +
+ +
+ )} + + {/* Error */} + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Issues List */} + {!loading && !error && activeTab === 'issues' && ( +
+ {filteredIssues.length > 0 ? ( + filteredIssues.map(issue => ( + + )) + ) : ( +
+ No issues found +
+ )} + + {hasMore && !loadingMore && ( +
+ +
+ )} + {loadingMore && ( +
+ +
+ )} +
+ )} + + {/* PRs List */} + {!loading && !error && activeTab === 'prs' && ( +
+ {filteredPrs.length > 0 ? ( + filteredPrs.map(pr => { + const blocked = isPrBlocked(pr); + const validation = pr.head ? validations.get(pr.head) : undefined; + + return ( + + ); + }) + ) : ( +
+ No pull requests found +
+ )} + + {hasMore && !loadingMore && ( +
+ +
+ )} + {loadingMore && ( +
+ +
+ )} +
+ )} +
+
+ + )} + + ); + + // Footer content + const footerContent = ( +
+ {/* Left side: Selected Item / Checkbox */} +
+ {/* Selected Issue/PR display - hidden on mobile (shown in header instead) */} + {!isMobile && (selectedIssue || selectedPr) && ( +
+ + + {selectedIssue ? `Issue #${selectedIssue.number}` : `PR #${selectedPr?.number}`} + + +
+ )} + + {/* Include Diff Checkbox - only show when PR tab is active and PR is selected */} + {activeTab === 'prs' && selectedPr && ( + + )} +
+ + {/* Right side: Buttons */} +
+ + +
+
+ ); + + return ( + <> + {isMobile ? ( + onOpenChange(false)} + footer={!isGitHubConnected ? undefined : footerContent} + renderHeader={(closeButton) => ( +
+
+

Select from GitHub

+ {closeButton} +
+ {/* Tabs - using SortableTabsStrip */} +
+ }, + { id: 'prs', label: 'Pull Requests', icon: }, + ]} + activeId={activeTab} + onSelect={(id) => { + setActiveTab(id as GitHubTab); + setSearchQuery(''); + }} + variant="active-pill" + layoutMode="fit" + /> +
+ + {/* Selected Item Inline Display */} + {(selectedIssue || selectedPr) && ( +
+ + + {selectedIssue ? `Issue #${selectedIssue.number}` : `PR #${selectedPr?.number}`} + + +
+ )} +
+ )} + > + {dialogContent} +
+ ) : ( + + + +
+ + + Select from GitHub + + + {/* Tabs - using SortableTabsStrip */} +
+ }, + { id: 'prs', label: 'Pull Requests', icon: }, + ]} + activeId={activeTab} + onSelect={(id) => { + setActiveTab(id as GitHubTab); + setSearchQuery(''); + }} + variant="active-pill" + layoutMode="fit" + /> +
+
+
+ + {dialogContent} + + {/* Footer */} + + {footerContent} + +
+
+ )} + + ); +} diff --git a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx index 1a0cef46..93970a5f 100644 --- a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx @@ -66,9 +66,13 @@ const buildIssueContextText = (args: { export function GitHubIssuePickerDialog({ open, onOpenChange, + mode = 'createSession', + onSelect, }: { open: boolean; onOpenChange: (open: boolean) => void; + mode?: 'createSession' | 'select'; + onSelect?: (issue: { number: number; title: string; url: string; contextText: string; author?: { login: string; avatarUrl?: string } }) => void; }) { const { github } = useRuntimeAPIs(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); @@ -259,6 +263,68 @@ export function GitHubIssuePickerDialog({ }, []); const startSession = React.useCallback(async (issueNumber: number) => { + if (mode === 'select') { + // In select mode, fetch full issue details and return via onSelect + if (!projectDirectory) { + toast.error('No active project'); + return; + } + if (!github?.issueGet || !github?.issueComments) { + toast.error('GitHub runtime API unavailable'); + return; + } + if (startingIssueNumber) return; + setStartingIssueNumber(issueNumber); + try { + const issueRes = await github.issueGet(projectDirectory, issueNumber); + if (issueRes.connected === false) { + toast.error('GitHub not connected'); + return; + } + if (!issueRes.repo) { + toast.error('Repo not resolvable', { + description: 'origin remote must be a GitHub URL', + }); + return; + } + const issue = issueRes.issue; + if (!issue) { + toast.error('Issue not found'); + return; + } + + const commentsRes = await github.issueComments(projectDirectory, issueNumber); + if (commentsRes.connected === false) { + toast.error('GitHub not connected'); + return; + } + const comments = commentsRes.comments ?? []; + + // Build full context text like in createSession mode + const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); + + if (onSelect) { + onSelect({ + number: issue.number, + title: issue.title, + url: issue.url, + contextText, + author: issue.author ? { + login: issue.author.login, + avatarUrl: issue.author.avatarUrl, + } : undefined, + }); + } + onOpenChange(false); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to load issue details', { description: message }); + } finally { + setStartingIssueNumber(null); + } + return; + } + if (!projectDirectory) { toast.error('No active project'); return; @@ -444,7 +510,7 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence } finally { setStartingIssueNumber(null); } - }, [createInWorktree, github, onOpenChange, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]); + }, [createInWorktree, github, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]); return ( @@ -452,10 +518,12 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence - New Session From GitHub Issue + {mode === 'select' ? 'Link GitHub Issue' : 'New Session From GitHub Issue'} - Seeds a new session with hidden issue context (title/body/labels/comments). + {mode === 'select' + ? 'Select an issue to link to this session.' + : 'Seeds a new session with hidden issue context (title/body/labels/comments).'} @@ -583,6 +651,7 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence ) : null}
+ {mode !== 'select' && (

Actions

@@ -634,6 +703,7 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
+ )} ); diff --git a/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx b/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx deleted file mode 100644 index 491db391..00000000 --- a/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx +++ /dev/null @@ -1,881 +0,0 @@ -import React from 'react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { toast } from '@/components/ui'; -import { - RiCheckboxBlankLine, - RiCheckboxLine, - RiExternalLinkLine, - RiGitPullRequestLine, - RiLoader4Line, - RiSearchLine, -} from '@remixicon/react'; -import { cn } from '@/lib/utils'; -import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { useMessageStore } from '@/stores/messageStore'; -import { useContextStore } from '@/stores/contextStore'; -import { useUIStore } from '@/stores/useUIStore'; -import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; -import { opencodeClient } from '@/lib/opencode/client'; -import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator'; -import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager'; -import { getRemotes } from '@/lib/gitApi'; -import type { - GitHubPullRequestContextResult, - GitHubPullRequestHeadRepo, - GitHubPullRequestSummary, - GitHubPullRequestsListResult, - GitRemote, -} from '@/lib/api/types'; - -const parsePullRequestNumber = (value: string): number | null => { - const trimmed = value.trim(); - if (!trimmed) return null; - - const urlMatch = trimmed.match(/\/pull\/(\d+)(?:\b|\/|$)/i); - if (urlMatch) { - const parsed = Number(urlMatch[1]); - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; - } - - const hashMatch = trimmed.match(/^#?(\d+)$/); - if (hashMatch) { - const parsed = Number(hashMatch[1]); - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; - } - - return null; -}; - -const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => { - return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; -}; - -const sanitizeGitRemoteName = (value: string): string => { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 64); -}; - -const looksLikeSshUrl = (value: string): boolean => { - const trimmed = value.trim(); - return /^git@/i.test(trimmed) || /^ssh:\/\//i.test(trimmed); -}; - -const resolvePreferredPushTransport = (remotes: GitRemote[]): 'ssh' | 'https' => { - const candidates = remotes.length > 0 - ? remotes - : []; - const preferredByName = candidates.find((remote) => remote.name === 'origin') - || candidates.find((remote) => remote.name === 'upstream') - || candidates[0]; - - const sample = preferredByName?.pushUrl || preferredByName?.fetchUrl || ''; - return looksLikeSshUrl(sample) ? 'ssh' : 'https'; -}; - -const resolveForkRemoteUrl = (headRepo: GitHubPullRequestHeadRepo | null | undefined, preferredTransport: 'ssh' | 'https'): string => { - if (!headRepo) { - return ''; - } - - if (preferredTransport === 'ssh') { - return headRepo.sshUrl || headRepo.cloneUrl || headRepo.url || ''; - } - - return headRepo.cloneUrl || headRepo.sshUrl || headRepo.url || ''; -}; - -export function GitHubPullRequestPickerDialog({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const { github } = useRuntimeAPIs(); - const githubAuthStatus = useGitHubAuthStore((state) => state.status); - const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); - const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); - const setSettingsPage = useUIStore((state) => state.setSettingsPage); - const activeProject = useProjectsStore((state) => state.getActiveProject()); - - const projectDirectory = activeProject?.path ?? null; - const projectRef = React.useMemo(() => { - if (!projectDirectory) { - return null; - } - return { - id: activeProject?.id ?? `path:${projectDirectory}`, - path: projectDirectory, - }; - }, [activeProject?.id, projectDirectory]); - - const [query, setQuery] = React.useState(''); - const [createInWorktree, setCreateInWorktree] = React.useState(false); - const [includeDiff, setIncludeDiff] = React.useState(false); - const [result, setResult] = React.useState(null); - const [prs, setPrs] = React.useState([]); - const [page, setPage] = React.useState(1); - const [hasMore, setHasMore] = React.useState(false); - const [startingNumber, setStartingNumber] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(false); - const [isLoadingMore, setIsLoadingMore] = React.useState(false); - const [existingBranchHeads, setExistingBranchHeads] = React.useState>(new Map()); - const [projectRemotes, setProjectRemotes] = React.useState([]); - const [error, setError] = React.useState(null); - - const preferredPushTransport = React.useMemo( - () => resolvePreferredPushTransport(projectRemotes), - [projectRemotes] - ); - - const refresh = React.useCallback(async () => { - if (!projectDirectory) { - setResult(null); - setError('No active project'); - return; - } - if (githubAuthChecked && githubAuthStatus?.connected === false) { - setResult({ connected: false }); - setPrs([]); - setHasMore(false); - setPage(1); - setError(null); - return; - } - if (!github?.prsList) { - setResult(null); - setError('GitHub runtime API unavailable'); - return; - } - - setIsLoading(true); - setError(null); - try { - const next = await github.prsList(projectDirectory, { page: 1 }); - setResult(next); - setPrs(next.prs ?? []); - setPage(next.page ?? 1); - setHasMore(Boolean(next.hasMore)); - if (next.connected === false) { - setError(null); - } - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setIsLoading(false); - } - }, [github, githubAuthChecked, githubAuthStatus, projectDirectory]); - - const loadMore = React.useCallback(async () => { - if (!projectDirectory) return; - if (!github?.prsList) return; - if (isLoadingMore || isLoading) return; - if (!hasMore) return; - - setIsLoadingMore(true); - try { - const nextPage = page + 1; - const next = await github.prsList(projectDirectory, { page: nextPage }); - setResult(next); - setPrs((prev) => [...prev, ...(next.prs ?? [])]); - setPage(next.page ?? nextPage); - setHasMore(Boolean(next.hasMore)); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - toast.error('Failed to load more PRs', { description: message }); - } finally { - setIsLoadingMore(false); - } - }, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]); - - React.useEffect(() => { - if (!open) { - setQuery(''); - setCreateInWorktree(false); - setIncludeDiff(false); - setResult(null); - setPrs([]); - setPage(1); - setHasMore(false); - setStartingNumber(null); - setIsLoading(false); - setError(null); - setExistingBranchHeads(new Map()); - setProjectRemotes([]); - return; - } - void refresh(); - }, [open, refresh]); - - React.useEffect(() => { - if (!open || !projectDirectory) { - return; - } - - let cancelled = false; - void getRemotes(projectDirectory) - .then((remotes) => { - if (!cancelled) { - setProjectRemotes(Array.isArray(remotes) ? remotes : []); - } - }) - .catch(() => { - if (!cancelled) { - setProjectRemotes([]); - } - }); - - return () => { - cancelled = true; - }; - }, [open, projectDirectory]); - - const checkLocalBranchExists = React.useCallback(async (heads: string[]) => { - if (!projectRef) return; - const unique = Array.from(new Set(heads.map((h) => (h || '').trim()).filter(Boolean))); - if (unique.length === 0) return; - - // Only check unknown heads (optimistic enable; disable after result arrives). - const unknown = unique.filter((h) => !existingBranchHeads.has(h)); - if (unknown.length === 0) return; - - const results = await Promise.all( - unknown.map(async (head) => { - const validation = await validateWorktreeCreate(projectRef, { - mode: 'new', - branchName: head, - worktreeName: head, - }).catch(() => ({ ok: false, errors: [{ code: 'validation_failed', message: 'Validation failed' }] })); - - const blockedByBranch = validation.errors.some((entry) => - entry.code === 'branch_in_use' || entry.code === 'branch_exists' - ); - return { head, blocked: blockedByBranch }; - }) - ); - - setExistingBranchHeads((prev) => { - const next = new Map(prev); - for (const item of results) { - next.set(item.head, item.blocked); - } - return next; - }); - }, [projectRef, existingBranchHeads]); - - React.useEffect(() => { - if (!open) return; - if (!projectRef) return; - if (!createInWorktree) return; - void checkLocalBranchExists(prs.map((pr) => pr.head)); - }, [open, projectRef, createInWorktree, prs, checkLocalBranchExists]); - - React.useEffect(() => { - if (!open) return; - if (githubAuthChecked && githubAuthStatus?.connected === false) { - setResult({ connected: false }); - setPrs([]); - setHasMore(false); - setPage(1); - setError(null); - } - }, [githubAuthChecked, githubAuthStatus, open]); - - const connected = githubAuthChecked ? result?.connected !== false : true; - const repoUrl = result?.repo?.url ?? null; - - const openGitHubSettings = React.useCallback(() => { - setSettingsPage('github'); - setSettingsDialogOpen(true); - }, [setSettingsDialogOpen, setSettingsPage]); - - const filtered = React.useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return prs; - return prs.filter((pr) => { - if (String(pr.number) === q.replace(/^#/, '')) return true; - return pr.title.toLowerCase().includes(q); - }); - }, [prs, query]); - - const isPrDisabledForWorktree = React.useCallback((pr: GitHubPullRequestSummary): boolean => { - if (!createInWorktree) return false; - const head = pr.head?.trim(); - if (!head) return true; - const exists = existingBranchHeads.get(head); - // Optimistic: treat unknown as enabled. - return exists === true; - }, [createInWorktree, existingBranchHeads]); - - const directNumber = React.useMemo(() => parsePullRequestNumber(query), [query]); - - 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 parts = settingsDefaultModel.split('/'); - if (parts.length !== 2) return null; - const [providerID, modelID] = parts; - if (!providerID || !modelID) return null; - - 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; - if (!settingsDefaultVariant) return undefined; - - const provider = configState.providers.find((p) => p.id === providerID); - const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as - | { variants?: Record } - | undefined; - const variants = model?.variants; - if (!variants) return undefined; - if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return undefined; - return settingsDefaultVariant; - }, []); - - const createPrWorktreeSession = React.useCallback(async ( - baseRepo: GitHubPullRequestsListResult['repo'] | undefined, - pr: GitHubPullRequestSummary, - ): Promise<{ id: string } | null> => { - if (!projectDirectory || !projectRef) return null; - const headRef = pr.head; - const headRepo = pr.headRepo; - if (!headRef) { - throw new Error('PR head ref missing'); - } - - const isFork = Boolean( - headRepo?.owner && headRepo?.repo && - baseRepo?.owner && baseRepo?.repo && - (headRepo.owner !== baseRepo.owner || headRepo.repo !== baseRepo.repo) - ); - - const preferredBranch = pr.head; - const remoteName = isFork - ? (sanitizeGitRemoteName(`pr-${headRepo?.owner || 'fork'}-${headRepo?.repo || ''}`) || `pr-${pr.number}`) - : 'origin'; - const remoteUrl = isFork ? resolveForkRemoteUrl(headRepo, preferredPushTransport) : ''; - - if (isFork && !remoteUrl) { - throw new Error('PR fork remote URL missing'); - } - - const startRef = `${remoteName}/${preferredBranch}`; - const validation = await validateWorktreeCreate(projectRef, { - mode: 'new', - branchName: preferredBranch, - worktreeName: preferredBranch, - startRef, - setUpstream: true, - upstreamRemote: remoteName, - upstreamBranch: preferredBranch, - ensureRemoteName: isFork ? remoteName : undefined, - ensureRemoteUrl: isFork ? remoteUrl : undefined, - }); - - if (!validation.ok) { - const branchError = validation.errors.find((entry) => - entry.code === 'branch_in_use' || entry.code === 'branch_exists' - ); - if (branchError) { - throw new Error(branchError.message); - } - throw new Error(validation.errors[0]?.message || 'PR worktree validation failed'); - } - - // Prevent clobbering/removing an existing local branch when using PR worktree mode. - if (existingBranchHeads.get(preferredBranch) === true) { - throw new Error(`Local branch already exists: ${preferredBranch}`); - } - - const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, startRef, { - kind: 'pr', - worktreeName: preferredBranch, - setUpstream: true, - upstreamRemote: remoteName, - upstreamBranch: preferredBranch, - ensureRemoteName: isFork ? remoteName : undefined, - ensureRemoteUrl: isFork ? remoteUrl : undefined, - createdFromBranch: pr.base, - }); - if (!session?.id) { - throw new Error('Failed to create PR worktree session'); - } - - const meta = useSessionStore.getState().worktreeMetadata.get(session.id); - const worktreeDir = meta?.path; - if (!worktreeDir) { - throw new Error('Worktree directory not found'); - } - - // Update stored metadata for better UX + reintegration target. - useSessionStore.getState().setWorktreeMetadata(session.id, { - ...(meta || { path: worktreeDir, projectDirectory, branch: preferredBranch, label: preferredBranch }), - path: worktreeDir, - projectDirectory, - branch: preferredBranch, - label: preferredBranch, - createdFromBranch: pr.base, - kind: 'pr' as const, - }); - - return { id: session.id }; - }, [projectDirectory, projectRef, existingBranchHeads, preferredPushTransport]); - - const startSession = React.useCallback(async (number: number) => { - if (!projectDirectory) { - toast.error('No active project'); - return; - } - if (!github?.prContext) { - toast.error('GitHub runtime API unavailable'); - return; - } - if (startingNumber) return; - setStartingNumber(number); - try { - const prContext = await github.prContext(projectDirectory, number, { includeDiff, includeCheckDetails: false }); - if (prContext.connected === false) { - toast.error('GitHub not connected'); - return; - } - if (!prContext.repo) { - toast.error('Repo not resolvable', { description: 'origin remote must be a GitHub URL' }); - return; - } - if (!prContext.pr) { - toast.error('PR not found'); - return; - } - - const pr = prContext.pr; - const sessionTitle = `#${pr.number} ${pr.title}`.trim(); - - const sessionId = await (async () => { - if (createInWorktree) { - const worktreeSession = await createPrWorktreeSession(prContext.repo, pr); - return worktreeSession?.id || null; - } - const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null); - return session?.id || null; - })(); - - if (!sessionId) { - throw new Error('Failed to create session'); - } - - void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); - try { - useSessionStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); - } catch { - // ignore - } - - onOpenChange(false); - - const configState = useConfigStore.getState(); - const lastUsedProvider = useMessageStore.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('No model selected'); - return; - } - - const variant = resolveDefaultVariant(providerID, modelID); - try { - useContextStore.getState().saveSessionModelSelection(sessionId, providerID, modelID); - } catch { - // ignore - } - - if (agentName) { - try { - configState.setAgent(agentName); - } catch { - // ignore - } - try { - useContextStore.getState().saveSessionAgentSelection(sessionId, agentName); - } catch { - // ignore - } - try { - useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerID, modelID); - } catch { - // ignore - } - if (variant !== undefined) { - try { - configState.setCurrentVariant(variant); - } catch { - // ignore - } - try { - useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerID, modelID, variant); - } catch { - // ignore - } - } - } - - const visiblePromptText = 'Review this pull request using the provided PR context: description, comments, files, diff, checks.'; - const instructionsText = `Before reporting issues: -- First identify the PR intent (what it’s trying to achieve) from title/body/diff, then evaluate whether the implementation matches that intent; call out missing pieces, incorrect behavior vs intent, and scope creep. -- Gather any needed repository context (code, config, docs) to validate assumptions. -- No speculation: if something is unclear or cannot be verified, say what’s missing and ask for it instead of guessing. - -Output rules: -- Start with a 1-2 sentence summary. -- Provide a single concise PR review comment. -- No emojis. No code snippets. No fenced blocks. -- Short inline code identifiers allowed, but no snippets or fenced blocks. -- Reference evidence with file paths and line ranges (e.g., path/to/file.ts:120-138). If exact lines aren’t available, cite the file and say “approx” + why. -- Keep the entire comment under ~300 words. - -Report: -- Must-fix issues (blocking) — brief why and a one-line action each. -- Nice-to-have improvements (optional) — brief why and a one-line action each. - -Quality & safety (general): -- Call out correctness risks, edge cases, performance regressions, security/privacy concerns, and backwards-compatibility risks. -- Call out missing tests/verification steps and suggest the minimal validation needed. -- Note readability/maintainability issues when they materially affect future changes. - -Applicability (only if relevant): -- If changes affect multiple components/targets/environments (e.g., client/server, OSs, deployments), state what is affected vs not, and why. - -Architecture: -- Call out breakages, missing implementations across modules/targets, boundary violations, and cross-cutting concerns (errors, logging/observability, accessibility). - -Precedence: -- If local precedent conflicts with best practices, state it and suggest a follow-up task. - -Do not implement changes until I confirm; end with a short “Next actions” sentence describing the recommended plan. - -Format exactly: -Must-fix: -- — Action: -Nice-to-have: -- — Action: -If no issues, write: -Must-fix: -- None -Nice-to-have: -- None`; - const contextText = buildPullRequestContextText(prContext); - - void opencodeClient.sendMessage({ - id: sessionId, - providerID, - modelID, - agent: agentName, - variant, - text: visiblePromptText, - additionalParts: [ - { text: instructionsText, synthetic: true }, - { text: contextText, synthetic: true }, - ], - }).catch((e) => { - const message = e instanceof Error ? e.message : String(e); - toast.error('Failed to send PR context', { description: message }); - }); - - toast.success('Session created from PR'); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - toast.error(createInWorktree ? 'PR worktree failed' : 'Failed to start session', { description: message }); - } finally { - setStartingNumber(null); - } - }, [ - createInWorktree, - createPrWorktreeSession, - github, - includeDiff, - onOpenChange, - projectDirectory, - resolveDefaultAgentName, - resolveDefaultModelSelection, - resolveDefaultVariant, - startingNumber, - ]); - - return ( - - - - - - New Session From GitHub PR - - - Seeds a new session with hidden PR context (title/body/comments/files/checks). - - - -
- - setQuery(e.target.value)} - className="pl-9 w-full" - /> -
- -
- {!projectDirectory ? ( -
No active project selected.
- ) : null} - - {!github ? ( -
GitHub runtime API unavailable.
- ) : null} - - {isLoading ? ( -
- - Loading pull requests... -
- ) : null} - - {connected === false ? ( -
-
GitHub not connected. Connect your GitHub account in settings.
-
- -
-
- ) : null} - - {error ? ( -
{error}
- ) : null} - - {directNumber && projectDirectory && github && connected ? ( -
void startSession(directNumber)} - > - # -

- Use PR #{directNumber} -

-
- {startingNumber === directNumber ? ( - - ) : null} -
-
- ) : null} - - {filtered.length === 0 && !isLoading && connected && github && projectDirectory ? ( -
{query ? 'No PRs found' : 'No open PRs found'}
- ) : null} - - {filtered.map((pr) => { - const disabledByWorktree = isPrDisabledForWorktree(pr); - - return ( -
{ - if (disabledByWorktree) return; - void startSession(pr.number); - }} - > - #{pr.number} -
-

{pr.title}

- {createInWorktree && disabledByWorktree ? ( -

- PR worktree disabled: branch already exists or is in use ({pr.head}) -

- ) : null} -
-
- {startingNumber === pr.number ? ( - - ) : ( - e.stopPropagation()} - aria-label="Open in GitHub" - > - - - )} -
-
- ); - })} - - {hasMore && connected && projectDirectory && github ? ( -
- -
- ) : null} -
- -
-

Actions

-
-
-
setCreateInWorktree((v) => !v)} - onKeyDown={(e) => { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault(); - setCreateInWorktree((v) => !v); - } - }} - > - - Create in PR worktree -
- -
setIncludeDiff((v) => !v)} - onKeyDown={(e) => { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault(); - setIncludeDiff((v) => !v); - } - }} - > - - Include full diff -
-
- -
-
- {repoUrl ? ( - - ) : null} - -
-
-
- -
- ); -} diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx new file mode 100644 index 00000000..c733a0ae --- /dev/null +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -0,0 +1,1667 @@ +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectLabel, + SelectGroup, + SelectSeparator, +} from '@/components/ui/select'; +import { + RiGitBranchLine, + RiGitRepositoryLine, + RiGithubLine, + RiLoader4Line, + RiRefreshLine, + RiErrorWarningLine, + RiCheckLine, + RiExternalLinkLine, + RiCloseLine, +} from '@remixicon/react'; +import { cn } from '@/lib/utils'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useMessageStore } from '@/stores/messageStore'; +import { useContextStore } from '@/stores/contextStore'; +import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; +import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate'; +import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; +import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; +import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; +import { opencodeClient } from '@/lib/opencode/client'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useGitBranches } from '@/stores/useGitStore'; +import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; +import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import type { + GitHubIssue, + GitHubIssueComment, + GitHubIssuesListResult, + GitHubPullRequestContextResult, + GitHubPullRequestSummary, +} from '@/lib/api/types'; +import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; + +type Mode = 'new-branch' | 'existing-branch'; + +interface ValidationState { + isValidating: boolean; + branchError: string | null; + worktreeError: string | null; + touched: boolean; +} + +// State for New Branch mode +interface NewBranchState { + branchName: string; + worktreeName: string; + isSyncingWorktreeName: boolean; + sourceBranch: string; + linkedIssue: GitHubIssue | null; + linkedPr: GitHubPullRequestSummary | 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 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); +}; + +const LAST_SOURCE_BRANCH_KEY = 'oc:lastWorktreeSourceBranch'; + +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)}`; +}; + +export function NewWorktreeDialog({ + open, + onOpenChange, + onWorktreeCreated, +}: NewWorktreeDialogProps) { + const { github } = useRuntimeAPIs(); + const isMobile = useUIStore((state) => state.isMobile); + const githubAuthStatus = useGitHubAuthStore((state) => state.status); + const githubAuthChecked = useGitHubAuthStore((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('new-branch'); + + // Separate state for each mode (persisted when switching tabs) + const [newBranchState, setNewBranchState] = React.useState({ + branchName: '', + worktreeName: '', + isSyncingWorktreeName: true, + sourceBranch: '', + linkedIssue: null, + linkedPr: null, + includePrDiff: false, + }); + + const [existingBranchState, setExistingBranchState] = React.useState({ + selectedBranch: '', + worktreeName: '', + }); + + // Use cached branches from Git store (instant if already fetched) + const branches = useGitBranches(projectDirectory); + + // 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 = useSessionStore((state) => state.availableWorktreesByProject); + const loadSessions = useSessionStore((state) => state.loadSessions); + const existingWorktreeNames = React.useMemo(() => { + if (!projectDirectory) return new Set(); + 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); + + // Mobile branch picker states + const [existingBranchPickerOpen, setExistingBranchPickerOpen] = React.useState(false); + const [sourceBranchPickerOpen, setSourceBranchPickerOpen] = React.useState(false); + + // Validation state + const [validation, setValidation] = React.useState({ + isValidating: false, + branchError: null, + worktreeError: null, + touched: false, + }); + + // Creation state + const [isCreating, setIsCreating] = React.useState(false); + const [validationAbortController, setValidationAbortController] = React.useState(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 parts = settingsDefaultModel.split('/'); + if (parts.length !== 2) return null; + const [providerID, modelID] = parts; + if (!providerID || !modelID) return null; + + 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; + if (!settingsDefaultVariant) return undefined; + + const provider = configState.providers.find((p) => p.id === providerID); + const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as + | { variants?: Record } + | undefined; + const variants = model?.variants; + if (!variants) return undefined; + if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return undefined; + return settingsDefaultVariant; + }, []); + + const applySessionModelAndAgentDefaults = React.useCallback((args: { + sessionId: string; + providerID: string; + modelID: string; + agentName?: string; + variant?: string; + }) => { + const configState = useConfigStore.getState(); + + try { + useContextStore.getState().saveSessionModelSelection(args.sessionId, args.providerID, args.modelID); + } catch { + // ignore + } + + if (!args.agentName) { + return; + } + + try { + configState.setAgent(args.agentName); + } catch { + // ignore + } + try { + useContextStore.getState().saveSessionAgentSelection(args.sessionId, args.agentName); + } catch { + // ignore + } + try { + useContextStore.getState().saveAgentModelForSession(args.sessionId, args.agentName, args.providerID, args.modelID); + } catch { + // ignore + } + if (args.variant !== undefined) { + try { + configState.setCurrentVariant(args.variant); + } catch { + // ignore + } + try { + useContextStore + .getState() + .saveAgentModelVariantForSession(args.sessionId, args.agentName, args.providerID, args.modelID, args.variant); + } catch { + // ignore + } + } + }, []); + + const sendLinkedContextMessage = React.useCallback(async (args: { + sessionId: string; + issue: GitHubIssue | null; + pr: GitHubPullRequestSummary | null; + includeDiff: boolean; + }) => { + if (!projectDirectory || !github) { + return; + } + + const configState = useConfigStore.getState(); + const lastUsedProvider = useMessageStore.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('No model selected'); + return; + } + + const variant = resolveDefaultVariant(providerID, modelID); + + applySessionModelAndAgentDefaults({ + sessionId: args.sessionId, + providerID, + modelID, + agentName, + variant, + }); + + if (args.issue) { + if (!github.issueGet || !github.issueComments) { + return; + } + + const issueRes = await github.issueGet(projectDirectory, args.issue.number); + 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); + if (commentsRes.connected === false) { + throw new Error('Failed to load issue comments'); + } + + const visiblePromptText = `Review this issue #${args.issue.number} using the provided issue context`; + const instructionsText = `Review this issue using the provided issue context: title, body, labels, assignees, comments, metadata. + +Process: +- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: . +- Gather any needed repository context (code, config, docs) to validate assumptions. +- After gathering, if anything is still unclear or cannot be verified, do not speculate-state what's missing and ask targeted questions. + +Output rules: +- Compact output; pick ONE template below and omit the others. +- No emojis. No code snippets. No fenced blocks. +- Short inline code identifiers allowed. +- Reference evidence with file paths and line ranges when applicable; if exact lines aren't available, cite the file and say "approx" + why. +- Keep the entire response under ~300 words. + +Templates (choose one): +Bug: +- Summary (1-2 sentences) +- Likely cause (max 2) +- Repro/diagnostics needed (max 3) +- Fix approach (max 4 steps) +- Verification (max 3) + +Feature: +- Summary (1-2 sentences) +- Requirements (max 4) +- Unknowns/questions (max 4) +- Proposed plan (max 5 steps) +- Verification (max 3) + +Question/Support: +- Summary (1-2 sentences) +- Answer/guidance (max 6 lines) +- Missing info (max 4) + +Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`; + const contextText = buildIssueContextText({ + repo: issueRes.repo, + issue: issueRes.issue, + comments: commentsRes.comments ?? [], + }); + + await opencodeClient.sendMessage({ + id: args.sessionId, + providerID, + modelID, + agent: agentName, + variant, + text: visiblePromptText, + additionalParts: [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + }); + + toast.success('Session created from issue'); + return; + } + + if (args.pr) { + if (!github.prContext) { + return; + } + + const prContext = await github.prContext(projectDirectory, args.pr.number, { + includeDiff: args.includeDiff, + includeCheckDetails: false, + }); + if (prContext.connected === false || !prContext.repo || !prContext.pr) { + throw new Error('Failed to load PR context'); + } + + const visiblePromptText = `Review this pull request #${args.pr.number} using the provided PR context`; + const instructionsText = `Before reporting issues: +- First identify the PR intent (what it's trying to achieve) from title/body/diff, then evaluate whether the implementation matches that intent; call out missing pieces, incorrect behavior vs intent, and scope creep. +- Gather any needed repository context (code, config, docs) to validate assumptions. +- No speculation: if something is unclear or cannot be verified, say what's missing and ask for it instead of guessing. + +Output rules: +- Start with a 1-2 sentence summary. +- Provide a single concise PR review comment. +- No emojis. No code snippets. No fenced blocks. +- Short inline code identifiers allowed, but no snippets or fenced blocks. +- Reference evidence with file paths and line ranges (e.g., path/to/file.ts:120-138). If exact lines aren't available, cite the file and say "approx" + why. +- Keep the entire comment under ~300 words. + +Report: +- Must-fix issues (blocking)-brief why and a one-line action each. +- Nice-to-have improvements (optional)-brief why and a one-line action each. + +Quality & safety (general): +- Call out correctness risks, edge cases, performance regressions, security/privacy concerns, and backwards-compatibility risks. +- Call out missing tests/verification steps and suggest the minimal validation needed. +- Note readability/maintainability issues when they materially affect future changes. + +Applicability (only if relevant): +- If changes affect multiple components/targets/environments (e.g., client/server, OSs, deployments), state what is affected vs not, and why. + +Architecture: +- Call out breakages, missing implementations across modules/targets, boundary violations, and cross-cutting concerns (errors, logging/observability, accessibility). + +Precedence: +- If local precedent conflicts with best practices, state it and suggest a follow-up task. + +Do not implement changes until I confirm; end with a short "Next actions" sentence describing the recommended plan. + +Format exactly: +Must-fix: +- - - - Action: +Nice-to-have: +- - - - Action: +If no issues, write: +Must-fix: +- None +Nice-to-have: +- None`; + const contextText = buildPullRequestContextText(prContext); + + await opencodeClient.sendMessage({ + id: args.sessionId, + providerID, + modelID, + agent: agentName, + variant, + text: visiblePromptText, + additionalParts: [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + }); + + toast.success('Session created from PR'); + } + }, [ + applySessionModelAndAgentDefaults, + github, + projectDirectory, + resolveDefaultAgentName, + resolveDefaultModelSelection, + resolveDefaultVariant, + ]); + + // Get current state based on mode + const currentState = mode === 'new-branch' ? newBranchState : existingBranchState; + + // Set default source branch when branches become available + React.useEffect(() => { + if (!branches?.all || !projectDirectory) return; + if (newBranchState.sourceBranch) return; // Already set + + const loadDefaultSourceBranch = async () => { + try { + const rootBranch = await getRootBranch(projectDirectory).catch(() => null); + const savedSourceBranch = localStorage.getItem(LAST_SOURCE_BRANCH_KEY); + const defaultSourceBranch = savedSourceBranch && branches.all?.includes(savedSourceBranch) + ? savedSourceBranch + : rootBranch && branches.all?.includes(rootBranch) + ? rootBranch + : branches.all?.includes('main') + ? 'main' + : branches.all?.includes('master') + ? 'master' + : branches.all?.[0] || ''; + + if (defaultSourceBranch) { + setNewBranchState(prev => ({ + ...prev, + sourceBranch: defaultSourceBranch, + })); + } + } catch { + // ignore + } + }; + + void loadDefaultSourceBranch(); + }, [branches, projectDirectory, newBranchState.sourceBranch]); + + // Reset state when dialog opens/closes + React.useEffect(() => { + if (!open) { + setMode('new-branch'); + setNewBranchState({ + branchName: '', + worktreeName: '', + isSyncingWorktreeName: true, + sourceBranch: '', + linkedIssue: null, + linkedPr: null, + includePrDiff: false, + }); + setExistingBranchState({ + selectedBranch: '', + worktreeName: '', + }); + setValidation({ + isValidating: false, + branchError: null, + worktreeError: null, + touched: false, + }); + return; + } + + // Generate unique slug when dialog opens + const uniqueSlug = generateUniqueSlug(); + setNewBranchState(prev => ({ + ...prev, + branchName: uniqueSlug, + worktreeName: uniqueSlug, + isSyncingWorktreeName: true, + })); + }, [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 = 'Branch name is required'; + } + + if (!normalizedWorktree) { + worktreeError = 'Worktree directory is required'; + } + + // Only run server validation if we have values + if (normalizedBranch && normalizedWorktree) { + const result = await validateWorktreeCreate(projectRef, { + mode: mode === 'existing-branch' ? 'existing' : 'new', + branchName: normalizedBranch, + worktreeName: normalizedWorktree, + existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined, + }); + + 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, + existingBranchState.selectedBranch, + currentState.worktreeName, + validation.touched, + validationAbortController, + isCreating, + ]); + + // 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('No active project'); + 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('Branch name is required'); + return; + } + + if (!normalizedWorktree) { + toast.error('Worktree directory is required'); + return; + } + + if (validationAbortController) { + validationAbortController.abort(); + setValidationAbortController(null); + } + + setValidation((prev) => ({ + ...prev, + isValidating: false, + branchError: null, + worktreeError: null, + })); + + setIsCreating(true); + + try { + const setupCommands = await getWorktreeSetupCommands(projectRef); + + // Determine source branch - use PR base if PR is selected, otherwise use selected source branch + const effectiveSourceBranch = newBranchState.linkedPr + ? newBranchState.linkedPr.base + : newBranchState.sourceBranch; + + const args = { + 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, + ...(effectiveSourceBranch && mode === 'new-branch' ? { startRef: effectiveSourceBranch } : {}), + }; + + const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args); + const metadata = await createWorktree(projectRef, resolvedArgs); + + const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; + const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; + const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false; + + let createdSessionId: string | null = null; + + if (linkedIssue || linkedPr) { + const sessionTitle = linkedIssue + ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() + : linkedPr + ? `#${linkedPr.number} ${linkedPr.title}`.trim() + : 'New session'; + + const session = await useSessionStore.getState().createSession(sessionTitle, metadata.path, null); + if (!session?.id) { + throw new Error('Failed to create session'); + } + + createdSessionId = session.id; + void useSessionStore.getState().updateSessionTitle(session.id, sessionTitle).catch(() => undefined); + + try { + useSessionStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents); + } catch { + // ignore + } + } + + // Save source branch preference (only if not from PR) + if (newBranchState.sourceBranch && mode === 'new-branch' && !newBranchState.linkedPr) { + localStorage.setItem(LAST_SOURCE_BRANCH_KEY, newBranchState.sourceBranch); + } + + toast.success('Worktree created', { + description: `${metadata.branch || metadata.name}${effectiveSourceBranch ? ` from ${effectiveSourceBranch}` : ''}`, + }); + + try { + await loadSessions(); + } catch { + // best effort + } + + onOpenChange(false); + + if (createdSessionId) { + onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId }); + void sendLinkedContextMessage({ + sessionId: createdSessionId, + issue: linkedIssue, + pr: linkedPr, + includeDiff: includePrDiff, + }).catch((error) => { + const message = error instanceof Error ? error.message : 'Failed to send GitHub context'; + toast.error('Failed to send GitHub context', { description: message }); + }); + } else { + onWorktreeCreated?.(metadata.path); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create worktree'; + toast.error('Failed to create worktree', { 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, + 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, + 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, + includePrDiff: result.includeDiff ?? false, + branchName: pr.head, + worktreeName: slugifyWorktreeName(pr.head), + isSyncingWorktreeName: true, + })); + } + }; + + // GitHub connection check + const isGitHubConnected = githubAuthChecked && githubAuthStatus?.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, + branchName: '', + includePrDiff: false, + isSyncingWorktreeName: true, + })); + }; + + // Footer content + const footerContent = ( +
+ {/* Validation error */} +
+ {validation.touched && (validation.branchError || validation.worktreeError) && ( + <> + + + {validation.branchError || validation.worktreeError} + + + )} +
+ + {/* Buttons */} +
+ + +
+
+ ); + + return ( + <> + {isMobile ? ( + onOpenChange(false)} + footer={footerContent} + > + {/* Mode Selection - using SortableTabsStrip */} +
+ }, + { id: 'existing-branch', label: 'Existing Branch', icon: }, + ]} + activeId={mode} + onSelect={(id) => handleModeChange(id as Mode)} + variant="active-pill" + layoutMode="fit" + className="w-full" + /> +
+ +
+ {/* Branch Name / Existing Branch Selection */} + {mode === 'existing-branch' ? ( +
+ + + + {/* Mobile Branch Picker Overlay */} + setExistingBranchPickerOpen(false)} + > +
+ {localBranches.length === 0 && remoteBranches.length === 0 ? ( +
+ No branches found +
+ ) : ( + <> + {localBranches.length > 0 && ( +
+
+ Local branches +
+
+ {localBranches.map(branch => ( + + ))} +
+
+ )} + {remoteBranches.length > 0 && ( +
+
+ Remote branches +
+
+ {remoteBranches.map(branch => ( + + ))} +
+
+ )} + + )} +
+
+
+ ) : ( +
+
+ + {mode === 'new-branch' && isGitHubConnected && ( + + )} +
+ { + setNewBranchState(prev => ({ + ...prev, + branchName: e.target.value, + isSyncingWorktreeName: true, + linkedIssue: null, + linkedPr: null, + })); + }} + onBlur={() => setValidation(prev => ({ ...prev, touched: true }))} + placeholder="feature/my-awesome-feature" + disabled={!!newBranchState.linkedPr} + className={cn( + 'h-8', + validation.touched && validation.branchError && 'border-destructive', + newBranchState.linkedPr && 'bg-muted text-muted-foreground' + )} + /> + {newBranchState.linkedPr && ( +
+ + + Using PR branch: {newBranchState.linkedPr.head} + +
+ )} + {newBranchState.linkedIssue && !newBranchState.linkedPr && ( +
+ + + From issue #{newBranchState.linkedIssue.number}: {newBranchState.linkedIssue.title} + +
+ )} +
+ )} + + {/* Worktree Directory */} +
+
+ + {mode !== 'existing-branch' && ( + + )} +
+ { + 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="my-worktree-directory" + className={cn( + 'h-8', + validation.touched && validation.worktreeError && 'border-destructive' + )} + /> +
+ + {/* Source Branch - Only for New Branch mode, hide when PR is selected */} + {mode === 'new-branch' && !newBranchState.linkedPr && ( +
+ + + {newBranchState.sourceBranch && ( +
+ New branch will be created from {newBranchState.sourceBranch} +
+ )} + + {/* Mobile Source Branch Picker Overlay */} + setSourceBranchPickerOpen(false)} + > +
+ {localBranches.length === 0 && remoteBranches.length === 0 ? ( +
+ No branches found +
+ ) : ( + <> + {localBranches.length > 0 && ( +
+
+ Local branches +
+
+ {localBranches.map(branch => ( + + ))} +
+
+ )} + {remoteBranches.length > 0 && ( +
+
+ Remote branches +
+
+ {remoteBranches.map(branch => ( + + ))} +
+
+ )} + + )} +
+
+
+ )} + + {/* Linked Item Preview - Two row minimal display */} + {(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && ( +
+ {/* Row 1: Type, number, title, actions */} +
+ + + {newBranchState.linkedIssue && ( + + Issue #{newBranchState.linkedIssue.number} + + )} + {newBranchState.linkedPr && ( + + PR #{newBranchState.linkedPr.number} + + )} + + + {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} + + + e.stopPropagation()} + > + + + + +
+ + {/* Row 2: PR branch info + diff indicator */} + {newBranchState.linkedPr && ( +
+ + {newBranchState.linkedPr.head} → {newBranchState.linkedPr.base} + + {newBranchState.includePrDiff && ( + + +diff + + )} +
+ )} +
+ )} +
+
+ ) : ( + + + +
+ + + New Worktree + + + {/* Mode Selection - using SortableTabsStrip */} +
+ }, + { id: 'existing-branch', label: 'Existing Branch', icon: }, + ]} + activeId={mode} + onSelect={(id) => handleModeChange(id as Mode)} + variant="active-pill" + layoutMode="fit" + className="w-full" + /> +
+
+
+ +
+ {/* Branch Name / Existing Branch Selection */} + {mode === 'existing-branch' ? ( +
+ + +
+ ) : ( +
+
+ + {mode === 'new-branch' && isGitHubConnected && ( + + )} +
+ { + setNewBranchState(prev => ({ + ...prev, + branchName: e.target.value, + isSyncingWorktreeName: true, + linkedIssue: null, + linkedPr: null, + })); + }} + onBlur={() => setValidation(prev => ({ ...prev, touched: true }))} + placeholder="feature/my-awesome-feature" + disabled={!!newBranchState.linkedPr} + className={cn( + 'h-8', + validation.touched && validation.branchError && 'border-destructive', + newBranchState.linkedPr && 'bg-muted text-muted-foreground' + )} + /> + {newBranchState.linkedPr && ( +
+ + + Using PR branch: {newBranchState.linkedPr.head} + +
+ )} + {newBranchState.linkedIssue && !newBranchState.linkedPr && ( +
+ + + From issue #{newBranchState.linkedIssue.number}: {newBranchState.linkedIssue.title} + +
+ )} +
+ )} + + {/* Worktree Directory */} +
+
+ + {mode !== 'existing-branch' && ( + + )} +
+ { + 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="my-worktree-directory" + className={cn( + 'h-8', + validation.touched && validation.worktreeError && 'border-destructive' + )} + /> +
+ + {/* Source Branch - Only for New Branch mode, hide when PR is selected */} + {mode === 'new-branch' && !newBranchState.linkedPr && ( +
+ + + {newBranchState.sourceBranch && ( +
+ New branch will be created from {newBranchState.sourceBranch} +
+ )} +
+ )} + + {/* Linked Item Preview - Two row minimal display */} + {(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && ( +
+ {/* Row 1: Type, number, title, actions */} +
+ + + {newBranchState.linkedIssue && ( + + Issue #{newBranchState.linkedIssue.number} + + )} + {newBranchState.linkedPr && ( + + PR #{newBranchState.linkedPr.number} + + )} + + + {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} + + + e.stopPropagation()} + > + + + + +
+ + {/* Row 2: PR branch info + diff indicator */} + {newBranchState.linkedPr && ( +
+ + {newBranchState.linkedPr.head} → {newBranchState.linkedPr.base} + + {newBranchState.includePrDiff && ( + + +diff + + )} +
+ )} +
+ )} +
+ + {/* Footer */} + + {/* Validation error - inline with buttons */} +
+ {validation.touched && (validation.branchError || validation.worktreeError) && ( + <> + + + {validation.branchError || validation.worktreeError} + + + )} +
+ +
+ + +
+
+
+
+ )} + + + + ); +} diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index a6c156b4..c4925eb9 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -61,14 +61,9 @@ import { RiFolderAddLine, RiFolderLine, RiGitBranchLine, - RiGitPullRequestLine, - RiGitRepositoryLine, RiNodeTree, RiStickyNoteLine, RiLinkUnlinkM, - - RiGithubLine, - RiMore2Line, RiPencilAiLine, RiPushpinLine, @@ -88,16 +83,14 @@ import type { WorktreeMetadata } from '@/types/worktree'; import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { getSafeStorage } from '@/stores/utils/safeStorage'; -import { createWorktreeOnly, createWorktreeSession } from '@/lib/worktreeSessionCreator'; +import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { useGitStore } from '@/stores/useGitStore'; import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; -import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog'; -import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog'; +import { NewWorktreeDialog } from './NewWorktreeDialog'; import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel'; -import { BranchPickerDialog } from './BranchPickerDialog'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { SessionFolderItem } from './SessionFolderItem'; @@ -404,8 +397,6 @@ interface SortableProjectItemProps { onHoverChange: (hovered: boolean) => void; onNewSession: () => void; onNewWorktreeSession?: () => void; - onNewSessionFromGitHubIssue?: () => void; - onNewSessionFromGitHubPR?: () => void; onOpenMultiRunLauncher: () => void; onRenameStart: () => void; onRenameSave: () => void; @@ -437,8 +428,6 @@ const SortableProjectItem: React.FC = ({ onHoverChange, onNewSession, onNewWorktreeSession, - onNewSessionFromGitHubIssue, - onNewSessionFromGitHubPR, onOpenMultiRunLauncher, onRenameStart, onRenameSave, @@ -602,18 +591,6 @@ const SortableProjectItem: React.FC = ({ New Session in Worktree )} - {showCreateButtons && isRepo && !hideDirectoryControls && onNewSessionFromGitHubIssue && ( - - - New session from GitHub issue - - )} - {showCreateButtons && isRepo && !hideDirectoryControls && onNewSessionFromGitHubPR && ( - - - New session from GitHub PR - - )} {showCreateButtons && isRepo && !hideDirectoryControls && ( @@ -760,9 +737,7 @@ export const SessionSidebar: React.FC = ({ const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); const [expandedSessionGroups, setExpandedSessionGroups] = React.useState>(new Set()); const [hoveredProjectId, setHoveredProjectId] = React.useState(null); - const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); - const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false); - const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false); + const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false); const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false); const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState>(new Set()); const [openMenuSessionId, setOpenMenuSessionId] = React.useState(null); @@ -1893,17 +1868,6 @@ export const SessionSidebar: React.FC = ({ : null), [activeProjectForHeader], ); - const branchPickerProject = React.useMemo(() => { - if (!activeProjectForHeader) { - return null; - } - return { - id: activeProjectForHeader.id, - path: activeProjectForHeader.path, - normalizedPath: activeProjectForHeader.normalizedPath, - label: activeProjectForHeader.label, - }; - }, [activeProjectForHeader]); const activeProjectIsRepo = React.useMemo( () => (activeProjectForHeader ? Boolean(projectRepoStatus.get(activeProjectForHeader.id)) : false), @@ -3167,15 +3131,7 @@ export const SessionSidebar: React.FC = ({ if (activeProjectForHeader.id !== activeProjectId) { setActiveProjectIdOnly(activeProjectForHeader.id); } - const newWorktreePath = await createWorktreeOnly(); - if (!newWorktreePath) { - return; - } - setActiveMainTab('chat'); - if (mobileVariant) { - setSessionSwitcherOpen(false); - } - openNewSessionDraft({ directoryOverride: newWorktreePath }); + setNewWorktreeDialogOpen(true); }} className={headerActionButtonClass} aria-label="New worktree" @@ -3185,32 +3141,6 @@ export const SessionSidebar: React.FC = ({

New worktree

- - - - -

New from issue

-
- - - - -

New from PR

-
- -

Manage branches

-
- ) : null} {useMobileNotesPanel ? ( @@ -3373,18 +3288,6 @@ export const SessionSidebar: React.FC = ({ } createWorktreeSession(); }} - onNewSessionFromGitHubIssue={() => { - if (projectKey !== activeProjectId) { - setActiveProjectIdOnly(projectKey); - } - setIssuePickerOpen(true); - }} - onNewSessionFromGitHubPR={() => { - if (projectKey !== activeProjectId) { - setActiveProjectIdOnly(projectKey); - } - setPullRequestPickerOpen(true); - }} onOpenMultiRunLauncher={() => { if (projectKey !== activeProjectId) { setActiveProjectIdOnly(projectKey); @@ -3459,34 +3362,22 @@ export const SessionSidebar: React.FC = ({ )} - { - setIssuePickerOpen(open); - if (!open && mobileVariant) { - setActiveMainTab('chat'); + { + setActiveMainTab('chat'); + if (mobileVariant) { setSessionSwitcherOpen(false); } - }} - /> - - { - setPullRequestPickerOpen(open); - if (!open && mobileVariant) { - setActiveMainTab('chat'); - setSessionSwitcherOpen(false); + if (options?.sessionId) { + setCurrentSession(options.sessionId); + return; } + openNewSessionDraft({ directoryOverride: worktreePath }); }} /> - - {useMobileNotesPanel ? ( { getSessionsByDirectory, } = useSessionStore(); - const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); - const { currentDirectory } = useDirectoryStore(); const { themeMode, setThemeMode } = useThemeSystem(); @@ -200,14 +197,14 @@ export const CommandPalette: React.FC = () => { New Session - {settingsAutoCreateWorktree ? shortcut('new_chat_worktree') : shortcut('new_chat')} + {shortcut('new_chat')} New Session with Worktree - {settingsAutoCreateWorktree ? shortcut('new_chat') : shortcut('new_chat_worktree')} + {shortcut('new_chat_worktree')} diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index a40eea74..dcf00248 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -7,7 +7,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useUIStore } from "@/stores/useUIStore"; -import { useConfigStore } from "@/stores/useConfigStore"; import { RiAddLine, RiAiAgentLine, @@ -54,7 +53,6 @@ const renderShortcut = (id: string, fallbackCombo: string, overrides: Record { const { isHelpDialogOpen, setHelpDialogOpen, shortcutOverrides } = useUIStore(); - const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); const mod = getModifierLabel(); const shortcuts: ShortcutSection[] = [ @@ -108,14 +106,14 @@ export const HelpDialog: React.FC = () => { items: [ { id: 'new_chat', - description: settingsAutoCreateWorktree ? "Create New Session in Worktree" : "Create New Session", - icon: settingsAutoCreateWorktree ? RiGitBranchLine : RiAddLine, + description: "Create New Session", + icon: RiAddLine, keys: '', }, { id: 'new_chat_worktree', - description: settingsAutoCreateWorktree ? "Create New Session" : "Create New Session in Worktree", - icon: settingsAutoCreateWorktree ? RiAddLine : RiGitBranchLine, + description: "Create New Session in Worktree", + icon: RiGitBranchLine, keys: '', }, { id: 'focus_input', description: "Focus Chat Input", icon: RiText, keys: '' }, diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 8020fd56..68c65023 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; import { useFireworksCelebration } from '@/contexts/FireworksContext'; import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; @@ -59,7 +58,6 @@ import { StashDialog } from './git/StashDialog'; import { InProgressOperationBanner } from './git/InProgressOperationBanner'; import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection'; import type { GitRemote } from '@/lib/gitApi'; -import { BranchPickerDialog } from '@/components/session/BranchPickerDialog'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { cn } from '@/lib/utils'; import { generateCommitMessage as generateSessionCommitMessage } from '@/lib/gitApi'; @@ -217,11 +215,7 @@ const gitViewSnapshots = new Map(); const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/, ''); -interface GitViewProps { - mode?: 'full' | 'sidebar'; -} - -export const GitView: React.FC = ({ mode = 'full' }) => { +export const GitView: React.FC = () => { const { git } = useRuntimeAPIs(); const currentDirectory = useEffectiveDirectory(); const { @@ -295,8 +289,6 @@ export const GitView: React.FC = ({ mode = 'full' }) => { }, [currentDirectory]); const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled); - const projects = useProjectsStore((state) => state.projects); - const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false); const [rootBranchHint, setRootBranchHint] = React.useState(null); React.useEffect(() => { @@ -324,26 +316,6 @@ export const GitView: React.FC = ({ mode = 'full' }) => { }; }, [worktreeMetadata?.projectDirectory]); - const branchPickerProject = React.useMemo(() => { - const current = normalizePath(currentDirectory); - const worktreeRoot = normalizePath(worktreeMetadata?.projectDirectory); - const best = projects - .map((project) => ({ - id: project.id, - path: project.path, - label: project.label, - normalizedPath: normalizePath(project.path), - })) - .sort((a, b) => b.normalizedPath.length - a.normalizedPath.length) - .find((project) => { - if (!project.normalizedPath) return false; - if (worktreeRoot && project.normalizedPath === worktreeRoot) return true; - return current === project.normalizedPath || current.startsWith(`${project.normalizedPath}/`); - }); - - return best ?? null; - }, [currentDirectory, projects, worktreeMetadata?.projectDirectory]); - const [commitMessage, setCommitMessage] = React.useState( initialSnapshot?.commitMessage ?? '' ); @@ -355,7 +327,6 @@ export const GitView: React.FC = ({ mode = 'full' }) => { const [logMaxCountLocal, setLogMaxCountLocal] = React.useState(25); const [isSettingIdentity, setIsSettingIdentity] = React.useState(false); const { triggerFireworks } = useFireworksCelebration(); - const isSidebarMode = mode === 'sidebar'; const autoAppliedDefaultRef = React.useRef>(new Map()); const identityApplyCountRef = React.useRef(0); @@ -1759,7 +1730,7 @@ export const GitView: React.FC = ({ mode = 'full' }) => { } return ( -
+
= ({ mode = 'full' }) => { onSelectIdentity={handleApplyIdentity} isApplyingIdentity={isSettingIdentity} isWorktreeMode={!!worktreeMetadata} - isSidebarMode={isSidebarMode} onOpenHistory={() => setIsHistoryDialogOpen(true)} - onOpenBranchPicker={!isSidebarMode && branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined} /> {/* In-progress operation banner */} @@ -1801,7 +1770,7 @@ export const GitView: React.FC = ({ mode = 'full' }) => {
-
+
= ({ mode = 'full' }) => { onSelect={(tabID) => setActionTab(tabID as ActionTab)} layoutMode="fit" variant="active-pill" - inactiveTabsIconOnly={isSidebarMode && isMobile} + inactiveTabsIconOnly={isMobile} className="h-full" />
- {!isSidebarMode ?
: null} @@ -1840,12 +1808,12 @@ export const GitView: React.FC = ({ mode = 'full' }) => { onClearSelection={clearSelection} onRevertAll={handleRevertAll} onViewDiff={(path) => { - if (isSidebarMode && currentDirectory && !isMobile) { + if (currentDirectory && !isMobile) { openContextDiff(currentDirectory, path); return; } navigateToDiff(path); - if (isSidebarMode && isMobile) { + if (isMobile) { setRightSidebarOpen(false); } }} @@ -2051,12 +2019,6 @@ export const GitView: React.FC = ({ mode = 'full' }) => { onConfirm={handleStashAndRetry} /> - -
); }; diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index 5f439546..4112f7c8 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -4,7 +4,6 @@ import { RiCheckLine, RiLoader4Line, RiGitBranchLine, - RiGitRepositoryLine, RiBriefcaseLine, RiHomeLine, RiGraduationCapLine, @@ -47,9 +46,7 @@ interface GitHeaderProps { onSelectIdentity: (profile: GitIdentityProfile) => void; isApplyingIdentity: boolean; isWorktreeMode: boolean; - isSidebarMode?: boolean; onOpenHistory?: () => void; - onOpenBranchPicker?: () => void; } const IDENTITY_ICON_MAP: Record< @@ -207,9 +204,7 @@ export const GitHeader: React.FC = ({ onSelectIdentity, isApplyingIdentity, isWorktreeMode, - isSidebarMode = false, onOpenHistory, - onOpenBranchPicker, }) => { const isMobile = useUIStore((state) => state.isMobile); @@ -217,38 +212,20 @@ export const GitHeader: React.FC = ({ return null; } - const useTwoRowHeader = isSidebarMode || isMobile; + const useTwoRowHeader = isMobile; const managementButtons = (
- {onOpenBranchPicker ? ( - - - - - Manage branches - - ) : null} - {onOpenHistory ? ( History @@ -265,7 +242,7 @@ export const GitHeader: React.FC = ({ onPull={onPull} onPush={onPush} disabled={!status} - iconOnly={isSidebarMode} + iconOnly={true} tooltipDelayMs={useTwoRowHeader ? 300 : 1000} aheadCount={status.ahead} behindCount={status.behind} @@ -283,68 +260,36 @@ export const GitHeader: React.FC = ({ /> ); - if (useTwoRowHeader) { - return ( -
-
-
- {isWorktreeMode ? ( - - ) : ( - - )} -
-
- -
-
- {syncButtons} - {managementButtons} -
-
{identityControl}
-
-
- ); - } - return ( -
-
- {isWorktreeMode ? ( - - ) : ( - - )} - -
{syncButtons}
+
+
+
+ {isWorktreeMode ? ( + + ) : ( + + )} +
-
- {managementButtons} - {identityControl} +
+
+ {syncButtons} + {managementButtons} +
+
{identityControl}
); diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 0d358889..44212abf 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -70,25 +70,20 @@ export const useKeyboardShortcuts = () => { return; } - if (eventMatchesShortcut(e, combo('new_chat')) || eventMatchesShortcut(e, combo('new_chat_worktree'))) { + const matchedNewSessionShortcut = eventMatchesShortcut(e, combo('new_chat')); + const matchedWorktreeShortcut = eventMatchesShortcut(e, combo('new_chat_worktree')); + + if (matchedNewSessionShortcut || matchedWorktreeShortcut) { e.preventDefault(); - const isVSCode = isVSCodeRuntime(); - const autoWorktree = useConfigStore.getState().settingsAutoCreateWorktree; - const matchedPrimaryShortcut = eventMatchesShortcut(e, combo('new_chat')); - const shouldCreateWorktree = isVSCode - ? false - : (matchedPrimaryShortcut ? autoWorktree : !autoWorktree); + setActiveMainTab('chat'); + setSessionSwitcherOpen(false); - if (shouldCreateWorktree) { - setActiveMainTab('chat'); - setSessionSwitcherOpen(false); + if (!isVSCodeRuntime() && matchedWorktreeShortcut) { createWorktreeSession(); return; } - setActiveMainTab('chat'); - setSessionSwitcherOpen(false); openNewSessionDraft(); return; }