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:
@@ -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-<number>-<slug>)</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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user