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, RiGithubLine, 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 { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary } from '@/lib/api/types'; 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, }: { 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 setSidebarSection = useUIStore((state) => state.setSidebarSection); const activeProject = useProjectsStore((state) => state.getActiveProject()); const projectDirectory = activeProject?.path ?? null; 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 refresh = React.useCallback(async () => { if (!projectDirectory) { setResult(null); setError('No active project'); return; } if (githubAuthChecked && githubAuthStatus?.connected === false) { setResult({ connected: false }); setIssues([]); setHasMore(false); setPage(1); setError(null); return; } if (!github?.issuesList) { setResult(null); setError('GitHub runtime API unavailable'); 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]); 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 = 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('Failed to load more issues', { description: message }); } finally { setIsLoadingMore(false); } }, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]); 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(() => { setSidebarSection('settings'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSidebarSection]); const filtered = React.useMemo(() => { const q = query.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, query]); const directNumber = React.useMemo(() => parseIssueNumber(query), [query]); const resolveDefaultAgentName = React.useCallback((): string | undefined => { const configState = useConfigStore.getState(); const visibleAgents = configState.getVisibleAgents(); if (configState.settingsDefaultAgent) { const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); if (settingsAgent) { return settingsAgent.name; } } return ( visibleAgents.find((agent) => agent.name === 'build')?.name || visibleAgents[0]?.name ); }, []); const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => { const configState = useConfigStore.getState(); const settingsDefaultModel = configState.settingsDefaultModel; if (!settingsDefaultModel) { return null; } const parts = settingsDefaultModel.split('/'); if (parts.length !== 2) { return null; } const [providerID, modelID] = parts; if (!providerID || !modelID) { return null; } const modelMetadata = configState.getModelMetadata(providerID, modelID); if (!modelMetadata) { return null; } return { providerID, modelID }; }, []); const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { const configState = useConfigStore.getState(); const settingsDefaultVariant = configState.settingsDefaultVariant; if (!settingsDefaultVariant) { return undefined; } const provider = configState.providers.find((p) => p.id === providerID); const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as | { variants?: Record } | undefined; const variants = model?.variants; if (!variants) { return undefined; } if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) { return undefined; } return settingsDefaultVariant; }, []); const startSession = React.useCallback(async (issueNumber: number) => { 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 ?? []; 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 ); if (!created?.id) { throw new Error('Failed to create worktree session'); } return created.id; } const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null); if (!session?.id) { throw new Error('Failed to create session'); } return session.id; })(); // Ensure worktree-based sessions also get the issue title. void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); try { useSessionStore.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 = 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 issue using the provided issue context: title, body, labels, assignees, comments, metadata.'; const instructionsText = `Review this issue using the provided issue context. Process: - First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: . - Gather any needed repository context (code, config, docs) to validate assumptions. - After gathering, if anything is still unclear or cannot be verified, do not speculate—state what’s missing and ask targeted questions. Output rules: - Compact output; pick ONE template below and omit the others. - No emojis. No code snippets. No fenced blocks. - Short inline code identifiers allowed. - Reference evidence with file paths and line ranges when applicable; if exact lines aren’t available, cite the file and say “approx” + why. - Keep the entire response under ~300 words. Templates (choose one): Bug: - Summary (1-2 sentences) - Likely cause (max 2) - Repro/diagnostics needed (max 3) - Fix approach (max 4 steps) - Verification (max 3) Feature: - Summary (1-2 sentences) - Requirements (max 4) - Unknowns/questions (max 4) - Proposed plan (max 5 steps) - Verification (max 3) Question/Support: - Summary (1-2 sentences) - Answer/guidance (max 6 lines) - Missing info (max 4) Do not implement changes until I confirm; end with: “Next actions: <1 sentence>”.`; const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); 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 issue context', { description: message, }); }); toast.success('Session created from issue'); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to start session', { description: message }); } finally { setStartingIssueNumber(null); } }, [createInWorktree, github, onOpenChange, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]); return ( New Session From GitHub Issue Seeds a new session with hidden issue context (title/body/labels/comments).
setQuery(e.target.value)} className="pl-9 w-full" />
{!projectDirectory ? (
No active project selected.
) : null} {!github ? (
GitHub runtime API unavailable.
) : null} {isLoading ? (
Loading issues...
) : null} {connected === false ? (
GitHub not connected. Connect your GitHub account in settings.
) : null} {error ? (
{error}
) : null} {directNumber && projectDirectory && github && connected ? (
void startSession(directNumber)} > #

Use issue #{directNumber}

{startingIssueNumber === directNumber ? ( ) : null}
) : null} {filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
{query ? 'No issues found' : 'No open issues found'}
) : null} {filtered.map((issue) => (
void startSession(issue.number)} > #{issue.number}

{issue.title}

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

Actions

setCreateInWorktree((v) => !v)} onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setCreateInWorktree((v) => !v); } }} > Create in worktree (issue-<number>-<slug>)
{repoUrl ? ( ) : null}
); }