feat: streamline worktree session flow (#577)

* feat: add unified worktree creation dialog with GitHub integration

Unified dialog for creating worktrees with branch selection and GitHub integration
New GitHub picker dialog for selecting issues and PRs with validation
Persistent state when switching between new branch and existing branch modes

* refactor: minimalistic compact design for worktree dialogs

Redesigned dialogs with minimalistic compact layout
Moved mode tabs inline with dialog header
Inline validation errors with action buttons

* fix: maintain consistent dialog height in all states

Fixed dialog height fluctuations when loading or switching tabs
Consistent 300px height for loading, empty, and populated states
Nested container structure prevents layout shifts

* refactor: minimalistic compact UI redesign for worktree dialogs

Minimalistic compact layouts with reduced spacing and inline tabs
Standardized SortableTabsStrip with pills variant for mode selection
Two-row linked item display and inline validation with action buttons

* feat: mobile overlays for worktree dialogs with branch pickers

Mobile overlay panels replace dropdowns for branch selection
Fixed footer button overlapping with proper flex layout
Responsive design with conditional mobile/desktop rendering

* refactor: relocate selected item badge to footer with proper alignment

Selected item badge moved to footer left side on desktop
Proper alignment with Cancel/Select buttons using h-8 height
Cleaner header layout removing the selected item display

* perf: use cached branches from git store for instant branch selection

Branch list now loads instantly from git store cache
No more loading delay when opening new worktree dialog
Removes redundant API calls by using existing cached data

* refactor: improve branch selection UI with grouped headers and compact layout

Group branches into Local and Remote sections with clear headers
Compact dropdowns with better sizing and text wrapping
Bold labels and smaller issue/PR titles for cleaner layout

* feat: add prefilled slugs and improve branch selection UX in new worktree dialog

Prefilled unique branch name when opening new worktree dialog
Group branches into Local and Remote sections with bold headers
Instant branch list from cache with compact, wrapped dropdowns

* feat: update GitHub button text to be more descriptive

* feat: add GitHub issue linking to draft sessions

Link GitHub issues to draft sessions with full context including body, labels and comments
View issue author avatar, number and title in session input area
Open linked issues in browser or remove link with inline action buttons

* style: redesign queued messages with text-only row layout

* style: redesign attached files with text-only row layout and file type icons

Redesigned attached files with clean text-only rows matching linked issue style
Split display into two rows: image previews and file list with proper type icons
Removed internal Server/Local indicators to reduce visual clutter

* refactor: remove New from PR and Manage branches from sessions UI

Remove New from PR and Manage branches buttons from sessions sidebar
Delete GitHubPullRequestPickerDialog component and related code
Simplify GitView to sidebar-only mode with no mode prop

* fix: simplify new session shortcuts and remove worktree toggle

* fix: restore issue and PR context when creating worktree sessions

Worktrees created from GitHub issues and PRs now seed the new session with visible and synthetic context messages
Sidebar now opens the created linked session directly instead of creating an extra draft
Visible prompts are cleaner and include issue or PR numbers for clarity
This commit is contained in:
Bohdan Triapitsyn
2026-03-03 00:20:15 +02:00
committed by GitHub
parent d4269a1cc8
commit bdad912ea5
18 changed files with 2850 additions and 1658 deletions
+2 -59
View File
@@ -151,21 +151,8 @@ fn build_macos_menu<R: tauri::Runtime>(
let pkg_info = app.package_info();
let auto_worktree = app
.try_state::<MenuRuntimeState>()
.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<R: tauri::Runtime>(
)
}
#[tauri::command]
fn desktop_set_auto_worktree_menu(app: tauri::AppHandle, enabled: bool) -> Result<(), String> {
let Some(state) = app.try_state::<MenuRuntimeState>() 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<bool>,
}
#[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,
+1 -20
View File
@@ -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<string, unknown>) => Promise<unknown> } } }).__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 });
+102 -1
View File
@@ -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<ChatInputProps> = ({ onOpenSettings, scrollToBo
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollToBo
</div>
</div>
)}
{/* Linked Issue Button - only in draft mode */}
{newSessionDraftOpen && (
<div className="pb-2 w-full px-1">
{linkedIssue ? (
<button
type="button"
onClick={() => setIssuePickerOpen(true)}
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
>
{linkedIssue.author?.avatarUrl && (
<img
src={linkedIssue.author.avatarUrl}
alt={linkedIssue.author.login}
className="h-5 w-5 rounded-full flex-shrink-0"
/>
)}
<span className="text-muted-foreground flex-shrink-0">
#{linkedIssue.number}
{linkedIssue.author && (
<span className="ml-1">by {linkedIssue.author.login}</span>
)}
</span>
<span className="text-foreground truncate">
{linkedIssue.title}
</span>
<span className="flex items-center gap-0.5 flex-shrink-0">
<a
href={linkedIssue.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label="Open issue in browser"
>
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
</a>
<span
onClick={(e) => {
e.stopPropagation();
setLinkedIssue(null);
}}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove linked issue"
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
</span>
</button>
) : (
<button
type="button"
onClick={() => setIssuePickerOpen(true)}
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
>
<RiGithubLine
className="h-4 w-4 flex-shrink-0"
style={{ color: currentTheme?.colors?.status?.success }}
/>
<span className="text-muted-foreground">Link GitHub Issue</span>
</button>
)}
</div>
)}
<div
className={cn(
"flex flex-col relative overflow-visible",
@@ -2378,6 +2471,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
</div>
</div>
</form>
{/* Issue Picker Dialog */}
<GitHubIssuePickerDialog
open={issuePickerOpen}
onOpenChange={setIssuePickerOpen}
mode="select"
onSelect={(issue) => setLinkedIssue(issue)}
/>
</>
);
};
+285 -322
View File
@@ -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="*/*"
/>
<button
type='button'
onClick={() => {
if (isVSCodeRuntime) {
void handleVSCodePick();
} else {
fileInputRef.current?.click();
}
}}
className={cn(
buttonSizeClass,
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
)}
title='Attach files'
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={isVSCodeRuntime ? handleVSCodePick : () => fileInputRef.current?.click()}
className={cn(
'flex items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'hover:bg-muted text-muted-foreground',
buttonSizeClass
)}
aria-label="Attach files"
>
<RiAttachment2 className={iconSizeClass} />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Attach files</p>
</TooltipContent>
</Tooltip>
</>
);
});
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 (
<button
type="button"
className="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5"
>
<FileTypeIcon filePath={file.filename} extension={extension} className="h-4 w-4" />
<span className="text-foreground truncate max-w-[200px]">
{displayName}
</span>
<span
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label={`Remove ${displayName}`}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
</button>
);
}
return (
<div className="relative h-10 w-10 rounded-lg border border-border/40 bg-muted/10 overflow-hidden flex-shrink-0 group">
<img
src={imageUrl}
alt={displayName}
className="h-full w-full object-cover"
loading="lazy"
/>
<button
onClick={onRemove}
className="absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
title="Remove image"
aria-label={`Remove ${displayName}`}
>
<RiCloseLine className="h-2.5 w-2.5" />
</button>
</div>
);
});
ImagePreview.displayName = 'ImagePreview';
interface FileChipProps {
file: AttachedFile;
onRemove: () => void;
}
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
const getFileIcon = () => {
if (file.mimeType.startsWith('image/')) {
return <RiFileImageLine className="h-3.5 w-3.5" />;
}
if (file.mimeType.includes('text') || file.mimeType.includes('code')) {
return <RiFileLine className="h-3.5 w-3.5" />;
}
if (file.mimeType.includes('json') || file.mimeType.includes('xml')) {
return <RiFilePdfLine className="h-3.5 w-3.5" />;
}
return <RiFileLine className="h-3.5 w-3.5" />;
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 (
<div className="relative h-12 w-12 sm:h-14 sm:w-14 overflow-hidden rounded-lg border border-border/40 bg-muted/10 flex-shrink-0">
<img
src={file.dataUrl}
alt={displayName}
className="h-full w-full object-cover"
loading="lazy"
/>
<button
onClick={onRemove}
className="absolute top-1 right-1 h-5 w-5 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
title="Remove image"
aria-label={`Remove ${displayName}`}
>
<RiCloseLine className="h-3 w-3" />
</button>
</div>
);
}
const fileSize = formatFileSize(file.size);
const extension = getFileExtension(file.filename);
return (
<div className="flex w-full sm:inline-flex sm:w-auto items-center gap-1.5 px-3 sm:px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta max-w-full min-w-0">
<div title={file.source === 'server' ? "Server file" : "Local file"}>
{file.source === 'server' ? (
<RiHardDrive3Line className="h-3 w-3 text-primary flex-shrink-0" />
) : (
<RiComputerLine className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</div>
{getFileIcon()}
<div className="overflow-hidden max-w-[120px] sm:max-w-[180px] flex-1 min-w-0">
<span className="truncate block" title={file.serverPath || displayName}>
{displayName}
</span>
</div>
<span className="ml-auto text-muted-foreground flex-shrink-0 text-xs">
{formatFileSize(file.size)}
<button
type="button"
onClick={(e) => {
// Prevent click from bubbling if clicking the remove button
if ((e.target as HTMLElement).closest('[data-remove-button]')) {
return;
}
}}
className="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5"
>
<FileTypeIcon filePath={file.filename} extension={extension} className="h-4 w-4" />
<span className="text-foreground truncate max-w-[200px]">
{displayName}
{fileSize && <span className="text-muted-foreground ml-1">({fileSize})</span>}
</span>
<button
onClick={onRemove}
className="hover:text-destructive min-h-6 min-w-6 sm:min-h-0 sm:min-w-0 sm:p-0.5 flex items-center justify-center flex-shrink-0 rounded focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
title="Remove file"
<span
data-remove-button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label={`Remove ${displayName}`}
>
<RiCloseLine className="h-4 w-4 sm:h-3 sm:w-3" />
</button>
</div>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
</button>
);
});
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 (
<div className="pb-2 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
{attachedFiles.map((file) => (
<FileChip
key={file.id}
file={file}
onRemove={() => removeAttachedFile(file.id)}
/>
))}
</div>
<div className="pb-4 w-full px-1 space-y-3">
{/* Images row - inline with previews */}
{images.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{images.map((file) => (
<ImagePreview
key={file.id}
file={file}
onRemove={() => removeAttachedFile(file.id)}
/>
))}
</div>
)}
{/* Other files row - inline text-only */}
{otherFiles.length > 0 && (
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap">
{otherFiles.map((file) => (
<FileChip
key={file.id}
file={file}
onRemove={() => removeAttachedFile(file.id)}
/>
))}
</div>
)}
</div>
);
});
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 <RiFileLine className="h-3.5 w-3.5" />;
if (mimeType.startsWith('image/')) {
return <RiFileImageLine className="h-3.5 w-3.5" />;
}
if (mimeType.includes('text') || mimeType.includes('code')) {
return <RiFileLine className="h-3.5 w-3.5" />;
}
if (mimeType.includes('json') || mimeType.includes('xml')) {
return <RiFilePdfLine className="h-3.5 w-3.5" />;
}
return <RiFileLine className="h-3.5 w-3.5" />;
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 (
<div className={cn(compact ? 'space-y-1.5 mt-1.5' : 'space-y-2 mt-2')}>
{}
{otherFiles.length > 0 && (
<div className={cn('flex flex-wrap', compact ? 'gap-1.5' : 'gap-2')}>
{otherFiles.map((file, index) => (
<div className={cn(
"grid gap-2",
compact ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2"
)}>
{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 (
<div
key={`file-${file.url || file.filename || index}`}
className={cn(
'inline-flex items-center bg-muted/30 border border-border/30 typography-meta',
compact ? 'gap-1 px-2 py-0.5 rounded-lg' : 'gap-1.5 px-2.5 py-1 rounded-xl'
)}
key={index}
className="relative aspect-video rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
>
{getFileIcon(file.mime)}
<div className={cn('overflow-hidden', compact ? 'max-w-[140px]' : 'max-w-[200px]')}>
<span className="truncate block" title={extractFilename(file.filename)}>
{extractFilename(file.filename)}
</span>
<img
src={file.url}
alt={fileName}
className="h-full w-full object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="absolute bottom-0 left-0 right-0 p-2 text-white opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-xs font-medium truncate">{fileName}</p>
{sizeText && <p className="text-xs opacity-80">{sizeText}</p>}
</div>
</div>
))}
</div>
)}
);
}
{mermaidFiles.length > 0 && (
<div className={cn('flex flex-wrap', compact ? 'gap-1.5' : 'gap-2')}>
{mermaidFiles.map((file, index) => {
const filename = extractFilename(file.filename) || 'Diagram';
return (
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<button
key={`mermaid-${file.url || file.filename || index}`}
type="button"
onClick={() => handleMermaidClick(file)}
onClick={() => {
if (onShowPopup && file.url) {
onShowPopup({
open: true,
title: fileName,
content: '',
image: {
url: file.url,
mimeType: file.mime,
filename: fileName,
},
});
}
}}
className={cn(
'inline-flex items-center bg-muted/30 border border-border/30 typography-meta transition-colors',
'hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
compact ? 'gap-1 px-2 py-0.5 rounded-lg' : 'gap-1.5 px-2.5 py-1 rounded-xl'
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left",
compact ? "text-xs" : "text-sm"
)}
title={`Open ${filename}`}
aria-label={`Open diagram ${filename}`}
>
{getFileIcon(file.mime)}
<div className={cn('overflow-hidden', compact ? 'max-w-[140px]' : 'max-w-[200px]')}>
<span className="truncate block" title={filename}>
{filename}
</span>
<div className="flex-shrink-0">
{file.mime?.startsWith('image/') ? (
<RiFileImageLine className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
) : file.mime?.includes('pdf') ? (
<RiFilePdfLine className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
) : (
<RiFileLine className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{fileName}</p>
{sizeText && <p className="text-xs text-muted-foreground">{sizeText}</p>}
</div>
</button>
);
})}
</div>
)}
</TooltipTrigger>
<TooltipContent>
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
);
});
{}
{imageFiles.length > 0 && (
<div className={cn('overflow-x-auto -mx-1 px-1 scrollbar-thin', compact ? 'py-0.5' : 'py-1')}>
<div className={cn('flex snap-x snap-mandatory', compact ? 'gap-2' : 'gap-3')}>
{imageFiles.map((file, index) => {
const filename = extractFilename(file.filename) || 'Image';
MessageFilesDisplay.displayName = 'MessageFilesDisplay';
return (
<Tooltip key={`img-${file.url || file.filename || index}`} delayDuration={1000}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleImageClick(index)}
className={cn(
'relative flex-none border border-border/40 bg-muted/10 overflow-hidden snap-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary',
compact
? 'h-12 w-12 sm:h-14 sm:w-14 md:h-16 md:w-16 rounded-lg'
: 'aspect-square w-16 sm:w-20 md:w-24 rounded-xl'
)}
aria-label={filename}
>
{file.url ? (
<img
src={file.url}
alt={filename}
className="h-full w-full object-cover"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.visibility = 'hidden';
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center bg-muted/30 text-muted-foreground">
<RiFileImageLine className="h-6 w-6" />
</div>
)}
<span className="sr-only">{filename}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="typography-meta px-2 py-1">
{filename}
</TooltipContent>
</Tooltip>
);
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 (
<div className="space-y-2">
<div className={cn("grid gap-2", getGridCols())}>
{urls.map((url, index) => (
<button
key={index}
type="button"
onClick={() => onShowPopup?.({
open: true,
title: caption || `Image ${index + 1} of ${urls.length}`,
content: '',
image: {
url,
gallery: urls.map(u => ({ url: u })),
index,
},
})}
</div>
</div>
className="relative aspect-square rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
>
<img
src={url}
alt={caption || `Image ${index + 1}`}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
</button>
))}
</div>
{caption && (
<p className="text-sm text-muted-foreground italic">{caption}</p>
)}
</div>
);
});
ImageGallery.displayName = 'ImageGallery';
@@ -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 (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta group">
<RiMessage2Line className="h-3 w-3 text-muted-foreground flex-shrink-0" />
<button
type="button"
onClick={() => onEdit(message)}
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-left"
title="Click to edit"
>
<span className="truncate max-w-[200px]">
{firstLine || '(empty)'}
</span>
<button
type="button"
onClick={() => onEdit(message)}
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
>
<RiMessage2Line
className="h-4 w-4 flex-shrink-0 text-muted-foreground"
/>
<span className="text-muted-foreground flex-shrink-0">
Queued
{attachmentCount > 0 && (
<span className="text-muted-foreground flex-shrink-0">
+{attachmentCount} file{attachmentCount > 1 ? 's' : ''}
</span>
<span className="ml-1">+{attachmentCount} file{attachmentCount > 1 ? 's' : ''}</span>
)}
</button>
<button
type="button"
onClick={() => removeFromQueue(sessionId, message.id)}
className="ml-1 hover:text-destructive p-0.5 opacity-60 group-hover:opacity-100 transition-opacity"
title="Remove from queue"
</span>
<span className="text-foreground truncate">
{firstLine || '(empty)'}
</span>
<span
onClick={(e) => {
e.stopPropagation();
removeFromQueue(sessionId, message.id);
}}
className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove from queue"
>
<RiCloseLine className="h-3 w-3" />
</button>
</div>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
</button>
);
});
@@ -98,17 +100,15 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro
}
return (
<div className="pb-2">
<div className="flex items-center flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
{queuedMessages.map((message) => (
<QueuedMessageChip
key={message.id}
message={message}
sessionId={currentSessionId}
onEdit={handleEdit}
/>
))}
</div>
<div className="pb-2 w-full px-1 space-y-1">
{queuedMessages.map((message) => (
<QueuedMessageChip
key={message.id}
message={message}
sessionId={currentSessionId}
onEdit={handleEdit}
/>
))}
</div>
);
});
@@ -714,7 +714,7 @@ export const MainLayout: React.FC = () => {
>
<div className="h-full overflow-hidden flex flex-col bg-background shadow-none drawer-safe-area">
<ErrorBoundary>
<GitView mode="sidebar" />
<GitView />
</ErrorBoundary>
</div>
</motion.aside>
@@ -39,7 +39,7 @@ export const RightSidebarTabs: React.FC = () => {
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{rightSidebarTab === 'git' ? <GitView mode="sidebar" /> : <SidebarFilesTree />}
{rightSidebarTab === 'git' ? <GitView /> : <SidebarFilesTree />}
</div>
</div>
);
@@ -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 = () => {
<span className="typography-ui-label text-foreground">Show Deletion Dialog</span>
</div>
{!isVSCode && (
<div
className="group flex cursor-pointer items-center gap-2 py-1"
role="button"
tabIndex={0}
aria-pressed={settingsAutoCreateWorktree}
onClick={() => {
void handleAutoWorktreeChange(!settingsAutoCreateWorktree);
}}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
void handleAutoWorktreeChange(!settingsAutoCreateWorktree);
}
}}
>
<Checkbox
checked={settingsAutoCreateWorktree}
onChange={(checked) => {
void handleAutoWorktreeChange(checked);
}}
ariaLabel="Always create worktree"
/>
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">Always Create Worktree</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{settingsAutoCreateWorktree
? `New session (Worktree): ${getModifierLabel()}+N\nStandard: Shift+${getModifierLabel()}+N`
: `New session (Standard): ${getModifierLabel()}+N\nWorktree: Shift+${getModifierLabel()}+N`}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
)}
</section>
</div>
);
@@ -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<GitHubTab>('issues');
const [searchQuery, setSearchQuery] = React.useState('');
const [issues, setIssues] = React.useState<GitHubIssueSummary[]>([]);
const [prs, setPrs] = React.useState<GitHubPullRequestSummary[]>([]);
const [loading, setLoading] = React.useState(false);
const [loadingMore, setLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [selectedIssue, setSelectedIssue] = React.useState<GitHubIssue | null>(null);
const [selectedPr, setSelectedPr] = React.useState<GitHubPullRequestSummary | null>(null);
const [includeDiff, setIncludeDiff] = React.useState(false);
const [validations, setValidations] = React.useState<Map<string, ValidationResult>>(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 ? (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-4">
<RiGithubLine className="h-12 w-12 text-muted-foreground" />
<div className="text-center">
<p className="typography-ui-label text-foreground">Connect to GitHub</p>
<p className="typography-small text-muted-foreground mt-1">
Link issues or pull requests to auto-fill worktree details
</p>
</div>
<Button onClick={openGitHubSettings} size="sm">Connect GitHub</Button>
</div>
) : (
<>
{/* Search */}
<div className="relative mt-2">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={activeTab === 'issues' ? "Search issues or enter #123..." : "Search PRs or enter #456..."}
className="h-8 pl-9"
/>
</div>
{/* List Content */}
<div className="mt-2 h-[300px] overflow-hidden">
<div className="h-full overflow-y-auto">
{/* Loading */}
{loading && (
<div className="flex items-center justify-center h-full">
<RiLoader4Line className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{/* Error */}
{error && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-2 p-2 rounded-md bg-destructive/10 text-destructive">
<RiErrorWarningLine className="h-4 w-4" />
<span className="typography-small">{error}</span>
</div>
</div>
)}
{/* Issues List */}
{!loading && !error && activeTab === 'issues' && (
<div className="space-y-0.5 min-h-full">
{filteredIssues.length > 0 ? (
filteredIssues.map(issue => (
<button
key={issue.number}
onClick={() => handleSelectIssue(issue)}
className={cn(
'w-full text-left px-2 py-1.5 rounded transition-colors',
selectedIssue?.number === issue.number
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'hover:bg-interactive-hover'
)}
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground shrink-0 typography-micro">#{issue.number}</span>
<span className="typography-small line-clamp-2">{issue.title}</span>
</div>
</button>
))
) : (
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
No issues found
</div>
)}
{hasMore && !loadingMore && (
<div className="flex justify-center pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => void loadMore()}
className="h-7 text-xs"
>
Load more
</Button>
</div>
)}
{loadingMore && (
<div className="flex items-center justify-center py-2">
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
{/* PRs List */}
{!loading && !error && activeTab === 'prs' && (
<div className="space-y-0.5 min-h-full">
{filteredPrs.length > 0 ? (
filteredPrs.map(pr => {
const blocked = isPrBlocked(pr);
const validation = pr.head ? validations.get(pr.head) : undefined;
return (
<button
key={pr.number}
onClick={() => !blocked && handleSelectPr(pr)}
disabled={blocked}
className={cn(
'w-full text-left px-2 py-1.5 rounded transition-colors',
selectedPr?.number === pr.number
? 'bg-interactive-selection text-interactive-selection-foreground'
: blocked
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-interactive-hover'
)}
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground shrink-0 typography-micro">#{pr.number}</span>
<div className="min-w-0 flex-1">
<span className="typography-small line-clamp-1">{pr.title}</span>
<div className="flex items-center gap-2 mt-0.5">
<span className="typography-micro text-muted-foreground">
{pr.head} {pr.base}
</span>
{blocked && validation?.error && (
<span className="typography-micro text-destructive">
{validation.error}
</span>
)}
</div>
</div>
</div>
</button>
);
})
) : (
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
No pull requests found
</div>
)}
{hasMore && !loadingMore && (
<div className="flex justify-center pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => void loadMore()}
className="h-7 text-xs"
>
Load more
</Button>
</div>
)}
{loadingMore && (
<div className="flex items-center justify-center py-2">
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
</div>
</>
)}
</>
);
// Footer content
const footerContent = (
<div className={cn(
'w-full',
isMobile ? 'flex flex-col gap-2' : 'flex flex-row items-center'
)}>
{/* Left side: Selected Item / Checkbox */}
<div className={cn(
'flex items-center gap-4',
isMobile ? 'w-full justify-center order-1' : 'flex-1'
)}>
{/* Selected Issue/PR display - hidden on mobile (shown in header instead) */}
{!isMobile && (selectedIssue || selectedPr) && (
<div className="flex items-center gap-2 px-2 h-8 rounded-md bg-muted/50 border border-border/50">
<RiCheckLine className="h-3.5 w-3.5 text-status-success shrink-0" />
<span className="typography-small truncate max-w-[150px]">
{selectedIssue ? `Issue #${selectedIssue.number}` : `PR #${selectedPr?.number}`}
</span>
<button
onClick={handleClear}
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Include Diff Checkbox - only show when PR tab is active and PR is selected */}
{activeTab === 'prs' && selectedPr && (
<label className="flex items-center gap-2 cursor-pointer h-8">
<Checkbox
checked={includeDiff}
onChange={(checked) => setIncludeDiff(checked)}
ariaLabel="Include PR diff in session context"
/>
<span className="typography-small text-foreground">
Include PR diff
</span>
</label>
)}
</div>
{/* Right side: Buttons */}
<div className={cn(
'flex gap-2',
isMobile ? 'w-full order-2' : 'justify-end'
)}>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
className={cn(isMobile && 'flex-1')}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleConfirm}
disabled={!canConfirm}
className={cn(isMobile && 'flex-1')}
>
Select
</Button>
</div>
</div>
);
return (
<>
{isMobile ? (
<MobileOverlayPanel
open={open}
title="Select from GitHub"
onClose={() => onOpenChange(false)}
footer={!isGitHubConnected ? undefined : footerContent}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-2 px-3 py-2 border-b border-border/40">
<div className="flex items-center justify-between">
<h2 className="typography-ui-label font-semibold text-foreground">Select from GitHub</h2>
{closeButton}
</div>
{/* Tabs - using SortableTabsStrip */}
<div className="w-full">
<SortableTabsStrip
items={[
{ id: 'issues', label: 'Issues', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'prs', label: 'Pull Requests', icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
]}
activeId={activeTab}
onSelect={(id) => {
setActiveTab(id as GitHubTab);
setSearchQuery('');
}}
variant="active-pill"
layoutMode="fit"
/>
</div>
{/* Selected Item Inline Display */}
{(selectedIssue || selectedPr) && (
<div className="flex items-center gap-2 px-2 py-1 rounded-md bg-muted/50 border border-border/50">
<RiCheckLine className="h-3.5 w-3.5 text-status-success shrink-0" />
<span className="typography-small truncate flex-1">
{selectedIssue ? `Issue #${selectedIssue.number}` : `PR #${selectedPr?.number}`}
</span>
<button
onClick={handleClear}
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)}
>
{dialogContent}
</MobileOverlayPanel>
) : (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<DialogTitle className="flex items-center gap-2 shrink-0">
<RiGithubLine className="h-5 w-5" />
Select from GitHub
</DialogTitle>
{/* Tabs - using SortableTabsStrip */}
<div className="w-[220px]">
<SortableTabsStrip
items={[
{ id: 'issues', label: 'Issues', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'prs', label: 'Pull Requests', icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
]}
activeId={activeTab}
onSelect={(id) => {
setActiveTab(id as GitHubTab);
setSearchQuery('');
}}
variant="active-pill"
layoutMode="fit"
/>
</div>
</div>
</DialogHeader>
{dialogContent}
{/* Footer */}
<DialogFooter className="mt-1">
{footerContent}
</DialogFooter>
</DialogContent>
</Dialog>
)}
</>
);
}
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -452,10 +518,12 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiGithubLine className="h-5 w-5" />
New Session From GitHub Issue
{mode === 'select' ? 'Link GitHub Issue' : 'New Session From GitHub Issue'}
</DialogTitle>
<DialogDescription>
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).'}
</DialogDescription>
</DialogHeader>
@@ -583,6 +651,7 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
) : null}
</div>
{mode !== 'select' && (
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-2">
@@ -634,6 +703,7 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
@@ -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<GitHubPullRequestsListResult | null>(null);
const [prs, setPrs] = React.useState<GitHubPullRequestSummary[]>([]);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const [startingNumber, setStartingNumber] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [existingBranchHeads, setExistingBranchHeads] = React.useState<Map<string, boolean>>(new Map());
const [projectRemotes, setProjectRemotes] = React.useState<GitRemote[]>([]);
const [error, setError] = React.useState<string | null>(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<string, unknown>) => (m as { id?: string }).id === modelID) as
| { variants?: Record<string, unknown> }
| 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 its 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 whats 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 arent 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:
- <issue> <brief why> <file:line-range> Action: <one-line action>
Nice-to-have:
- <issue> <brief why> <file:line-range> Action: <one-line 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiGitPullRequestLine className="h-5 w-5" />
New Session From GitHub PR
</DialogTitle>
<DialogDescription>
Seeds a new session with hidden PR context (title/body/comments/files/checks).
</DialogDescription>
</DialogHeader>
<div className="relative mt-2">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by title or #123, or paste PR URL"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
<div className="flex-1 overflow-y-auto mt-2">
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
) : null}
{!github ? (
<div className="text-center text-muted-foreground py-8">GitHub runtime API unavailable.</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading pull requests...
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8 space-y-3">
<div>GitHub not connected. Connect your GitHub account in settings.</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={openGitHubSettings}>
Open settings
</Button>
</div>
</div>
) : null}
{error ? (
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
) : null}
{directNumber && projectDirectory && github && connected ? (
<div
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
startingNumber === directNumber && 'bg-interactive-selection/30'
)}
onClick={() => void startSession(directNumber)}
>
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
Use PR #{directNumber}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingNumber === directNumber ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
) : null}
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{query ? 'No PRs found' : 'No open PRs found'}</div>
) : null}
{filtered.map((pr) => {
const disabledByWorktree = isPrDisabledForWorktree(pr);
return (
<div
key={pr.number}
className={cn(
'group flex items-start gap-2 py-1.5 rounded transition-colors',
startingNumber === pr.number && 'bg-interactive-selection/30',
disabledByWorktree
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-interactive-hover/30 cursor-pointer'
)}
onClick={() => {
if (disabledByWorktree) return;
void startSession(pr.number);
}}
>
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0 pt-0.5">#{pr.number}</span>
<div className="flex-1 min-w-0">
<p className="typography-small text-foreground truncate ml-0.5">{pr.title}</p>
{createInWorktree && disabledByWorktree ? (
<p className="typography-micro text-muted-foreground mt-0.5 ml-0.5">
PR worktree disabled: branch already exists or is in use ({pr.head})
</p>
) : null}
</div>
<div className="flex-shrink-0 h-5 flex items-center mr-2 pt-0.5">
{startingNumber === pr.number ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors',
disabledByWorktree && 'pointer-events-none'
)}
onClick={(e) => e.stopPropagation()}
aria-label="Open in GitHub"
>
<RiExternalLinkLine className="h-4 w-4" />
</a>
)}
</div>
</div>
);
})}
{hasMore && connected && projectDirectory && github ? (
<div className="py-2 flex justify-center">
<button
type="button"
onClick={() => void loadMore()}
disabled={isLoadingMore || Boolean(startingNumber)}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
(isLoadingMore || Boolean(startingNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
)}
>
{isLoadingMore ? (
<span className="inline-flex items-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading...
</span>
) : (
'Load more'
)}
</button>
</div>
) : null}
</div>
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-4 sm:gap-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
<div
className="flex items-center gap-2 cursor-pointer"
role="button"
tabIndex={0}
aria-pressed={createInWorktree}
onClick={() => setCreateInWorktree((v) => !v)}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setCreateInWorktree((v) => !v);
}
}}
>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCreateInWorktree((v) => !v);
}}
aria-label="Toggle worktree"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{createInWorktree ? (
<RiCheckboxLine className="h-4 w-4 text-primary" />
) : (
<RiCheckboxBlankLine className="h-4 w-4" />
)}
</button>
<span className="typography-meta text-muted-foreground">Create in PR worktree</span>
</div>
<div
className="flex items-center gap-2 cursor-pointer"
role="button"
tabIndex={0}
aria-pressed={includeDiff}
onClick={() => setIncludeDiff((v) => !v)}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setIncludeDiff((v) => !v);
}
}}
>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIncludeDiff((v) => !v);
}}
aria-label="Toggle diff"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{includeDiff ? (
<RiCheckboxLine className="h-4 w-4 text-primary" />
) : (
<RiCheckboxBlankLine className="h-4 w-4" />
)}
</button>
<span className="typography-meta text-muted-foreground">Include full diff</span>
</div>
</div>
<div className="hidden sm:block sm:flex-1" />
<div className="flex items-center gap-2">
{repoUrl ? (
<Button variant="outline" size="sm" asChild>
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
<RiExternalLinkLine className="size-4" />
Open Repo
</a>
</Button>
) : null}
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading || Boolean(startingNumber)}>
Refresh
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
@@ -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<SortableProjectItemProps> = ({
onHoverChange,
onNewSession,
onNewWorktreeSession,
onNewSessionFromGitHubIssue,
onNewSessionFromGitHubPR,
onOpenMultiRunLauncher,
onRenameStart,
onRenameSave,
@@ -602,18 +591,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
New Session in Worktree
</DropdownMenuItem>
)}
{showCreateButtons && isRepo && !hideDirectoryControls && onNewSessionFromGitHubIssue && (
<DropdownMenuItem onClick={onNewSessionFromGitHubIssue}>
<RiGithubLine className="mr-1.5 h-4 w-4" />
New session from GitHub issue
</DropdownMenuItem>
)}
{showCreateButtons && isRepo && !hideDirectoryControls && onNewSessionFromGitHubPR && (
<DropdownMenuItem onClick={onNewSessionFromGitHubPR}>
<RiGitPullRequestLine className="mr-1.5 h-4 w-4" />
New session from GitHub PR
</DropdownMenuItem>
)}
{showCreateButtons && isRepo && !hideDirectoryControls && (
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
<ArrowsMerge className="mr-1.5 h-4 w-4" />
@@ -760,9 +737,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(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<Set<string>>(new Set());
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
@@ -1893,17 +1868,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
: 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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New worktree</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setIssuePickerOpen(true)}
className={headerActionButtonClass}
aria-label="New from issue"
>
<RiGithubLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New from issue</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setPullRequestPickerOpen(true)}
className={headerActionButtonClass}
aria-label="New from PR"
>
<RiGitPullRequestLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New from PR</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -3226,21 +3156,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</Tooltip>
</>
) : null}
{stableActiveProjectIsRepo && branchPickerProject ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setIsBranchPickerOpen(true)}
className={headerActionButtonClass}
aria-label="Manage branches"
>
<RiGitRepositoryLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Manage branches</p></TooltipContent>
</Tooltip>
) : null}
{useMobileNotesPanel ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -3373,18 +3288,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
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<SessionSidebarProps> = ({
)}
</ScrollableOverlay>
<GitHubIssuePickerDialog
open={issuePickerOpen}
onOpenChange={(open) => {
setIssuePickerOpen(open);
if (!open && mobileVariant) {
setActiveMainTab('chat');
<NewWorktreeDialog
open={newWorktreeDialogOpen}
onOpenChange={setNewWorktreeDialogOpen}
onWorktreeCreated={(worktreePath, options) => {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
}}
/>
<GitHubPullRequestPickerDialog
open={pullRequestPickerOpen}
onOpenChange={(open) => {
setPullRequestPickerOpen(open);
if (!open && mobileVariant) {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
if (options?.sessionId) {
setCurrentSession(options.sessionId);
return;
}
openNewSessionDraft({ directoryOverride: worktreePath });
}}
/>
<BranchPickerDialog
open={isBranchPickerOpen}
onOpenChange={setIsBranchPickerOpen}
project={branchPickerProject}
/>
{useMobileNotesPanel ? (
<MobileOverlayPanel
open={projectNotesPanelOpen}
@@ -12,7 +12,6 @@ import {
import { useUIStore } from '@/stores/useUIStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useDeviceInfo } from '@/lib/device';
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
@@ -47,8 +46,6 @@ export const CommandPalette: React.FC = () => {
getSessionsByDirectory,
} = useSessionStore();
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
const { currentDirectory } = useDirectoryStore();
const { themeMode, setThemeMode } = useThemeSystem();
@@ -200,14 +197,14 @@ export const CommandPalette: React.FC = () => {
<RiAddLine className="mr-2 h-4 w-4" />
<span>New Session</span>
<CommandShortcut>
{settingsAutoCreateWorktree ? shortcut('new_chat_worktree') : shortcut('new_chat')}
{shortcut('new_chat')}
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleCreateWorktreeSession}>
<RiGitBranchLine className="mr-2 h-4 w-4" />
<span>New Session with Worktree</span>
<CommandShortcut>
{settingsAutoCreateWorktree ? shortcut('new_chat') : shortcut('new_chat_worktree')}
{shortcut('new_chat_worktree')}
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleToggleRightSidebar}>
+4 -6
View File
@@ -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<str
export const HelpDialog: React.FC = () => {
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: '' },
+7 -45
View File
@@ -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<string, GitViewSnapshot>();
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
interface GitViewProps {
mode?: 'full' | 'sidebar';
}
export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
export const GitView: React.FC = () => {
const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
const {
@@ -295,8 +289,6 @@ export const GitView: React.FC<GitViewProps> = ({ 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<string | null>(null);
React.useEffect(() => {
@@ -324,26 +316,6 @@ export const GitView: React.FC<GitViewProps> = ({ 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<GitViewProps> = ({ mode = 'full' }) => {
const [logMaxCountLocal, setLogMaxCountLocal] = React.useState<number>(25);
const [isSettingIdentity, setIsSettingIdentity] = React.useState(false);
const { triggerFireworks } = useFireworksCelebration();
const isSidebarMode = mode === 'sidebar';
const autoAppliedDefaultRef = React.useRef<Map<string, string>>(new Map());
const identityApplyCountRef = React.useRef(0);
@@ -1759,7 +1730,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
}
return (
<div className={cn('flex h-full flex-col overflow-hidden', isSidebarMode ? 'bg-transparent' : 'bg-background')} data-keyboard-avoid="true">
<div className={cn('flex h-full flex-col overflow-hidden', 'bg-transparent')} data-keyboard-avoid="true">
<GitHeader
status={status}
localBranches={localBranches}
@@ -1778,9 +1749,7 @@ export const GitView: React.FC<GitViewProps> = ({ 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<GitViewProps> = ({ mode = 'full' }) => {
<div className="flex-1 min-h-0 overflow-hidden">
<div className="h-full min-h-0 flex flex-col">
<div className={cn('min-w-0 min-h-0 h-full flex flex-col', isSidebarMode ? 'bg-transparent' : 'bg-muted/10')}>
<div className={cn('min-w-0 min-h-0 h-full flex flex-col', 'bg-transparent')}>
<div className={cn(isMobile ? 'h-10 px-1.5' : 'h-8 px-2')}>
<SortableTabsStrip
items={actionTabItems}
@@ -1809,17 +1778,16 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
onSelect={(tabID) => setActionTab(tabID as ActionTab)}
layoutMode="fit"
variant="active-pill"
inactiveTabsIconOnly={isSidebarMode && isMobile}
inactiveTabsIconOnly={isMobile}
className="h-full"
/>
</div>
{!isSidebarMode ? <div className="h-px bg-border/40" /> : null}
<ScrollableOverlay
as={ScrollShadow}
ref={actionPanelScrollRef}
outerClassName="flex-1 min-h-0"
className={cn('px-4', isSidebarMode ? 'pt-1 pb-4' : 'py-4')}
className={cn('px-4', 'pt-1 pb-4')}
disableHorizontal
preventOverscroll
>
@@ -1840,12 +1808,12 @@ export const GitView: React.FC<GitViewProps> = ({ 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<GitViewProps> = ({ mode = 'full' }) => {
onConfirm={handleStashAndRetry}
/>
<BranchPickerDialog
open={isBranchPickerOpen}
onOpenChange={setIsBranchPickerOpen}
project={branchPickerProject}
/>
</div>
);
};
@@ -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<GitHeaderProps> = ({
onSelectIdentity,
isApplyingIdentity,
isWorktreeMode,
isSidebarMode = false,
onOpenHistory,
onOpenBranchPicker,
}) => {
const isMobile = useUIStore((state) => state.isMobile);
@@ -217,38 +212,20 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
return null;
}
const useTwoRowHeader = isSidebarMode || isMobile;
const useTwoRowHeader = isMobile;
const managementButtons = (
<div className="flex items-center gap-1 shrink-0">
{onOpenBranchPicker ? (
<Tooltip delayDuration={useTwoRowHeader ? 300 : 1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={isSidebarMode ? 'h-8 w-8 px-0' : 'gap-1.5 px-2 py-1 h-8 typography-ui-label'}
onClick={onOpenBranchPicker}
>
<RiGitRepositoryLine className="size-4" />
{!isSidebarMode && <span className="git-header-label">Manage branches</span>}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Manage branches</TooltipContent>
</Tooltip>
) : null}
{onOpenHistory ? (
<Tooltip delayDuration={useTwoRowHeader ? 300 : 1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={isSidebarMode ? 'h-8 w-8 px-0' : 'gap-1.5 px-2 py-1 h-8 typography-ui-label'}
className="h-8 w-8 px-0"
onClick={onOpenHistory}
>
<RiHistoryLine className="size-4" />
{!isSidebarMode && <span className="git-header-label">History</span>}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>History</TooltipContent>
@@ -265,7 +242,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
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<GitHeaderProps> = ({
/>
);
if (useTwoRowHeader) {
return (
<header className={`@container/git-header px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'border-b border-border/40 bg-background'}`}>
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="min-w-0 flex-1">
{isWorktreeMode ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
remotes={remotes}
tooltipDelayMs={useTwoRowHeader ? 300 : 1000}
/>
)}
</div>
</div>
<div className="mt-1.5 flex items-center justify-between gap-2 min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-1">
{syncButtons}
{managementButtons}
</div>
<div className="min-w-0 max-w-[45%]">{identityControl}</div>
</div>
</header>
);
}
return (
<header className={`@container/git-header flex items-center gap-2 px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'border-b border-border/40 bg-background'}`}>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
{isWorktreeMode ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
remotes={remotes}
/>
)}
<div className="shrink-0">{syncButtons}</div>
<header className="@container/git-header px-3 py-2 bg-transparent">
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="min-w-0 flex-1">
{isWorktreeMode ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
remotes={remotes}
tooltipDelayMs={useTwoRowHeader ? 300 : 1000}
/>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{managementButtons}
{identityControl}
<div className="mt-1.5 flex items-center justify-between gap-2 min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-1">
{syncButtons}
{managementButtons}
</div>
<div className="min-w-0 max-w-[45%]">{identityControl}</div>
</div>
</header>
);
+7 -12
View File
@@ -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;
}