feat: add GitHub issue picker and API endpoints

Add GitHubIssuePickerDialog UI for selecting issues
Enable new session from GitHub issue from session sidebar
Implement GitHub issues/list/get/comments APIs across desktop, web, and VS Code
This commit is contained in:
Bohdan Triapitsyn
2026-01-24 00:30:34 +02:00
parent da20c647e2
commit 57f00be773
15 changed files with 1614 additions and 9 deletions
@@ -44,8 +44,10 @@ export const GitHubSettings: React.FC = () => {
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
if (desktop?.openExternal) {
try {
await desktop.openExternal(url);
return;
const result = await desktop.openExternal(url);
if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
return;
}
} catch {
// fall through
}
@@ -0,0 +1,550 @@
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 { opencodeClient } from '@/lib/opencode/client';
import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
import { createBranch } from '@/lib/gitApi';
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult } 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 sanitizeSlug = (value: string): string => {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, 80);
};
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 activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
const baseBranch = activeProject?.worktreeDefaults?.baseBranch || 'main';
const [query, setQuery] = React.useState('');
const [createInWorktree, setCreateInWorktree] = React.useState(false);
const [result, setResult] = React.useState<GitHubIssuesListResult | null>(null);
const [startingIssueNumber, setStartingIssueNumber] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!projectDirectory) {
setResult(null);
setError('No active project');
return;
}
if (!github?.issuesList) {
setResult(null);
setError('GitHub runtime API unavailable');
return;
}
setIsLoading(true);
setError(null);
try {
const next = await github.issuesList(projectDirectory);
setResult(next);
if (next.connected === false) {
setError(null);
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsLoading(false);
}
}, [github, projectDirectory]);
React.useEffect(() => {
if (!open) {
setQuery('');
setCreateInWorktree(false);
setStartingIssueNumber(null);
setError(null);
setResult(null);
setIsLoading(false);
return;
}
void refresh();
}, [open, refresh]);
const issues = React.useMemo(() => result?.issues ?? [], [result?.issues]);
const connected = Boolean(result?.connected);
const repoUrl = result?.repo?.url ?? null;
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<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 buildUniqueIssueBranchName = React.useCallback(
async (issue: GitHubIssue) => {
const titleSlug = sanitizeSlug(issue.title);
const base = titleSlug ? `issue-${issue.number}-${titleSlug}` : `issue-${issue.number}`;
const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined;
for (let attempt = 0; attempt < 6; attempt += 1) {
const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`;
try {
const created = await createBranch(projectDirectory || '', candidate, startPoint);
if (created?.success) {
return candidate;
}
} catch {
// try next
}
}
throw new Error('Failed to create issue branch');
},
[baseBranch, projectDirectory]
);
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 branchName = await buildUniqueIssueBranchName(issue);
const created = await createWorktreeSessionForBranch(projectDirectory, branchName);
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 promptText =
'Review this GitHub issue. Summarize requirements + unknowns, ask clarifying questions, gather any needed code context, then propose a plan and next actions. Do not implement until I confirm.';
const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
void opencodeClient.sendMessage({
id: sessionId,
providerID,
modelID,
agent: agentName,
variant,
text: promptText,
additionalParts: [{ 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);
}
}, [
buildUniqueIssueBranchName,
createInWorktree,
github,
onOpenChange,
projectDirectory,
resolveDefaultAgentName,
resolveDefaultModelSelection,
resolveDefaultVariant,
startingIssueNumber,
]);
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">
<RiGithubLine className="h-5 w-5" />
New Session From GitHub Issue
</DialogTitle>
<DialogDescription>
Seeds a new session with hidden issue context (title/body/labels/comments).
</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 issue 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 issues...
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8">GitHub not connected.</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-muted/30 rounded transition-colors cursor-pointer',
startingIssueNumber === directNumber && 'bg-muted/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 issue #{directNumber}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === 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 issues found' : 'No open issues found'}</div>
) : null}
{filtered.map((issue) => (
<div
key={issue.number}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-muted/30 rounded transition-colors cursor-pointer',
startingIssueNumber === issue.number && 'bg-muted/30'
)}
onClick={() => void startSession(issue.number)}
>
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0">
#{issue.number}
</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
{issue.title}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === issue.number ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
aria-label="Open in GitHub"
>
<RiExternalLinkLine className="h-4 w-4" />
</a>
)}
</div>
</div>
))}
</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 items-center gap-2">
<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 worktree</span>
<span className="typography-meta text-muted-foreground/70">(issue-&lt;number&gt;-&lt;slug&gt;)</span>
</div>
<div className="flex-1" />
{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(startingIssueNumber)}>
Refresh
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -42,6 +42,8 @@ import {
RiGitRepositoryLine,
RiLinkUnlinkM,
RiGithubLine,
RiMore2Line,
RiPencilAiLine,
RiShare2Line,
@@ -62,6 +64,7 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { isVSCodeRuntime } from '@/lib/desktop';
import { BranchPickerDialog } from './BranchPickerDialog';
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
@@ -139,6 +142,7 @@ interface SortableProjectItemProps {
onNewSession: () => void;
onNewWorktreeSession?: () => void;
onOpenBranchPicker?: () => void;
onNewSessionFromGitHubIssue?: () => void;
onOpenMultiRunLauncher: () => void;
onClose: () => void;
sentinelRef: (el: HTMLDivElement | null) => void;
@@ -163,6 +167,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
onNewSession,
onNewWorktreeSession,
onOpenBranchPicker,
onNewSessionFromGitHubIssue,
onOpenMultiRunLauncher,
onClose,
sentinelRef,
@@ -273,6 +278,12 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
Browse Branches
</DropdownMenuItem>
)}
{isRepo && !hideDirectoryControls && onNewSessionFromGitHubIssue && (
<DropdownMenuItem onClick={onNewSessionFromGitHubIssue}>
<RiGithubLine className="mr-1.5 h-4 w-4" />
New session from GitHub issue
</DropdownMenuItem>
)}
{isRepo && !hideDirectoryControls && (
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
<ArrowsMerge className="mr-1.5 h-4 w-4" />
@@ -399,6 +410,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
@@ -1631,6 +1643,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
createWorktreeSession();
}}
onOpenBranchPicker={() => setBranchPickerOpen(true)}
onNewSessionFromGitHubIssue={() => {
if (projectKey !== activeProjectId) {
setActiveProject(projectKey);
}
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
setIssuePickerOpen(true);
}}
onOpenMultiRunLauncher={() => {
if (projectKey !== activeProjectId) {
setActiveProject(projectKey);
@@ -1672,6 +1694,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
projects={normalizedProjects}
activeProjectId={activeProjectId}
/>
<GitHubIssuePickerDialog
open={issuePickerOpen}
onOpenChange={setIssuePickerOpen}
/>
</div>
);
};
@@ -52,8 +52,10 @@ const openExternal = async (url: string) => {
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
if (desktop?.openExternal) {
try {
await desktop.openExternal(url);
return;
const result = await desktop.openExternal(url);
if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
return;
}
} catch {
// fall through
}
+52
View File
@@ -560,6 +560,54 @@ export type GitHubPullRequestMergeResult = {
message?: string;
};
export type GitHubIssueLabel = {
name: string;
color?: string;
};
export type GitHubIssueSummary = {
number: number;
title: string;
url: string;
state: 'open' | 'closed';
author?: GitHubUserSummary | null;
labels?: GitHubIssueLabel[];
};
export type GitHubIssue = GitHubIssueSummary & {
body?: string;
assignees?: GitHubUserSummary[];
createdAt?: string;
updatedAt?: string;
};
export type GitHubIssueComment = {
id: number;
url: string;
body: string;
author?: GitHubUserSummary | null;
createdAt?: string;
updatedAt?: string;
};
export type GitHubIssuesListResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
issues?: GitHubIssueSummary[];
};
export type GitHubIssueGetResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
issue?: GitHubIssue | null;
};
export type GitHubIssueCommentsResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
comments?: GitHubIssueComment[];
};
export type GitHubAuthStatus = {
connected: boolean;
user?: GitHubUserSummary | null;
@@ -591,6 +639,10 @@ export interface GitHubAPI {
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
issuesList(directory: string): Promise<GitHubIssuesListResult>;
issueGet(directory: string, number: number): Promise<GitHubIssueGetResult>;
issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult>;
}
export interface RuntimeAPIs {
+6 -2
View File
@@ -598,6 +598,7 @@ class OpencodeService {
modelID: string;
text: string;
prefaceText?: string;
prefaceTextSynthetic?: boolean;
agent?: string;
variant?: string;
files?: Array<{
@@ -609,6 +610,7 @@ class OpencodeService {
/** Additional text/file parts to include (for batch sending queued messages) */
additionalParts?: Array<{
text: string;
synthetic?: boolean;
files?: Array<{
type: 'file';
mime: string;
@@ -630,7 +632,8 @@ class OpencodeService {
if (params.prefaceText && params.prefaceText.trim()) {
parts.push({
type: 'text',
text: params.prefaceText
text: params.prefaceText,
synthetic: params.prefaceTextSynthetic !== false,
});
}
@@ -663,7 +666,8 @@ class OpencodeService {
if (additional.text && additional.text.trim()) {
parts.push({
type: 'text',
text: additional.text
text: additional.text,
...(additional.synthetic ? { synthetic: true } : {}),
});
}
if (additional.files && additional.files.length > 0) {