feat(ui): show GitLab merge request status in walkthrough, git view and work status

This commit is contained in:
2026-08-16 16:23:38 +00:00
parent 2c4ba78ae5
commit 89db325168
17 changed files with 373 additions and 6 deletions
@@ -4,6 +4,8 @@ import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
import { useGitProvider } from '@/lib/gitProvider';
import { useSession, useSessionMessages } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -113,6 +115,12 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
);
const prSummary = usePrVisualSummary(prKey);
// GitLab merge requests ride the same shared TTL cache as the git view and
// the walkthrough, so every surface that reports the branch's request stays
// consistent without extra requests.
const gitProvider = useGitProvider(directory);
const { mr: gitLabMr } = useGitLabMrForBranch(directory, branch);
// `getCurrentModel` is an imperative getter: its reference never changes, so
// calling it in render subscribes to nothing. Subscribe to the selected model
// ids and recompute the limits from those.
@@ -201,7 +209,17 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
const cost = typeof session?.cost === 'number' && session.cost > 0 ? session.cost : null;
const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow));
const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel);
const hasGitLabMr = gitProvider === 'gitlab' && gitLabMr !== null;
const gitLabMrVisualState = gitLabMr
? gitLabMr.state === 'merged'
? 'merged'
: gitLabMr.state === 'closed'
? 'closed'
: gitLabMr.draft
? 'draft'
: 'open'
: null;
const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel || hasGitLabMr);
useReportWorkStatusPresence('session-repository', hasSession || hasRepository);
@@ -284,6 +302,24 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
/>
) : null}
{hasGitLabMr && gitLabMr ? (
<WorkStatusRow
icon="git-merge"
onClick={directory ? () => openSurface('pr') : undefined}
ariaLabel={t('chat.workStatus.action.openMr')}
iconColor={`var(--pr-${gitLabMrVisualState})`}
label={gitLabMr.title || t('chat.workStatus.mr.untitled')}
value={(
<WorkStatusPill
color={`var(--pr-${gitLabMrVisualState})`}
background={`color-mix(in srgb, var(--pr-${gitLabMrVisualState}) 18%, transparent)`}
>
{gitLabMr.draft ? t('chat.workStatus.pr.draft') : `!${gitLabMr.number}`}
</WorkStatusPill>
)}
/>
) : null}
{prSummary ? (
<>
<WorkStatusRow
@@ -59,6 +59,7 @@ import { InProgressOperationBanner } from './git/InProgressOperationBanner';
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
import { deriveBaseBranch } from './git/baseBranch';
import { getFreshestPrStatusForBranch, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
import { createGitIndexMutationQueue, type GitIndexMutationDirection, type GitIndexMutationQueue } from './git/gitIndexMutationQueue';
import type { GitRemote } from '@/lib/gitApi';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
@@ -304,6 +305,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const openContextSurface = useUIStore((state) => state.openContextSurface);
const prStatusBranch = status?.current ?? null;
const { mr: gitLabMr } = useGitLabMrForBranch(currentDirectory, prStatusBranch);
const prChipStatus = useGitHubPrStatusStore((state) => {
if (!currentDirectory || !prStatusBranch) {
return null;
@@ -2361,6 +2363,10 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
onOpenPullRequest={
currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined
}
gitLabMr={gitLabMr}
onOpenGitLabMr={
currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined
}
/>
{/* In-progress operation banner */}
@@ -19,6 +19,7 @@ import type {
GitRemoteComparison,
GitHubPullRequest,
GitHubChecksSummary,
GitLabMergeRequestSummary,
} from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
@@ -51,6 +52,8 @@ interface GitHeaderProps {
pullRequest?: GitHubPullRequest | null;
prChecks?: GitHubChecksSummary | null;
onOpenPullRequest?: () => void;
gitLabMr?: GitLabMergeRequestSummary | null;
onOpenGitLabMr?: () => void;
}
const IDENTITY_ICON_MAP: Record<string, IconName> = {
@@ -258,6 +261,8 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
pullRequest,
prChecks,
onOpenPullRequest,
gitLabMr,
onOpenGitLabMr,
}) => {
const { t } = useI18n();
if (!status) {
@@ -371,6 +376,40 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
</Tooltip>
) : null;
// GitLab merge request chip, mirroring the GitHub PR chip above. GitLab
// states are surfaced with the same PR state palette so merged/closed/open
// read identically across providers.
const gitLabMrVisualState = gitLabMr
? gitLabMr.state === 'merged'
? 'merged'
: gitLabMr.state === 'closed'
? 'closed'
: gitLabMr.draft
? 'draft'
: 'open'
: null;
const gitLabMrChip = gitLabMr && onOpenGitLabMr ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onOpenGitLabMr}
className="h-8 gap-1.5 px-2 typography-micro"
>
<Icon
name="git-merge"
className="size-3.5"
style={{ color: `var(--pr-${gitLabMrVisualState})` }}
/>
<span className="tabular-nums text-foreground/80">!{gitLabMr.number}</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('gitView.header.openMergeRequest')}</TooltipContent>
</Tooltip>
) : null;
const syncButtons = (
<SyncActions
syncAction={syncAction}
@@ -435,6 +474,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
<div className="mt-3 flex h-8 min-w-0 items-center gap-2">
{prChip ? <div className="shrink-0">{prChip}</div> : null}
{gitLabMrChip ? <div className="shrink-0">{gitLabMrChip}</div> : null}
<div className="min-w-0 flex-1" />
{upstreamStatusPill ? (
<div className="min-w-0 shrink">{upstreamStatusPill}</div>
@@ -14,6 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useI18n, type Locale } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
import { useGitProvider } from '@/lib/gitProvider';
import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
import { buildWalkthroughView } from '@/lib/walkthrough/model';
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
@@ -210,6 +211,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
const gitProvider = useGitProvider(directory);
const gitLabMr = useGitLabMrForBranch(directory, currentBranch);
useEffect(() => {
if (!directory || !currentBranch || !githubAuthChecked || !githubConnected || gitProvider !== 'github') return;
@@ -250,12 +252,18 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
[requestedSource, scope]
);
// Offer whichever pull request we know about: the one already selected, or
// the one this branch has.
// Offer whichever pull request or merge request we know about: the one
// already selected, or the one this branch has. GitLab repos get their MR
// number from the branch lookup; everything else falls back to the GitHub PR
// status store, which the polling effect above only fills for GitHub repos.
const prSource = useMemo<Extract<WalkthroughSource, { kind: 'pr' }> | null>(() => {
if (source.kind === 'pr') return source;
if (gitProvider === 'gitlab') {
const number = gitLabMr.mr?.number;
return number ? { kind: 'pr', number } : null;
}
return branchPrNumber ? { kind: 'pr', number: branchPrNumber } : null;
}, [branchPrNumber, source]);
}, [branchPrNumber, gitLabMr.mr, gitProvider, source]);
const selectWorkingTree = useCallback(
(value: WalkthroughWorkingTreeScope) => {
@@ -341,7 +349,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
const sourceLabel = source.kind === 'branch'
? t('walkthrough.scope.branch')
: source.kind === 'pr'
? t('walkthrough.scope.pullRequest', { number: source.number })
? gitProvider === 'gitlab'
? t('walkthrough.scope.mergeRequest', { number: source.number })
: t('walkthrough.scope.pullRequest', { number: source.number })
: scope === 'all'
? t('walkthrough.scope.all')
: scope === 'staged'
@@ -547,7 +557,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
)}
{prSource && (
<DropdownMenuRadioItem value="pr">
{t('walkthrough.scope.pullRequest', { number: prSource.number })}
{gitProvider === 'gitlab'
? t('walkthrough.scope.mergeRequest', { number: prSource.number })
: t('walkthrough.scope.pullRequest', { number: prSource.number })}
</DropdownMenuRadioItem>
)}
</DropdownMenuRadioGroup>