Files
openchamber/packages/ui/src/components/sections/integrations/LinearSettings.tsx
T
Bohdan Triapitsyn 65054a5f4a feat(ui): gate the pull-request surface on GitHub, move the account into it, and GitHub sign-in into Integrations
The pull-request rail icon now appears only while GitHub is connected
(OAuth or gh CLI), like Linear; Linear sits after the walkthrough in the
default rail order. The GitHub account avatar and switcher leave the
header for the pull-request panel, where the walkthrough, refresh, and
account controls share one row and one height, and the account stays
visible on the panel's empty state. A manual refresh keeps its spinner on
screen long enough to read as work done.

GitHub sign-in moves from Settings → Git to Settings → Integrations →
Built-in integrations as a card before Linear; search and the connect
buttons follow it.
2026-08-30 14:17:12 +03:00

342 lines
14 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop';
import { Icon } from '@/components/icon/Icon';
import { LinearProjectMapping } from './LinearProjectMapping';
import { LinearSessionComments } from './LinearSessionComments';
const AUTHORIZATION_WATCH_MS = 3 * 60_000;
const AUTHORIZATION_POLL_MS = 1_500;
type WorkspaceSnapshot = {
connected: boolean;
ids: string;
currentId: string;
currentAuthorizedAt: number;
};
function snapshotWorkspaces(status: {
connected?: boolean;
organization?: { id?: string } | null;
workspaces?: Array<{ id: string; current: boolean; authorizedAt?: number | null }>;
} | null): WorkspaceSnapshot {
const workspaces = status?.workspaces ?? [];
const current = workspaces.find((entry) => entry.current);
return {
connected: Boolean(status?.connected),
ids: workspaces.map((entry) => entry.id).slice().sort().join(','),
currentId: current?.id || status?.organization?.id || '',
currentAuthorizedAt: current?.authorizedAt ?? 0,
};
}
function authorizationCompleted(previous: WorkspaceSnapshot, next: WorkspaceSnapshot): boolean {
if (!next.connected) return false;
if (!previous.connected) return true;
return next.ids !== previous.ids
|| next.currentId !== previous.currentId
|| next.currentAuthorizedAt !== previous.currentAuthorizedAt;
}
export const LinearSettings: React.FC = () => {
const { t } = useI18n();
const runtimeLinear = getRegisteredRuntimeAPIs()?.linear;
const status = useLinearAuthStore((state) => state.status);
const isLoading = useLinearAuthStore((state) => state.isLoading);
const hasChecked = useLinearAuthStore((state) => state.hasChecked);
const refreshStatus = useLinearAuthStore((state) => state.refreshStatus);
const setStatus = useLinearAuthStore((state) => state.setStatus);
const [isBusy, setIsBusy] = React.useState(false);
const [isWaiting, setIsWaiting] = React.useState(false);
const [open, setOpen] = React.useState(false);
const pollTimerRef = React.useRef<number | null>(null);
const stopWaiting = React.useCallback(() => {
if (pollTimerRef.current != null) {
window.clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
setIsWaiting(false);
}, []);
React.useEffect(() => {
if (!runtimeLinear) {
return;
}
if (!hasChecked) {
void refreshStatus(runtimeLinear);
}
return () => {
stopWaiting();
};
}, [hasChecked, refreshStatus, runtimeLinear, stopWaiting]);
const startConnect = React.useCallback(async () => {
if (!runtimeLinear) return;
stopWaiting();
setIsBusy(true);
const previous = snapshotWorkspaces(useLinearAuthStore.getState().status);
try {
const payload = await runtimeLinear.authStart(isDesktopShell() ? 'desktop' : 'web');
setIsWaiting(true);
setOpen(true);
void openExternalUrl(payload.authorizationUrl);
const deadline = Date.now() + AUTHORIZATION_WATCH_MS;
pollTimerRef.current = window.setInterval(() => {
void (async () => {
if (Date.now() > deadline) {
stopWaiting();
toast.error(t('settings.integrations.linear.toast.authorizationFailed'));
return;
}
const next = await refreshStatus(runtimeLinear, { force: true });
if (authorizationCompleted(previous, snapshotWorkspaces(next))) {
stopWaiting();
toast.success(t('settings.integrations.linear.toast.connected'));
void focusDesktopWindow();
}
})();
}, AUTHORIZATION_POLL_MS);
} catch (error) {
console.error('Failed to start Linear connect:', error);
toast.error(t('settings.integrations.linear.toast.startConnectFailed'));
stopWaiting();
} finally {
setIsBusy(false);
}
}, [refreshStatus, runtimeLinear, stopWaiting, t]);
const activateWorkspace = React.useCallback(async (organizationId: string) => {
if (!runtimeLinear || !organizationId) return;
setIsBusy(true);
try {
const payload = await runtimeLinear.authActivate(organizationId);
setStatus(payload);
toast.success(t('settings.integrations.linear.toast.workspaceSwitched'));
} catch (error) {
console.error('Failed to switch Linear workspace:', error);
toast.error(t('settings.integrations.linear.toast.workspaceSwitchFailed'));
} finally {
setIsBusy(false);
}
}, [runtimeLinear, setStatus, t]);
const disconnect = React.useCallback(async () => {
if (!runtimeLinear) return;
setIsBusy(true);
try {
stopWaiting();
await runtimeLinear.authDisconnect();
toast.success(t('settings.integrations.linear.toast.disconnected'));
await refreshStatus(runtimeLinear, { force: true });
} catch (error) {
console.error('Failed to disconnect Linear:', error);
toast.error(t('settings.integrations.linear.toast.disconnectFailed'));
} finally {
setIsBusy(false);
}
}, [refreshStatus, runtimeLinear, stopWaiting, t]);
if (!runtimeLinear) {
return null;
}
const connected = Boolean(status?.connected);
const user = status?.user;
const organization = status?.organization;
const workspaces = status?.workspaces ?? [];
const otherWorkspaces = workspaces.filter((workspace) => !workspace.current);
const displayName = user?.displayName?.trim() || user?.name?.trim() || t('settings.integrations.linear.label.unknownUser');
const statusLabel = isWaiting
? t('settings.integrations.linear.status.waiting')
: isLoading && !hasChecked
? t('common.loading')
: connected
? (organization?.name?.trim() || t('settings.integrations.linear.status.connected'))
: t('settings.integrations.linear.status.notConnected');
const statusClassName = isWaiting
? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]'
: connected
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
: 'bg-[var(--surface-muted)] text-muted-foreground';
const expanded = isWaiting || open;
return (
<Collapsible
open={expanded}
onOpenChange={(nextOpen) => {
if (isWaiting) {
setOpen(true);
return;
}
setOpen(nextOpen);
}}
>
<div
data-settings-item="integrations.linear"
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
>
<CollapsibleTrigger
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name="linear" className="size-5 text-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">
{t('settings.integrations.linear.title')}
</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t('settings.integrations.linear.description')}
</p>
</div>
<span
aria-live="polite"
className={cn(
'max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium',
statusClassName,
)}
>
{statusLabel}
</span>
<Icon
name="arrow-down-s"
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out motion-reduce:transition-none',
expanded && 'rotate-180',
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
<div className="space-y-3">
{connected ? (
<div className="flex min-w-0 items-center gap-3">
{user?.avatarUrl ? (
<img
src={user.avatarUrl}
alt={t('settings.integrations.linear.avatarAlt.withName', { name: displayName })}
className="size-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
<Icon name="linear" className="size-4 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{displayName}</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{[organization?.name, user?.email].filter(Boolean).join(' · ')}
</p>
</div>
</div>
) : isWaiting ? (
<p className="text-xs text-muted-foreground">
{t('settings.integrations.linear.flow.description')}
</p>
) : null}
{connected ? (
<>
<LinearProjectMapping
linear={runtimeLinear}
connected={connected}
organizationId={organization?.id ?? null}
/>
<LinearSessionComments linear={runtimeLinear} connected={connected} />
{otherWorkspaces.length > 0 ? (
<div className="space-y-2">
<p className="typography-micro text-muted-foreground">
{t('settings.integrations.linear.label.otherWorkspaces')}
</p>
<div className="space-y-1">
{otherWorkspaces.map((workspace) => {
const workspaceUser = workspace.user;
const workspaceName = workspace.name?.trim()
|| t('settings.integrations.linear.status.connected');
return (
<div
key={workspace.id}
className="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-subtle)] bg-[var(--surface-muted)] px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{workspaceName}</div>
{workspaceUser?.email ? (
<p className="truncate text-xs text-muted-foreground">{workspaceUser.email}</p>
) : null}
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void activateWorkspace(workspace.id)}
disabled={isBusy}
>
{t('settings.integrations.linear.actions.switchTo')}
</Button>
</div>
);
})}
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void startConnect()}
disabled={isBusy || isWaiting}
data-settings-item="integrations.linear.add-workspace"
>
{t('settings.integrations.linear.actions.addWorkspace')}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => void disconnect()}
disabled={isBusy}
>
{t('settings.integrations.linear.actions.disconnect')}
</Button>
</div>
</>
) : isWaiting ? (
<div className="flex flex-wrap items-center gap-2">
<span className="typography-micro text-muted-foreground animate-pulse">
{t('settings.integrations.linear.flow.waiting')}
</span>
<Button type="button" size="sm" variant="ghost" disabled={isBusy} onClick={stopWaiting}>
{t('settings.common.actions.cancel')}
</Button>
</div>
) : (
<Button
type="button"
size="sm"
variant="default"
onClick={() => void startConnect()}
disabled={isBusy || (isLoading && !hasChecked)}
>
{isBusy ? <Icon name="loader-4" className="size-3.5 animate-spin" /> : null}
{t('settings.integrations.linear.actions.connect')}
</Button>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
};