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; }