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 { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import { cn } from '@/lib/utils'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import * as sessionActions from '@/sync/session-actions'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { useDeviceInfo } from '@/lib/device'; import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; const parseIssueNumber = (value: string): number | null => { const trimmed = value.trim(); if (!trimmed) return null; const urlMatch = trimmed.match(/\/issues\/(\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 buildIssueContextText = (args: { repo: GitHubIssuesListResult['repo'] | undefined; issue: GitHubIssue; comments: GitHubIssueComment[]; }) => { const payload = { repo: args.repo ?? null, issue: args.issue, comments: args.comments, }; return `GitHub issue context (JSON)\n${JSON.stringify(payload, null, 2)}`; }; 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 { t } = useI18n(); 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 isMobile = useUIStore((state) => state.isMobile); const { isTablet } = useDeviceInfo(); const alwaysShowActions = isMobile || isTablet; const activeProject = useProjectsStore((state) => state.getActiveProject()); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const projectDirectory = React.useMemo(() => { return activeProject?.path?.trim() || currentDirectory?.trim() || null; }, [activeProject?.path, currentDirectory]); const [query, setQuery] = React.useState(''); const [createInWorktree, setCreateInWorktree] = React.useState(false); const [result, setResult] = React.useState(null); const [issues, setIssues] = React.useState([]); const [page, setPage] = React.useState(1); const [hasMore, setHasMore] = React.useState(false); const [startingIssueNumber, setStartingIssueNumber] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); const [isLoadingMore, setIsLoadingMore] = React.useState(false); const [error, setError] = React.useState(null); const directNumber = React.useMemo(() => parseIssueNumber(query), [query]); const debouncedQuery = useDebouncedValue(query, 350); const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber; const refresh = React.useCallback(async () => { if (!projectDirectory) { setResult(null); setError(t('session.githubIssuePicker.error.noActiveProject')); return; } if (githubAuthChecked && githubAuthStatus?.connected === false) { setResult({ connected: false }); setIssues([]); setHasMore(false); setPage(1); setError(null); return; } if (!github?.issuesList) { setResult(null); setError(t('session.githubIssuePicker.error.runtimeUnavailable')); return; } setIsLoading(true); setError(null); try { const next = await github.issuesList(projectDirectory, { page: 1 }); setResult(next); setIssues(next.issues ?? []); 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, t]); React.useEffect(() => { if (!open || !projectDirectory) return; if (githubAuthChecked && githubAuthStatus?.connected === false) return; if (!github?.issuesList) return; if (!debouncedQuery.trim() || directNumber) { void refresh(); return; } const controller = new AbortController(); setIsLoading(true); setError(null); github.issuesList(projectDirectory, { page: 1, query: debouncedQuery.trim() }) .then((next) => { if (controller.signal.aborted) return; setResult(next); setIssues(next.issues ?? []); setPage(next.page ?? 1); setHasMore(Boolean(next.hasMore)); }) .catch((e) => { if (controller.signal.aborted) return; setError(e instanceof Error ? e.message : String(e)); }) .finally(() => { if (!controller.signal.aborted) setIsLoading(false); }); return () => controller.abort(); }, [open, projectDirectory, github, githubAuthChecked, githubAuthStatus, debouncedQuery, directNumber, refresh, t]); const loadMore = React.useCallback(async () => { if (!projectDirectory) return; if (!github?.issuesList) return; if (isLoadingMore || isLoading) return; if (!hasMore) return; setIsLoadingMore(true); try { const nextPage = page + 1; const next = isTextSearch ? await github.issuesList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() }) : await github.issuesList(projectDirectory, { page: nextPage }); setResult(next); setIssues((prev) => [...prev, ...(next.issues ?? [])]); setPage(next.page ?? nextPage); setHasMore(Boolean(next.hasMore)); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(t('session.githubIssuePicker.toast.loadMoreFailed'), { description: message }); } finally { setIsLoadingMore(false); } }, [github, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]); React.useEffect(() => { if (!open) { setQuery(''); setCreateInWorktree(false); setStartingIssueNumber(null); setError(null); setResult(null); setIssues([]); setPage(1); setHasMore(false); setIsLoading(false); return; } void refresh(); }, [open, refresh]); React.useEffect(() => { if (!open) return; if (githubAuthChecked && githubAuthStatus?.connected === false) { setResult({ connected: false }); setIssues([]); 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 resolveDefaultAgentName = React.useCallback((): string | undefined => { const configState = useConfigStore.getState(); const visibleAgents = configState.getVisibleAgents(); if (configState.settingsDefaultAgent) { const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); if (settingsAgent) { return settingsAgent.name; } } return ( visibleAgents.find((agent) => agent.name === 'build')?.name || visibleAgents[0]?.name ); }, []); const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => { const configState = useConfigStore.getState(); const settingsDefaultModel = configState.settingsDefaultModel; if (!settingsDefaultModel) { return null; } const parsed = parseModelIdentifier(settingsDefaultModel); if (!parsed) { return null; } const { providerId: providerID, modelId: modelID } = parsed; const modelMetadata = configState.getModelMetadata(providerID, modelID); if (!modelMetadata) { return null; } return { providerID, modelID }; }, []); const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { const configState = useConfigStore.getState(); const settingsDefaultVariant = configState.settingsDefaultVariant; const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID ? configState.currentVariant : undefined; const provider = configState.providers.find((p) => p.id === providerID); const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as | { variants?: Record } | undefined; const variants = model?.variants; if (!variants) { return settingsDefaultVariant || currentVariant || undefined; } if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) { return settingsDefaultVariant; } if (currentVariant && Object.prototype.hasOwnProperty.call(variants, currentVariant)) { return currentVariant; } return undefined; }, []); const startSession = React.useCallback(async (issueNumber: number, sourceRepo?: GitHubRepoSelector | null) => { if (mode === 'select') { // In select mode, fetch full issue details and return via onSelect if (!projectDirectory) { toast.error(t('session.githubIssuePicker.error.noActiveProject')); return; } if (!github?.issueGet || !github?.issueComments) { toast.error(t('session.githubIssuePicker.error.runtimeUnavailable')); return; } if (startingIssueNumber) return; setStartingIssueNumber(issueNumber); try { const issueRes = await github.issueGet(projectDirectory, issueNumber, { sourceRepo }); if (issueRes.connected === false) { toast.error(t('session.githubIssuePicker.error.notConnected')); return; } if (!issueRes.repo) { toast.error(t('session.githubIssuePicker.error.repoNotResolvable'), { description: t('session.githubIssuePicker.error.repoMustBeGithub'), }); return; } const issue = issueRes.issue; if (!issue) { toast.error(t('session.githubIssuePicker.error.issueNotFound')); return; } const commentsRes = await github.issueComments(projectDirectory, issueNumber, { sourceRepo }); if (commentsRes.connected === false) { toast.error(t('session.githubIssuePicker.error.notConnected')); 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(t('session.githubIssuePicker.toast.loadIssueDetailsFailed'), { description: message }); } finally { setStartingIssueNumber(null); } return; } if (!projectDirectory) { toast.error(t('session.githubIssuePicker.error.noActiveProject')); return; } if (!github?.issueGet || !github?.issueComments) { toast.error(t('session.githubIssuePicker.error.runtimeUnavailable')); return; } if (startingIssueNumber) return; setStartingIssueNumber(issueNumber); try { const issueRes = await github.issueGet(projectDirectory, issueNumber, { sourceRepo }); if (issueRes.connected === false) { toast.error(t('session.githubIssuePicker.error.notConnected')); return; } if (!issueRes.repo) { toast.error(t('session.githubIssuePicker.error.repoNotResolvable'), { description: t('session.githubIssuePicker.error.repoMustBeGithub'), }); return; } const issue = issueRes.issue; if (!issue) { toast.error(t('session.githubIssuePicker.error.issueNotFound')); return; } const commentsRes = await github.issueComments(projectDirectory, issueNumber, { sourceRepo }); if (commentsRes.connected === false) { toast.error(t('session.githubIssuePicker.error.notConnected')); return; } const comments = commentsRes.comments ?? []; const sessionTitle = `#${issue.number} ${issue.title}`.trim(); const { sessionId } = await (async () => { if (createInWorktree) { const preferred = `issue-${issue.number}-${generateBranchSlug()}`; const created = await createWorktreeSessionForNewBranch( projectDirectory, preferred, undefined, { returnAfterDirectoryCreated: true } ); if (!created?.id) { throw new Error('Failed to create worktree session'); } return { sessionId: created.id, sessionDirectory: created.path }; } const session = await sessionActions.createSession(sessionTitle, projectDirectory, null); if (!session?.id) { throw new Error('Failed to create session'); } return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory }; })(); // Ensure worktree-based sessions also get the issue title. void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); try { useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); } catch { // ignore } // Close modal immediately after session exists (don't wait for message send). onOpenChange(false); const configState = useConfigStore.getState(); const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; const defaultModel = resolveDefaultModelSelection(); const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID; const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined; if (!providerID || !modelID) { toast.error(t('session.githubIssuePicker.error.noModelSelected')); return; } const variant = resolveDefaultVariant(providerID, modelID); const visiblePromptText = await renderMagicPrompt('github.issue.review.visible', { issue_number: String(issue.number), }); const instructionsText = await renderMagicPrompt('github.issue.review.instructions'); const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); void useSessionUIStore.getState().sendMessage( visiblePromptText, providerID, modelID, agentName, undefined, undefined, [ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], variant, undefined, { sessionId }, ).catch((e) => { const message = e instanceof Error ? e.message : String(e); toast.error(t('session.githubIssuePicker.toast.sendContextFailed'), { description: message, }); }); toast.success(t('session.githubIssuePicker.toast.sessionCreated')); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(t('session.githubIssuePicker.toast.startSessionFailed'), { description: message }); } finally { setStartingIssueNumber(null); } }, [createInWorktree, github, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber, t]); const title = mode === 'select' ? t('session.githubIssuePicker.title.select') : t('session.githubIssuePicker.title.createSession'); const description = mode === 'select' ? t('session.githubIssuePicker.description.select') : t('session.githubIssuePicker.description.createSession'); const content = ( <>
setQuery(e.target.value)} className="pl-9 w-full" />
{!projectDirectory ? (
{t('session.githubIssuePicker.empty.noActiveProject')}
) : null} {!github ? (
{t('session.githubIssuePicker.empty.runtimeUnavailable')}
) : null} {isLoading ? (
{t('session.githubIssuePicker.loading.issues')}
) : null} {connected === false ? (
{t('session.githubIssuePicker.empty.notConnected')}
) : null} {error ? (
{error}
) : null} {directNumber && projectDirectory && github && connected ? (
void startSession(directNumber)} > #

{t('session.githubIssuePicker.actions.useIssue', { number: directNumber })}

{startingIssueNumber === directNumber ? ( ) : null}
) : null} {issues.length === 0 && !isLoading && connected && github && projectDirectory ? (
{debouncedQuery.trim() ? t('session.githubIssuePicker.empty.noIssuesFound') : t('session.githubIssuePicker.empty.noOpenIssuesFound')}
) : null} {issues.map((issue) => (
void startSession(issue.number, issue.sourceRepo)} > #{issue.number}

{issue.title}

{issue.sourceRepo?.source === 'upstream' ? ( {issue.sourceRepo.owner}/{issue.sourceRepo.repo} ) : null}
))} {hasMore && connected && projectDirectory && github ? (
) : null}
{mode !== 'select' && (

{t('session.githubIssuePicker.actions.sectionTitle')}

setCreateInWorktree((v) => !v)} onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setCreateInWorktree((v) => !v); } }} > {t('session.githubIssuePicker.actions.createInWorktree')} (issue-<number>-<slug>)
{repoUrl ? ( ) : null}
)} ); if (isMobile) { return ( onOpenChange(false)} renderHeader={(closeButton) => (

{title}

{closeButton}

{description}

)} > {content}
); } return ( {title} {description} {content} ); }