feat(ui): attach GitLab issues and merge requests to sessions

This commit is contained in:
2026-08-16 16:23:36 +00:00
parent 267db3fb9d
commit 454b3591e5
15 changed files with 1920 additions and 11 deletions
+41 -10
View File
@@ -61,6 +61,9 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
import { GitLabIssuePickerDialog } from '@/components/session/GitLabIssuePickerDialog';
import { GitLabMrPickerDialog } from '@/components/session/GitLabMrPickerDialog';
import { useGitProvider } from '@/lib/gitProvider';
import { Icon } from "@/components/icon/Icon";
import { DraftPresetChips } from './DraftPresetChips';
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
@@ -376,6 +379,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const { currentTheme } = useThemeSystem();
const chatSearchDirectory = useChatSearchDirectory();
const isGitRepo = useIsGitRepo(currentDirectory);
const gitProvider = useGitProvider(currentDirectory);
const currentGitStatus = useGitStore((state) =>
currentDirectory ? state.directories.get(currentDirectory)?.status ?? null : null,
);
@@ -663,6 +667,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Issue linking state
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [prPickerOpen, setPrPickerOpen] = React.useState(false);
const [gitlabIssuePickerOpen, setGitlabIssuePickerOpen] = React.useState(false);
const [gitlabMrPickerOpen, setGitlabMrPickerOpen] = React.useState(false);
const [linkedIssue, setLinkedIssue] = React.useState<{
number: number;
title: string;
@@ -945,12 +951,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, [isExpandedInput, setExpandedInput]);
const openIssuePicker = React.useCallback(() => {
setIssuePickerOpen(true);
}, []);
if (gitProvider === 'gitlab') {
setGitlabIssuePickerOpen(true);
} else {
setIssuePickerOpen(true);
}
}, [gitProvider]);
const openPrPicker = React.useCallback(() => {
setPrPickerOpen(true);
}, []);
if (gitProvider === 'gitlab') {
setGitlabMrPickerOpen(true);
} else {
setPrPickerOpen(true);
}
}, [gitProvider]);
const getSubmitErrorMessage = (error: unknown, fallback: string) => {
const message = error instanceof Error ? error.message : '';
@@ -2545,7 +2559,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
author={linkedIssue.author}
openInBrowserLabel={t('chat.chatInput.linked.issue.openInBrowserAria')}
removeLabel={t('chat.chatInput.linked.issue.removeAria')}
onReopenPicker={() => setIssuePickerOpen(true)}
onReopenPicker={openIssuePicker}
onRemove={() => setLinkedIssue(null)}
/>
) : null}
@@ -2558,7 +2572,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
branches={{ head: linkedPr.head, base: linkedPr.base }}
openInBrowserLabel={t('chat.chatInput.linked.pr.openInBrowserAria')}
removeLabel={t('chat.chatInput.linked.pr.removeAria')}
onReopenPicker={() => setPrPickerOpen(true)}
onReopenPicker={openPrPicker}
onRemove={() => setLinkedPr(null)}
/>
) : null}
@@ -2878,6 +2892,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setLinkedIssue(null);
}}
/>
<GitLabIssuePickerDialog
open={gitlabIssuePickerOpen}
onOpenChange={setGitlabIssuePickerOpen}
mode="select"
onSelect={(issue) => {
setLinkedIssue(issue);
setLinkedPr(null);
}}
/>
<GitLabMrPickerDialog
open={gitlabMrPickerOpen}
onOpenChange={setGitlabMrPickerOpen}
onSelect={(pr) => {
setLinkedPr(pr);
setLinkedIssue(null);
}}
/>
<ReviewFlowDialog
open={reviewDialogOpen}
onOpenChange={setReviewDialogOpen}
@@ -2944,8 +2975,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
requestAnimationFrame(openIssuePicker);
}}
>
<Icon name="github" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{t('chat.chatInput.actions.linkGithubIssue')}
<Icon name={gitProvider === 'gitlab' ? 'git-branch' : 'github'} className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabIssue') : t('chat.chatInput.actions.linkGithubIssue')}
</button>
<button
type="button"
@@ -2956,8 +2987,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
requestAnimationFrame(openPrPicker);
}}
>
<Icon name="git-pull-request" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{t('chat.chatInput.actions.linkGithubPr')}
<Icon name={gitProvider === 'gitlab' ? 'git-merge' : 'git-pull-request'} className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabMr') : t('chat.chatInput.actions.linkGithubPr')}
</button>
</div>
</MobileOverlayPanel>
@@ -27,7 +27,7 @@ type ComposerAttachmentControlsProps = {
handlePickLocalFiles: () => void;
openIssuePicker: () => void;
openPrPicker: () => void;
/** Only shows the GitHub issue/PR attach actions when the repo is GitHub. */
/** Shows the GitHub issue/PR or GitLab issue/MR attach actions based on the repo provider. */
gitProvider?: GitProvider | null;
onOpenSettings?: () => void;
onMenuOpenChange?: (open: boolean) => void;
@@ -121,6 +121,25 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
{t('chat.chatInput.actions.linkGithubPr')}
</DropdownMenuItem>
</>
) : gitProvider === 'gitlab' ? (
<>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(openIssuePicker);
}}
>
<Icon name="git-branch"/>
{t('chat.chatInput.actions.linkGitlabIssue')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(openPrPicker);
}}
>
<Icon name="git-merge"/>
{t('chat.chatInput.actions.linkGitlabMr')}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
@@ -0,0 +1,733 @@
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 { buildLinkedIssue } from '@/lib/linkedIssues';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
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 { GitLabIssue, GitLabIssueComment, GitLabIssuesListResult, GitLabIssueSummary } 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: GitLabIssuesListResult['repo'] | undefined;
issue: GitLabIssue;
comments: GitLabIssueComment[];
}) => {
const payload = {
repo: args.repo ?? null,
issue: args.issue,
comments: args.comments,
};
return `GitLab issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
};
export function GitLabIssuePickerDialog({
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 { gitlab } = useRuntimeAPIs();
const gitlabAuthStatus = useGitLabAuthStore((state) => state.status);
const gitlabAuthChecked = useGitLabAuthStore((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<GitLabIssuesListResult | null>(null);
const [issues, setIssues] = React.useState<GitLabIssueSummary[]>([]);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const [startingIssueNumber, setStartingIssueNumber] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(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.gitlabIssuePicker.error.noActiveProject'));
return;
}
if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) {
setResult({ connected: false, issues: [], page: 1, hasMore: false });
setIssues([]);
setHasMore(false);
setPage(1);
setError(null);
return;
}
if (!gitlab?.issuesList) {
setResult(null);
setError(t('session.gitlabIssuePicker.error.runtimeUnavailable'));
return;
}
setIsLoading(true);
setError(null);
try {
const next = await gitlab.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);
}
}, [gitlab, gitlabAuthChecked, gitlabAuthStatus, projectDirectory, t]);
React.useEffect(() => {
if (!open || !projectDirectory) return;
if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return;
if (!gitlab?.issuesList) return;
if (!debouncedQuery.trim() || directNumber) {
void refresh();
return;
}
const controller = new AbortController();
setIsLoading(true);
setError(null);
gitlab.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, gitlab, gitlabAuthChecked, gitlabAuthStatus, debouncedQuery, directNumber, refresh, t]);
const loadMore = React.useCallback(async () => {
if (!projectDirectory) return;
if (!gitlab?.issuesList) return;
if (isLoadingMore || isLoading) return;
if (!hasMore) return;
setIsLoadingMore(true);
try {
const nextPage = page + 1;
const next = isTextSearch
? await gitlab.issuesList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() })
: await gitlab.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.gitlabIssuePicker.toast.loadMoreFailed'), { description: message });
} finally {
setIsLoadingMore(false);
}
}, [gitlab, 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 (gitlabAuthChecked && gitlabAuthStatus?.connected === false) {
setResult({ connected: false, issues: [], page: 1, hasMore: false });
setIssues([]);
setHasMore(false);
setPage(1);
setError(null);
}
}, [gitlabAuthChecked, gitlabAuthStatus, open]);
const connected = gitlabAuthChecked ? result?.connected !== false : true;
const repoUrl = result?.repo?.url ?? null;
const openGitLabSettings = React.useCallback(() => {
setSettingsPage('git');
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<string, unknown>) => (m as { id?: string }).id === modelID) as
| { variants?: Record<string, unknown> }
| 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) => {
if (mode === 'select') {
// In select mode, fetch full issue details and return via onSelect
if (!projectDirectory) {
toast.error(t('session.gitlabIssuePicker.error.noActiveProject'));
return;
}
if (!gitlab?.issueGet || !gitlab?.issueComments) {
toast.error(t('session.gitlabIssuePicker.error.runtimeUnavailable'));
return;
}
if (startingIssueNumber) return;
setStartingIssueNumber(issueNumber);
try {
const issueRes = await gitlab.issueGet(projectDirectory, issueNumber);
if (issueRes.connected === false) {
toast.error(t('session.gitlabIssuePicker.error.notConnected'));
return;
}
if (!issueRes.repo) {
toast.error(t('session.gitlabIssuePicker.error.repoNotResolvable'), {
description: t('session.gitlabIssuePicker.error.repoMustBeGitlab'),
});
return;
}
const issue = issueRes.issue;
if (!issue) {
toast.error(t('session.gitlabIssuePicker.error.issueNotFound'));
return;
}
const commentsRes = await gitlab.issueComments(projectDirectory, issueNumber);
if (commentsRes.connected === false) {
toast.error(t('session.gitlabIssuePicker.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.username,
avatarUrl: issue.author.avatarUrl,
} : undefined,
});
}
onOpenChange(false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error(t('session.gitlabIssuePicker.toast.loadIssueDetailsFailed'), { description: message });
} finally {
setStartingIssueNumber(null);
}
return;
}
if (!projectDirectory) {
toast.error(t('session.gitlabIssuePicker.error.noActiveProject'));
return;
}
if (!gitlab?.issueGet || !gitlab?.issueComments) {
toast.error(t('session.gitlabIssuePicker.error.runtimeUnavailable'));
return;
}
if (startingIssueNumber) return;
setStartingIssueNumber(issueNumber);
try {
const issueRes = await gitlab.issueGet(projectDirectory, issueNumber);
if (issueRes.connected === false) {
toast.error(t('session.gitlabIssuePicker.error.notConnected'));
return;
}
if (!issueRes.repo) {
toast.error(t('session.gitlabIssuePicker.error.repoNotResolvable'), {
description: t('session.gitlabIssuePicker.error.repoMustBeGitlab'),
});
return;
}
const issue = issueRes.issue;
if (!issue) {
toast.error(t('session.gitlabIssuePicker.error.issueNotFound'));
return;
}
const commentsRes = await gitlab.issueComments(projectDirectory, issueNumber);
if (commentsRes.connected === false) {
toast.error(t('session.gitlabIssuePicker.error.notConnected'));
return;
}
const comments = commentsRes.comments ?? [];
const sessionTitle = `#${issue.number} ${issue.title}`.trim();
const { sessionId, sessionDirectory } = 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.gitlabIssuePicker.error.noModelSelected'));
return;
}
const variant = resolveDefaultVariant(providerID, modelID);
const visiblePromptText = await renderMagicPrompt('gitlab.issue.review.visible', {
issue_number: String(issue.number),
});
const instructionsText = await renderMagicPrompt('gitlab.issue.review.instructions');
const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
// Record the thread this session was created for, so it stays visible as
// a context source once the opening message has scrolled away. A
// snapshot, never re-fetched; a failed write must not fail the flow.
void sessionActions.setLinkedIssue(
sessionId,
sessionDirectory,
buildLinkedIssue({
url: issue.url,
number: issue.number,
title: issue.title,
kind: 'issue',
author: issue.author ? {
login: issue.author.username,
avatarUrl: issue.author.avatarUrl,
} : undefined,
linkedAt: Date.now(),
}),
true,
).catch(() => undefined);
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.gitlabIssuePicker.toast.sendContextFailed'), {
description: message,
});
});
toast.success(t('session.gitlabIssuePicker.toast.sessionCreated'));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error(t('session.gitlabIssuePicker.toast.startSessionFailed'), { description: message });
} finally {
setStartingIssueNumber(null);
}
}, [createInWorktree, gitlab, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber, t]);
const title = mode === 'select' ? t('session.gitlabIssuePicker.title.select') : t('session.gitlabIssuePicker.title.createSession');
const description = mode === 'select'
? t('session.gitlabIssuePicker.description.select')
: t('session.gitlabIssuePicker.description.createSession');
const content = (
<>
<div className="relative mt-2">
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('session.gitlabIssuePicker.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
<div className={cn(isMobile ? 'min-h-0 mt-2' : 'flex-1 overflow-y-auto mt-2')}>
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{t('session.gitlabIssuePicker.empty.noActiveProject')}</div>
) : null}
{!gitlab ? (
<div className="text-center text-muted-foreground py-8">{t('session.gitlabIssuePicker.empty.runtimeUnavailable')}</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
{t('session.gitlabIssuePicker.loading.issues')}
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8 space-y-3">
<div>{t('session.gitlabIssuePicker.empty.notConnected')}</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={openGitLabSettings}>
{t('session.gitlabIssuePicker.actions.openSettings')}
</Button>
</div>
</div>
) : null}
{error ? (
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
) : null}
{directNumber && projectDirectory && gitlab && connected ? (
<div
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
startingIssueNumber === 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">
{t('session.gitlabIssuePicker.actions.useIssue', { number: directNumber })}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === directNumber ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
) : null}
{issues.length === 0 && !isLoading && connected && gitlab && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{debouncedQuery.trim() ? t('session.gitlabIssuePicker.empty.noIssuesFound') : t('session.gitlabIssuePicker.empty.noOpenIssuesFound')}</div>
) : null}
{issues.map((issue) => (
<div
key={issue.number}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
startingIssueNumber === issue.number && 'bg-interactive-selection/30'
)}
onClick={() => void startSession(issue.number)}
>
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0">
#{issue.number}
</span>
<div className="flex-1 min-w-0 ml-0.5">
<p className="typography-small text-foreground truncate">
{issue.title}
</p>
</div>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === issue.number ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors",
alwaysShowActions ? "flex" : "hidden group-hover:flex"
)}
onClick={(e) => e.stopPropagation()}
aria-label={t('session.gitlabIssuePicker.actions.openInGitLabAria')}
>
<Icon name="external-link" className="h-4 w-4" />
</a>
)}
</div>
</div>
))}
{hasMore && connected && projectDirectory && gitlab ? (
<div className="py-2 flex justify-center">
<button
type="button"
onClick={() => void loadMore()}
disabled={isLoadingMore || Boolean(startingIssueNumber)}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
(isLoadingMore || Boolean(startingIssueNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
)}
>
{isLoadingMore ? (
<span className="inline-flex items-center gap-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
{t('session.gitlabIssuePicker.loading.more')}
</span>
) : (
t('session.gitlabIssuePicker.actions.loadMore')
)}
</button>
</div>
) : 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">{t('session.gitlabIssuePicker.actions.sectionTitle')}</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm: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={t('session.gitlabIssuePicker.actions.toggleWorktreeAria')}
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 ? (
<Icon name="checkbox" className="h-4 w-4 text-primary" />
) : (
<Icon name="checkbox-blank" className="h-4 w-4" />
)}
</button>
<span className="typography-meta text-muted-foreground">{t('session.gitlabIssuePicker.actions.createInWorktree')}</span>
<span className="typography-meta text-muted-foreground/70 hidden sm:inline">(issue-&lt;number&gt;-&lt;slug&gt;)</span>
</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">
<Icon name="external-link" className="size-4" />
{t('session.gitlabIssuePicker.actions.openRepo')}
</a>
</Button>
) : null}
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading || Boolean(startingIssueNumber)}>
{t('session.gitlabIssuePicker.actions.refresh')}
</Button>
</div>
</div>
</div>
)}
</>
);
if (isMobile) {
return (
<MobileOverlayPanel
open={open}
title={title}
onClose={() => onOpenChange(false)}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-1.5 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">{title}</h2>
{closeButton}
</div>
<p className="typography-small text-muted-foreground">{description}</p>
</div>
)}
>
{content}
</MobileOverlayPanel>
);
}
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">
<Icon name="git-branch" className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,477 @@
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 { Checkbox } from '@/components/ui/checkbox';
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 { useUIStore } from '@/stores/useUIStore';
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { useDeviceInfo } from '@/lib/device';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import type { GitLabMergeRequestContextResult, GitLabMergeRequestSummary, GitLabMergeRequestsListResult } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
const parsePrNumber = (value: string): number | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const urlMatch = trimmed.match(/\/merge_requests\/(\d+)(?:\b|\/|$)/i);
if (urlMatch) {
const parsed = Number(urlMatch[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
const shortMatch = trimmed.match(/^!?(\d+)$/);
if (shortMatch) {
const parsed = Number(shortMatch[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
return null;
};
const buildMergeRequestContextText = (payload: GitLabMergeRequestContextResult) => {
return `GitLab merge request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
};
export function GitLabMrPickerDialog({
open,
onOpenChange,
onSelect,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect?: (mr: {
number: number;
title: string;
url: string;
head: string;
base: string;
includeDiff: boolean;
instructionsText: string;
contextText: string;
author?: { login: string; avatarUrl?: string };
}) => void;
}) {
const { t } = useI18n();
const { gitlab } = useRuntimeAPIs();
const gitlabAuthStatus = useGitLabAuthStore((state) => state.status);
const gitlabAuthChecked = useGitLabAuthStore((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 projectDirectory = activeProject?.path ?? null;
const [query, setQuery] = React.useState('');
const [includeDiff, setIncludeDiff] = React.useState(false);
const [result, setResult] = React.useState<GitLabMergeRequestsListResult | null>(null);
const [mrs, setMrs] = React.useState<GitLabMergeRequestSummary[]>([]);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const [loadingMrNumber, setLoadingMrNumber] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const directNumber = React.useMemo(() => parsePrNumber(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.gitlabMrPicker.error.noActiveProject'));
return;
}
if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) {
setResult({ connected: false, mrs: [], page: 1, hasMore: false });
setMrs([]);
setHasMore(false);
setPage(1);
setError(null);
return;
}
if (!gitlab?.mrsList) {
setResult(null);
setError(t('session.gitlabMrPicker.error.runtimeUnavailable'));
return;
}
setIsLoading(true);
setError(null);
try {
const next = await gitlab.mrsList(projectDirectory, { page: 1 });
setResult(next);
setMrs(next.mrs ?? []);
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);
}
}, [gitlab, gitlabAuthChecked, gitlabAuthStatus, projectDirectory, t]);
React.useEffect(() => {
if (!open || !projectDirectory) return;
if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return;
if (!gitlab?.mrsList) return;
if (!debouncedQuery.trim() || directNumber) {
void refresh();
return;
}
const controller = new AbortController();
setIsLoading(true);
setError(null);
gitlab.mrsList(projectDirectory, { page: 1, query: debouncedQuery.trim() })
.then((next) => {
if (controller.signal.aborted) return;
setResult(next);
setMrs(next.mrs ?? []);
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, gitlab, gitlabAuthChecked, gitlabAuthStatus, debouncedQuery, directNumber, refresh, t]);
const loadMore = React.useCallback(async () => {
if (!projectDirectory) return;
if (!gitlab?.mrsList) return;
if (isLoadingMore || isLoading) return;
if (!hasMore) return;
setIsLoadingMore(true);
try {
const nextPage = page + 1;
const next = isTextSearch
? await gitlab.mrsList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() })
: await gitlab.mrsList(projectDirectory, { page: nextPage });
setResult(next);
setMrs((prev) => [...prev, ...(next.mrs ?? [])]);
setPage(next.page ?? nextPage);
setHasMore(Boolean(next.hasMore));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error(t('session.gitlabMrPicker.toast.loadMoreFailed'), { description: message });
} finally {
setIsLoadingMore(false);
}
}, [gitlab, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]);
React.useEffect(() => {
if (!open) {
setQuery('');
setIncludeDiff(false);
setLoadingMrNumber(null);
setError(null);
setResult(null);
setMrs([]);
setPage(1);
setHasMore(false);
setIsLoading(false);
return;
}
void refresh();
}, [open, refresh]);
React.useEffect(() => {
if (!open) return;
if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) {
setResult({ connected: false, mrs: [], page: 1, hasMore: false });
setMrs([]);
setHasMore(false);
setPage(1);
setError(null);
}
}, [gitlabAuthChecked, gitlabAuthStatus, open]);
const connected = gitlabAuthChecked ? result?.connected !== false : true;
const openGitLabSettings = React.useCallback(() => {
setSettingsPage('git');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]);
const attachMr = React.useCallback(async (mrNumber: number) => {
if (!projectDirectory) {
toast.error(t('session.gitlabMrPicker.error.noActiveProject'));
return;
}
if (!gitlab?.mrContext) {
toast.error(t('session.gitlabMrPicker.error.runtimeUnavailable'));
return;
}
if (loadingMrNumber) return;
setLoadingMrNumber(mrNumber);
try {
const context = await gitlab.mrContext(projectDirectory, mrNumber, {
includeDiff,
});
if (context.connected === false) {
toast.error(t('session.gitlabMrPicker.error.notConnected'));
return;
}
if (!context.mr) {
toast.error(t('session.gitlabMrPicker.error.mrNotFound'));
return;
}
if (!context.repo) {
toast.error(t('session.gitlabMrPicker.error.repoNotResolvable'), {
description: t('session.gitlabMrPicker.error.repoMustBeGitlab'),
});
return;
}
if (onSelect) {
const instructionsText = await renderMagicPrompt('gitlab.pr.review.instructions');
onSelect({
number: context.mr.number,
title: context.mr.title,
url: context.mr.url,
head: context.mr.sourceBranch,
base: context.mr.targetBranch,
includeDiff,
instructionsText,
contextText: buildMergeRequestContextText(context),
author: context.mr.author
? {
login: context.mr.author.username,
avatarUrl: context.mr.author.avatarUrl,
}
: undefined,
});
}
onOpenChange(false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error(t('session.gitlabMrPicker.toast.loadDetailsFailed'), { description: message });
} finally {
setLoadingMrNumber(null);
}
}, [gitlab, includeDiff, loadingMrNumber, onOpenChange, onSelect, projectDirectory, t]);
const title = t('session.gitlabMrPicker.title');
const description = t('session.gitlabMrPicker.description');
const content = (
<>
<div className="mt-2 flex items-center gap-3">
<div className="relative flex-1 min-w-0">
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('session.gitlabMrPicker.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
<button
type="button"
onClick={() => setIncludeDiff((prev) => !prev)}
className="h-9 shrink-0 flex items-center gap-2 text-left"
aria-pressed={includeDiff}
aria-label={t('session.gitlabMrPicker.includeDiffAria')}
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={includeDiff}
onChange={(checked) => setIncludeDiff(checked)}
ariaLabel={t('session.gitlabMrPicker.includeDiffAria')}
/>
</span>
<span className="typography-small text-muted-foreground whitespace-nowrap">{t('session.gitlabMrPicker.includeDiff')}</span>
</button>
</div>
<div className={cn(isMobile ? 'min-h-0' : 'flex-1 overflow-y-auto')}>
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{t('session.gitlabMrPicker.empty.noActiveProject')}</div>
) : null}
{!gitlab ? (
<div className="text-center text-muted-foreground py-8">{t('session.gitlabMrPicker.empty.runtimeUnavailable')}</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
{t('session.gitlabMrPicker.loading.mergeRequests')}
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8 space-y-3">
<div>{t('session.gitlabMrPicker.empty.notConnected')}</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={openGitLabSettings}>
{t('session.gitlabMrPicker.actions.openSettings')}
</Button>
</div>
</div>
) : null}
{error ? (
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
) : null}
{directNumber && projectDirectory && gitlab && connected ? (
<div
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
loadingMrNumber === directNumber && 'bg-interactive-selection/30'
)}
onClick={() => void attachMr(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">
{t('session.gitlabMrPicker.actions.useMergeRequest', { number: directNumber })}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{loadingMrNumber === directNumber ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
) : null}
{mrs.length === 0 && !isLoading && connected && gitlab && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{debouncedQuery.trim() ? t('session.gitlabMrPicker.empty.noMergeRequestsFound') : t('session.gitlabMrPicker.empty.noOpenMergeRequestsFound')}</div>
) : null}
{mrs.map((mr) => (
<div
key={mr.number}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
loadingMrNumber === mr.number && 'bg-interactive-selection/30'
)}
onClick={() => void attachMr(mr.number)}
>
<div className="flex-1 min-w-0 ml-0.5">
<p className="typography-small text-foreground truncate">
<span className="text-muted-foreground mr-1">!{mr.number}</span>
{mr.title}
</p>
<p className="typography-meta text-muted-foreground truncate">{mr.sourceBranch} {mr.targetBranch}</p>
</div>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{loadingMrNumber === mr.number ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={mr.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors",
alwaysShowActions ? "flex" : "hidden group-hover:flex"
)}
onClick={(e) => e.stopPropagation()}
aria-label={t('session.gitlabMrPicker.actions.openInGitLabAria')}
>
<Icon name="external-link" className="h-4 w-4" />
</a>
)}
</div>
</div>
))}
{hasMore && connected && projectDirectory && gitlab ? (
<div className="py-2 flex justify-center">
<button
type="button"
onClick={() => void loadMore()}
disabled={isLoadingMore || Boolean(loadingMrNumber)}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
(isLoadingMore || Boolean(loadingMrNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
)}
>
{isLoadingMore ? (
<span className="inline-flex items-center gap-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
{t('session.gitlabMrPicker.loading.more')}
</span>
) : (
t('session.gitlabMrPicker.actions.loadMore')
)}
</button>
</div>
) : null}
</div>
</>
);
if (isMobile) {
return (
<MobileOverlayPanel
open={open}
title={title}
onClose={() => onOpenChange(false)}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-1.5 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">{title}</h2>
{closeButton}
</div>
<p className="typography-small text-muted-foreground">{description}</p>
</div>
)}
>
{content}
</MobileOverlayPanel>
);
}
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">
<Icon name="git-merge" className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}