Files
GitLite/src/lib/components/ReviewCenter.svelte
T
Christoph 5db4f36abf feat(integrations): support automatic branch cleanup after merge
Add a new git::review_cleanup module that implements a CleanupPlan with
prepare() and finish() routines to safely remove/clean tracking and local
branches after a PR/MR is merged. The cleanup logic validates branch names,
ensures a clean worktree, checks remotes/URLs, verifies commits/ancestry,
protects against concurrent worktrees or divergent local/remote commits, and
performs authenticated fetch/push and ref updates. Unit tests for the cleanup
behavior are included.

Wire provider-side cleanup into integrations:
- add an integrations/cleanup module to read provider PR payloads and derive
  cleanup inputs
- run cleanup::prepare(...) before performing a merge when an optional
  cleanup_path is provided
- after a successful provider merge, run cleanup::finish(...); any failure is
  reported as MERGE_ACCEPTED_CLEANUP_FAILED

Also:
- export the new git review_cleanup module (src-tauri/src/git.rs)
- accept an optional cleanup_path parameter in run_integration_review_action
- remove the previous REVIEW_REQUEST_TIMEOUT wrapper around the spawned
  blocking task (the integration action is no longer wrapped with the 35s timeout)
2026-09-18 15:22:57 +02:00

774 lines
93 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from "svelte";
import type { AiSettings } from "../types";
import CreateReviewDialog from "./CreateReviewDialog.svelte";
import ConfirmDialog, { type ConfirmRequest } from "./ConfirmDialog.svelte";
import { t } from "../i18n.svelte";
import CommentEditor from "./CommentEditor.svelte";
import SelectMenu from "./SelectMenu.svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import {
AlertTriangle, Check, ChevronDown, ChevronRight,
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox,
LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
} from "@lucide/svelte";
import { addIntegrationReviewComment, getIntegrationReviewMergeOptions, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
import type { AppLanguage, GitIntegrationSettings, IntegrationMergeMethod, IntegrationMergeOptions, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types";
type LocalResolutionPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
interface Props {
aiSettings: AiSettings;
language: AppLanguage;
localRepositoryPath?: string;
integrations: GitIntegrationSettings;
initialQuery?: string;
initialSourceId?: string;
localResolutionRequestId?: string;
localResolutionPhase?: LocalResolutionPhase;
localResolutionMessage?: string;
loadCredential: (key: string) => Promise<StoredCredential | null>;
onOpenSettings: () => void;
onCleanupStateChange?: (busy: boolean, path: string) => void | Promise<void>;
onStartLocalResolution?: (request: IntegrationReviewRequest, sourceId: string) => void | Promise<void>;
onOpenLocalResolver?: () => void | Promise<void>;
onContinueLocalResolution?: () => void | Promise<void>;
onAbortLocalResolution?: () => void | Promise<void>;
onPushLocalResolution?: () => void | Promise<void>;
}
let { aiSettings, localRepositoryPath = "", language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onCleanupStateChange = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
let createOpen = $state(false);
let requests = $state<IntegrationReviewRequest[]>([]);
let loading = $state(false);
let errors = $state<Array<{ source: string; message: string }>>([]);
let query = $state("");
let stateFilter = $state<IntegrationReviewState>("open");
let repositoryFilter = $state("");
let selectedSourceId = $state("");
let selectedId = $state("");
let detailOpen = $state(false);
let detailPanel = $state<HTMLElement>();
let actionMenuId = $state("");
let actionBusyId = $state("");
let actionNotice = $state("");
let detailLoadingId = $state("");
let commentDraft = $state("");
let commentPosting = $state(false);
let collapsedRepositories = $state(new Set<string>());
let loadGeneration = 0;
let loadedStates = $state(new Set<"open" | "merged" | "closed">());
let completedResolutionRefreshKey = "";
const SOURCE_TIMEOUT_MS = 46_000;
const de = $derived(language === "de");
const sources = $derived(configuredIntegrationSources(integrations));
const activeSource = $derived(sources.find((source) => source.id === selectedSourceId) ?? null);
const normalizedQuery = $derived(query.trim().toLocaleLowerCase());
const filtered = $derived(requests.filter((request) => {
const haystack = `${request.title} ${request.repositoryName} ${request.author} ${request.number} ${request.sourceBranch} ${request.targetBranch}`.toLocaleLowerCase();
return request.state === stateFilter
&& (!repositoryFilter || request.repositoryName === repositoryFilter)
&& (!normalizedQuery || haystack.includes(normalizedQuery));
}));
/**
* Repositories of the requests loaded for the current state tab, grouped by
* owner and carrying the number of requests as the right-hand meta text.
*/
const repositoryOptions = $derived.by(() => {
const counts = new Map<string, number>();
for (const request of requests) {
if (request.state !== stateFilter || !request.repositoryName) continue;
counts.set(request.repositoryName, (counts.get(request.repositoryName) ?? 0) + 1);
}
const entries = [...counts.entries()]
.sort(([left], [right]) => left.localeCompare(right, undefined, { sensitivity: "base" }))
.map(([name, count]) => {
const separator = name.lastIndexOf("/");
return {
value: name,
label: separator > 0 ? name.slice(separator + 1) : name,
group: separator > 0 ? name.slice(0, separator) : (activeSource?.label ?? ""),
meta: String(count),
};
});
entries.sort((left, right) => left.group.localeCompare(right.group, undefined, { sensitivity: "base" })
|| left.label.localeCompare(right.label, undefined, { sensitivity: "base" }));
return [
{ value: "", label: de ? "Alle Repositories" : "All repositories", meta: String(counts.size) },
...entries,
];
});
$effect(() => {
// Drop the filter as soon as the chosen repository is no longer in the list.
if (repositoryFilter && !repositoryOptions.some((option) => option.value === repositoryFilter)) {
repositoryFilter = "";
}
});
const groupedRequests = $derived.by(() => {
const groups = new Map<string, IntegrationReviewRequest[]>();
for (const request of filtered) {
const key = request.repositoryName || providerLabel(request.provider);
groups.set(key, [...(groups.get(key) ?? []), request]);
}
return [...groups.entries()].map(([name, items]) => ({ name, items }));
});
const selected = $derived(requests.find((request) => request.id === selectedId) ?? null);
const backendRestartRequired = $derived(errors.some((error) => /command.*not found|unknown command|not registered/i.test(error.message)));
onMount(() => {
const closeActionMenu = () => { actionMenuId = ""; };
const closeDetailsOutside = (event: PointerEvent) => {
if (detailOpen && detailPanel && event.target instanceof Node && !detailPanel.contains(event.target)) {
detailOpen = false;
}
};
window.addEventListener("click", closeActionMenu);
window.addEventListener("pointerdown", closeDetailsOutside, true);
query = initialQuery;
const source = sources.find((candidate) => candidate.id === initialSourceId) ?? sources[0];
if (source) {
selectedSourceId = source.id;
void loadRequests("open", source.id);
}
return () => {
window.removeEventListener("click", closeActionMenu);
window.removeEventListener("pointerdown", closeDetailsOutside, true);
};
});
$effect(() => {
const key = localResolutionPhase === "complete" && localResolutionRequestId && selectedSourceId
? `${selectedSourceId}:${localResolutionRequestId}`
: "";
if (!key || key === completedResolutionRefreshKey) return;
completedResolutionRefreshKey = key;
void refreshCompletedResolution(key, localResolutionRequestId, selectedSourceId);
});
function withTimeout<T>(promise: Promise<T>, source: string, timeoutMs = SOURCE_TIMEOUT_MS): Promise<T> {
return new Promise<T>((resolve, reject) => {
const seconds = Math.round(timeoutMs / 1_000);
const timer = window.setTimeout(() => reject(new Error(de ? `${source} hat nach ${seconds} Sekunden nicht geantwortet.` : `${source} did not respond within ${seconds} seconds.`)), timeoutMs);
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
});
}
async function loadRequests(state: "open" | "merged" | "closed" = stateFilter === "draft" ? "open" : stateFilter, sourceId = selectedSourceId) {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return;
const generation = ++loadGeneration;
loading = true;
errors = [];
requests = requests.filter((request) => state === "open" ? request.state !== "open" && request.state !== "draft" : request.state !== state);
selectedId = "";
detailOpen = false;
actionMenuId = "";
actionNotice = "";
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
const loaded = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, state), source.label);
if (generation !== loadGeneration) return;
requests = [...requests, ...loaded].sort((left, right) => Date.parse(right.updatedAt || right.createdAt) - Date.parse(left.updatedAt || left.createdAt));
loadedStates = new Set([...loadedStates, state]);
const missingBranches = loaded.filter((request) => !request.sourceBranch || !request.targetBranch);
if (missingBranches.length) void enrichMissingBranches(missingBranches, source, credential, generation);
} catch (error) {
if (generation !== loadGeneration) return;
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally {
if (generation === loadGeneration) loading = false;
}
}
async function enrichMissingBranches(items: IntegrationReviewRequest[], source: (typeof sources)[number], credential: StoredCredential, generation: number) {
const queue = [...items];
const worker = async () => {
while (queue.length && generation === loadGeneration) {
const request = queue.shift();
if (!request) return;
try {
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000);
if (generation !== loadGeneration) return;
requests = requests.map((item) => item.id === details.id ? details : item);
} catch {
// Keep the request visible; selecting it retries the detail request and surfaces the error.
}
}
};
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, worker));
}
function selectSource(sourceId: string) {
if (sourceId === selectedSourceId) return;
selectedSourceId = sourceId;
stateFilter = "open";
requests = [];
errors = [];
selectedId = "";
detailOpen = false;
actionMenuId = "";
collapsedRepositories = new Set();
loadedStates = new Set();
void loadRequests("open", sourceId);
}
function selectState(state: IntegrationReviewState) {
stateFilter = state;
selectedId = "";
detailOpen = false;
const apiState = state === "draft" ? "open" : state;
if (!loadedStates.has(apiState)) void loadRequests(apiState);
}
function count(state: IntegrationReviewState): number {
return requests.filter((request) => request.state === state).length;
}
function countLabel(state: IntegrationReviewState): string {
return loadedStates.has(state === "draft" ? "open" : state) ? String(count(state)) : "";
}
function stateLabel(state: IntegrationReviewState): string {
if (state === "draft") return "Draft";
if (state === "merged") return de ? "Zusammengeführt" : "Merged";
if (state === "closed") return de ? "Geschlossen" : "Closed";
return "Open";
}
function formatDate(value: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(de ? "de-DE" : "en-US", { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function formatRelativeDate(value: string): string {
if (!value) return "";
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return "";
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1) return de ? "gerade eben" : "just now";
if (minutes < 60) return de ? `vor ${minutes} Min.` : `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return de ? `vor ${hours} Std.` : `${hours}h ago`;
const days = Math.round(hours / 24);
return de ? `vor ${days} T.` : `${days}d ago`;
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : parts[0].slice(0, 2)).toLocaleUpperCase();
}
function actionLabel(provider: IntegrationReviewRequest["provider"]): string {
return provider === "azure-devops" ? "Open in Azure" : `Open in ${providerLabel(provider)}`;
}
function requestTypeLabel(provider: IntegrationReviewRequest["provider"]): string {
return provider === "gitlab" || provider === "gitlab-self-hosted" ? "Merge Request" : "Pull Request";
}
function toggleRepository(name: string) {
const next = new Set(collapsedRepositories);
if (next.has(name)) next.delete(name);
else next.add(name);
collapsedRepositories = next;
}
function selectRequest(request: IntegrationReviewRequest, showDetail = false) {
if (selectedId !== request.id) commentDraft = "";
selectedId = request.id;
if (showDetail) detailOpen = true;
if (request.changedFiles === null && detailLoadingId !== request.id) void loadDetails(request);
}
function openDetail(request: IntegrationReviewRequest) {
selectRequest(request, true);
}
async function loadDetails(request: IntegrationReviewRequest) {
const source = activeSource;
if (!source) return;
detailLoadingId = request.id;
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) return;
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label);
requests = requests.map((item) => item.id === details.id ? details : item);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally { detailLoadingId = ""; }
}
async function refreshCompletedResolution(key: string, requestId: string, sourceId: string) {
const source = sources.find((candidate) => candidate.id === sourceId);
const initialRequest = requests.find((candidate) => candidate.id === requestId);
if (!source || !initialRequest) return;
let request: IntegrationReviewRequest = initialRequest;
actionNotice = de ? "Branch wurde gepusht. Der Merge-Status wird geprüft …" : "Branch pushed. Checking the merge status …";
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) return;
for (const delay of [700, 1_500, 3_000, 5_000]) {
await new Promise((resolve) => window.setTimeout(resolve, delay));
if (completedResolutionRefreshKey !== key || selectedSourceId !== sourceId) return;
const details: IntegrationReviewRequest = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000);
requests = requests.map((candidate) => candidate.id === details.id ? details : candidate);
request = details;
if (details.mergeStatus === "mergeable") {
actionNotice = de ? "Der Konflikt ist gelöst. Der Request kann jetzt zusammengeführt werden." : "The conflict is resolved. The request can now be merged.";
return;
}
if (details.mergeStatus !== "conflicts" && details.mergeStatus !== "checking") return;
}
actionNotice = de ? "Der Anbieter prüft den neuen Branch noch. Aktualisiere den Status in einigen Sekunden erneut." : "The provider is still checking the updated branch. Refresh the status again in a few seconds.";
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
}
}
function hasConflicts(request: IntegrationReviewRequest): boolean { return request.mergeStatus === "conflicts"; }
function isLocalResolutionActive(request: IntegrationReviewRequest): boolean {
return request.id === localResolutionRequestId && localResolutionPhase !== "idle";
}
function isWaitingForResolvedStatus(request: IntegrationReviewRequest): boolean {
return isLocalResolutionActive(request) && localResolutionPhase === "complete" && hasConflicts(request);
}
async function startLocalResolution(request: IntegrationReviewRequest) {
const source = activeSource;
if (!source || !request.sourceBranch || !request.targetBranch) return;
actionMenuId = "";
selectedId = request.id;
detailOpen = true;
await onStartLocalResolution(request, source.id);
}
async function runPrimaryAction(request: IntegrationReviewRequest) {
if (isWaitingForResolvedStatus(request)) return;
if (hasConflicts(request)) await startLocalResolution(request);
else if (request.state === "open" || request.state === "draft") await performReviewAction(request, "merge");
else if (request.state === "closed") await performReviewAction(request, "reopen");
else await openRequest(request);
}
function primaryActionLabel(request: IntegrationReviewRequest): string {
if (isWaitingForResolvedStatus(request)) return de ? "Status wird geprüft" : "Checking status";
if (hasConflicts(request)) return de ? "Konflikt lösen" : "Resolve conflict";
if (request.state === "open" || request.state === "draft") return de ? "Zusammenführen" : "Merge";
if (request.state === "closed") return de ? "Wieder öffnen" : "Reopen";
return actionLabel(request.provider);
}
async function openRequest(request: IntegrationReviewRequest | null = selected) {
if (!request?.webUrl) return;
try { await openInBrowser(request.webUrl); }
catch (error) { errors = [{ source: providerLabel(request.provider), message: error instanceof Error ? error.message : String(error) }]; }
}
function reviewActionLabel(action: IntegrationReviewAction): string {
if (action === "merge") return de ? "Zusammenführen" : "Merge";
if (action === "approve") return de ? "Freigeben" : "Approve";
if (action === "close") return de ? "Request schließen" : "Close request";
return de ? "Request wieder öffnen" : "Reopen request";
}
function mergeMethodLabel(method: IntegrationMergeMethod, request: IntegrationReviewRequest): string {
if (request.provider.startsWith("gitlab")) {
if (method === "merge") return de ? "Ohne Squash zusammenführen" : "Merge without squashing";
if (method === "squash") return de ? "Mit Squash zusammenführen" : "Squash and merge";
}
switch (method) {
case "merge": return de ? "Merge-Commit erstellen" : "Create merge commit";
case "rebase": return de ? "Rebase, dann Fast-forward" : "Rebase, then fast-forward";
case "rebase-merge": return de ? "Rebase, dann Merge-Commit erstellen" : "Rebase, then create merge commit";
case "squash": return de ? "Squash-Commit erstellen" : "Create squash commit";
case "fast-forward-only": return de ? "Nur Fast-forward" : "Fast-forward only";
default: return de ? "Projektstandard verwenden" : "Use project default";
}
}
let reviewConfirmRequest = $state<ConfirmRequest | null>(null);
let reviewConfirmResolve: ((result: { confirmed: boolean; method?: IntegrationMergeMethod; deleteBranch?: boolean }) => void) | null = null;
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeOptions?: IntegrationMergeOptions): Promise<{ confirmed: boolean; method?: IntegrationMergeMethod; deleteBranch?: boolean }> {
const values = { number: request.number };
reviewConfirmRequest = action === "merge"
? { title: t("confirm.review.mergeTitle", values), message: t("confirm.review.mergeMessage"), items: [request.title], confirmLabel: t("confirm.review.mergeAction"), danger: false,
checkbox: { label: de ? "Quellbranch remote und lokal löschen" : "Delete source branch remotely and locally", note: de ? `Nach dem Merge zu „${request.targetBranch}“ wechseln und „${request.sourceBranch}“ löschen. Ein passendes lokales Repository ohne ungesicherte Änderungen muss geöffnet sein.` : `After merging, switch to “${request.targetBranch}” and delete “${request.sourceBranch}”. Open the matching local repository with a clean working tree first.`, defaultChecked: false },
select: mergeOptions ? { label: de ? "Merge-Methode" : "Merge method", value: mergeOptions.defaultMethod, options: mergeOptions.methods.map(method => ({ value: method, label: mergeMethodLabel(method, request) })) } : undefined }
: action === "close"
? { title: t("confirm.review.closeTitle", values), message: t("confirm.review.closeMessage"), items: [request.title], confirmLabel: t("confirm.review.closeAction") }
: { title: t("confirm.review.reopenTitle", values), message: t("confirm.review.reopenMessage"), items: [request.title], confirmLabel: t("confirm.review.reopenAction"), danger: false };
return new Promise<{ confirmed: boolean; method?: IntegrationMergeMethod; deleteBranch?: boolean }>((resolve) => {
reviewConfirmResolve = resolve;
});
}
function answerReviewConfirmation(confirmed: boolean, value?: string, checked = false) {
const method = reviewConfirmRequest?.select?.options.find(option => option.value === value)?.value as IntegrationMergeMethod | undefined;
const resolve = reviewConfirmResolve;
reviewConfirmRequest = null;
reviewConfirmResolve = null;
resolve?.({ confirmed, method, deleteBranch: checked });
}
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
const source = activeSource;
if (!source || actionBusyId) return;
actionMenuId = "";
actionNotice = "";
actionBusyId = request.id;
errors = [];
let cleanupPath: string | undefined;
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
let mergeMethod: IntegrationMergeMethod | undefined;
if (action !== "approve") {
const options = action === "merge" ? await withTimeout(getIntegrationReviewMergeOptions(source.provider, source.baseUrl, credential.password, request), source.label) : undefined;
if (options && options.methods.length === 0) throw new Error(de ? "Für dieses Repository ist keine unterstützte Merge-Methode freigegeben." : "No supported merge method is enabled for this repository.");
const result = await askReviewConfirmation(request, action, options);
if (!result.confirmed) return;
mergeMethod = result.method;
if (action === "merge" && result.deleteBranch) {
if (!localRepositoryPath) throw new Error(de ? "Öffne zuerst das passende lokale Repository, um den Quellbranch remote und lokal zu löschen." : "Open the matching local repository before deleting the source branch remotely and locally.");
cleanupPath = localRepositoryPath;
await onCleanupStateChange(true, cleanupPath);
}
}
let cleanupError = "";
try {
// Cleanup can include fetch, checkout and push; do not time out a still-running mutation.
await runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action, mergeMethod, cleanupPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.startsWith("MERGE_ACCEPTED_CLEANUP_FAILED:")) throw error;
cleanupError = (de ? "Merge vom Anbieter angenommen, Branch-Aufräumen nicht abgeschlossen: " : "Merge accepted by the provider, branch cleanup incomplete: ") + message.replace("MERGE_ACCEPTED_CLEANUP_FAILED:", "").trim();
}
requests = [];
loadedStates = new Set();
await loadRequests(stateFilter === "draft" ? "open" : stateFilter);
if (cleanupError) errors = [...errors, { source: source.label, message: cleanupError }];
else actionNotice = cleanupPath
? (de ? `PR zusammengeführt. Zu „${request.targetBranch}“ gewechselt und „${request.sourceBranch}“ remote und lokal gelöscht.` : `PR merged. Switched to “${request.targetBranch}” and deleted “${request.sourceBranch}” remotely and locally.`)
: (de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally {
if (cleanupPath) {
try { await onCleanupStateChange(false, cleanupPath); }
catch (error) { errors = [...errors, { source: source.label, message: String(error) }]; }
}
actionBusyId = "";
}
}
async function submitComment() {
const request = selected;
const source = activeSource;
const body = commentDraft.trim();
if (!request || !source || !body || commentPosting) return;
commentPosting = true;
errors = [];
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
await withTimeout(addIntegrationReviewComment(source.provider, source.baseUrl, credential.username, credential.password, request, body), source.label);
commentDraft = "";
await loadDetails(request);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally { commentPosting = false; }
}
</script>
{#if createOpen && activeSource}
<CreateReviewDialog {aiSettings} {localRepositoryPath} source={activeSource} {de} {loadCredential} onClose={() => createOpen = false} onCreated={(request) => {
++loadGeneration;
loading = false;
requests = [request, ...requests.filter(item => item.id !== request.id)];
stateFilter = request.state;
query = "";
collapsedRepositories = new Set();
errors = [];
createOpen = false;
selectRequest(request, true);
}}/>
{/if}
<section class:has-inspector={detailOpen && !!selected} class="review-center" aria-label="Review Center">
<header class="review-header page-header">
<div class="review-heading page-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
{#if activeSource}<button class="create-review" type="button" disabled={loading} onclick={() => { detailOpen = false; createOpen = true; }}><GitPullRequest size={14}/>{activeSource.provider.startsWith("gitlab") ? (de ? "MR erstellen" : "Create MR") : (de ? "PR erstellen" : "Create PR")}</button>{/if}
</header>
{#if sources.length === 0}
<div class="empty-state"><div class="empty-icon"><GitPullRequest size={23} /></div><h2>{de ? "Keine Integration eingerichtet" : "No integration configured"}</h2><p>{de ? "Verbinde GitHub, GitLab, Gitea oder Azure DevOps, um Pull- und Merge-Requests hier zu sehen." : "Connect GitHub, GitLab, Gitea, or Azure DevOps to see pull and merge requests here."}</p><button class="primary" type="button" onclick={onOpenSettings}><Settings2 size={14} />{de ? "Integrationen öffnen" : "Open integrations"}</button></div>
{:else}
<div class="review-toolbar">
<nav class="state-tabs" aria-label={de ? "Request-Status" : "Request status"}>
{#each ["open", "draft", "merged", "closed"] as state}
<button class:active={stateFilter === state} type="button" onclick={() => selectState(state as IntegrationReviewState)}>{state === "draft" ? (de ? "Entwürfe" : "Drafts") : stateLabel(state as IntegrationReviewState)}{#if countLabel(state as IntegrationReviewState)}<span>{countLabel(state as IntegrationReviewState)}</span>{/if}</button>
{/each}
</nav>
<label class="review-search"><Search size={14} /><input bind:value={query} placeholder={de ? "Requests durchsuchen …" : "Search requests …"} /></label>
<div class="repository-picker">
<SelectMenu
value={repositoryFilter}
options={repositoryOptions}
placeholder={de ? "Alle Repositories" : "All repositories"}
searchable={repositoryOptions.length > 8}
searchPlaceholder={de ? "Repository suchen …" : "Search repository …"}
emptyText={de ? "Kein passendes Repository" : "No matching repository"}
ariaLabel={de ? "Repository filtern" : "Filter repository"}
onChange={(value) => { repositoryFilter = value; selectedId = ""; }}
>
{#snippet optionIcon()}<GitBranch size={14} aria-hidden="true" />{/snippet}
</SelectMenu>
</div>
<div class="integration-picker">
<SelectMenu value={selectedSourceId} options={sources.map(source => ({ value: source.id, label: source.label }))} ariaLabel={de ? "Integration auswählen" : "Select integration"} onChange={selectSource} />
</div>
<div class="group-actions">
<button class="icon-button" title={de ? "Aktualisieren" : "Refresh"} aria-label={de ? "Aktualisieren" : "Refresh"} type="button" onclick={() => loadRequests()} disabled={loading}><RefreshCw class={loading ? "spin" : ""} size={14} /></button>
</div>
</div>
{#if actionNotice}<div class="action-notice"><Check size={13} />{actionNotice}</div>{/if}
{#if errors.length > 0}
<details class="source-warning" open>
<summary><AlertTriangle size={14} /><span>{backendRestartRequired ? (de ? "Gitty muss neu gestartet werden, damit das Review-Backend geladen wird." : "Restart Gitty to load the Review Center backend.") : (de ? "Die ausgewählte Integration konnte nicht geladen werden." : "The selected integration could not be loaded.")}</span></summary>
<ul>{#each errors as error}<li><strong>{error.source}:</strong> {error.message}</li>{/each}</ul>
</details>
{/if}
<div class:inspector-open={detailOpen && selected} class="review-layout">
<main class="table-pane">
<div class="table-head">
<span>Status</span><span>Request</span><span>{de ? "Autor" : "Author"}</span><span>{de ? "Prüfer" : "Reviewers"}</span><span>{de ? "Repository / Branch" : "Repository / Branch"}</span><span>{de ? "Aktion" : "Action"}</span>
</div>
{#if loading && requests.length === 0}
<div class="loading-state"><LoaderCircle class="spin" size={17} />{de ? "Requests werden geladen …" : "Loading requests …"}</div>
{:else if groupedRequests.length === 0}
<div class="list-empty"><Inbox size={20} /><strong>{de ? "Keine passenden Requests" : "No matching requests"}</strong><span>{de ? "Passe Status oder Suche an." : "Adjust the status or search."}</span></div>
{:else}
<div class="request-groups">
{#each groupedRequests as group (group.name)}
<section class="request-group">
<button class="group-header" type="button" onclick={() => toggleRepository(group.name)}>
{#if collapsedRepositories.has(group.name)}<ChevronRight size={14} />{:else}<ChevronDown size={14} />{/if}
<GitBranch size={13} /><strong>{group.name}</strong><span>{group.items.length}</span>
</button>
{#if !collapsedRepositories.has(group.name)}
{#each group.items as request (request.id)}
<div class:selected={selectedId === request.id} class="request-row" role="button" tabindex="0" onclick={() => selectRequest(request)} onkeydown={(event) => { if (event.key === "Enter") selectRequest(request); }}>
<span class="request-status" class:open={request.state === "open"} class:draft={request.state === "draft"} class:merged={request.state === "merged"} class:closed={request.state === "closed"}>
{#if request.state === "merged"}<GitMerge size={13} />{:else if request.state === "closed"}<XCircle size={13} />{:else if request.state === "draft"}<CircleDotDashed size={13} />{:else}<GitPullRequest size={13} />{/if}
<span>{stateLabel(request.state)}<small title={formatDate(request.updatedAt || request.createdAt)}>{formatRelativeDate(request.updatedAt || request.createdAt)}</small></span>
</span>
<span class="request-title"><span><code>#{request.number}</code><strong title={request.title}>{request.title}</strong></span><small class="change-stats"><b>+{request.additions ?? ""}</b><i>/</i><em>{request.deletions ?? ""}</em>{#if request.changedFiles !== null}<span title={de ? "Geänderte Dateien" : "Changed files"}>{request.changedFiles} {request.changedFiles === 1 ? (de ? "Datei geändert" : "file changed") : (de ? "Dateien geändert" : "files changed")}</span>{:else if detailLoadingId === request.id}<span>{de ? "Lädt …" : "Loading …"}</span>{/if}</small></span>
<span class="request-author" title={request.author || (de ? "Unbekannt" : "Unknown")} aria-label={request.author || (de ? "Unbekannt" : "Unknown")}><i aria-hidden="true">{initials(request.author)}</i></span>
<span class="collaborators">{#if request.collaborators.length}{#each request.collaborators.slice(0, 3) as collaborator}<i title={collaborator}>{initials(collaborator)}</i>{/each}{:else}<span></span>{/if}</span>
<span class="repo-branch"><strong>{request.repositoryName}</strong>{#if request.sourceBranch && request.targetBranch}<span class="branch-route"><GitBranch size={11} /><code title={request.sourceBranch}>{request.sourceBranch}</code><b></b><code title={request.targetBranch}>{request.targetBranch}</code></span>{:else}<span class="branch-loading"><LoaderCircle class="spin" size={11} />{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span>
<span class="row-actions">
<span class:merge-action={request.state === "open" || request.state === "draft"} class:conflict-action={hasConflicts(request)} class="provider-action">
<button class="provider-button" class:merge-primary={request.state === "open" || request.state === "draft"} class:conflict={hasConflicts(request)} disabled={!!actionBusyId || isWaitingForResolvedStatus(request) || (hasConflicts(request) && (!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")))} type="button" onclick={(event) => { event.stopPropagation(); void runPrimaryAction(request); }}>{#if actionBusyId === request.id || isWaitingForResolvedStatus(request) || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")}<LoaderCircle class="spin" size={12} />{:else if request.state === "open" || request.state === "draft"}<GitMerge size={12} />{:else}<ExternalLink size={12} />{/if}{primaryActionLabel(request)}</button>
<button class="action-toggle" class:active={actionMenuId === request.id} type="button" aria-label={de ? "Weitere Aktionen" : "More actions"} onclick={(event) => { event.stopPropagation(); actionMenuId = actionMenuId === request.id ? "" : request.id; }}><ChevronDown size={13} /></button>
{#if actionMenuId === request.id}
<span class="action-menu" role="menu" tabindex="-1">
{#if request.state === "open" || request.state === "draft"}
{#if isWaitingForResolvedStatus(request)}<button class="menu-merge" type="button" role="menuitem" disabled><LoaderCircle class="spin" size={13} />{de ? "Merge-Status wird geprüft" : "Checking merge status"}</button>{:else if hasConflicts(request)}<button class="menu-conflict" type="button" role="menuitem" disabled={!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")} onclick={() => void startLocalResolution(request)}><GitMerge size={13} />{de ? "Konflikt lokal lösen" : "Resolve conflict locally"}</button>{:else}<button class="menu-merge" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "merge")}><GitMerge size={13} />{reviewActionLabel("merge")}</button>{/if}
<button class="menu-approve" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "approve")}><CircleCheck size={14} />{reviewActionLabel("approve")}</button>
<button class="menu-provider" type="button" role="menuitem" disabled={!request.webUrl} onclick={() => void openRequest(request)}><ExternalLink size={13} />{actionLabel(request.provider)}</button>
<button class="danger" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "close")}><XCircle size={13} />{reviewActionLabel("close")}</button>
{:else if request.state === "closed"}
<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "reopen")}><RotateCcw size={13} />{reviewActionLabel("reopen")}</button>
{/if}
</span>
{/if}
</span>
<button class="panel-button" class:active={detailOpen && selectedId === request.id} title={de ? "Detailpanel öffnen" : "Open detail panel"} aria-label={de ? "Detailpanel öffnen" : "Open detail panel"} type="button" onclick={(event) => { event.stopPropagation(); openDetail(request); }}><PanelRightOpen size={14} /></button>
</span>
</div>
{/each}
{/if}
</section>
{/each}
</div>
{/if}
</main>
{#if detailOpen && selected}
<aside bind:this={detailPanel} class="detail-panel" transition:fly={{ x: 140, duration: 210, easing: cubicOut }}>
<header class="detail-header unified-dialog-header">
<div class="detail-provider unified-dialog-heading"><span class="unified-dialog-icon" aria-hidden="true"><GitPullRequest size={17} /></span><div class="unified-dialog-text"><strong>{providerLabel(selected.provider)} {requestTypeLabel(selected.provider)}</strong></div></div>
<div class="detail-header-actions"><button class="icon-button" type="button" aria-label={de ? "Im Anbieter öffnen" : "Open in provider"} onclick={() => void openRequest()}><ExternalLink size={16} /></button><button data-dialog-close class="icon-button" type="button" aria-label={de ? "Detailansicht schließen" : "Close details"} onclick={() => { detailOpen = false; }}><X size={17} /></button></div>
</header>
<div class="detail-content">
<main class="detail-main">
<section class="detail-title"><div class="detail-title-line"><span>#{selected.number}</span><h2>{selected.title}</h2></div><div class="detail-summary"><span class="state-badge" class:open={selected.state === "open"} class:draft={selected.state === "draft"} class:merged={selected.state === "merged"} class:closed={selected.state === "closed"}>{stateLabel(selected.state)}</span><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong><span class="summary-separator">·</span><span class="detail-branch-route"><GitBranch size={13} />{#if selected.sourceBranch && selected.targetBranch}<code>{selected.sourceBranch}</code><span></span><code>{selected.targetBranch}</code>{:else}<span>{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span></div></section>
<div class:conflict={hasConflicts(selected) && !isWaitingForResolvedStatus(selected)} class="merge-summary">{#if detailLoadingId === selected.id || isWaitingForResolvedStatus(selected)}<LoaderCircle class="spin" size={14} /><span>{de ? "Der Anbieter prüft den gelösten Branch …" : "The provider is checking the resolved branch …"}</span>{:else if hasConflicts(selected)}<AlertTriangle size={14} /><strong>{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}</strong>{:else}<CircleCheck size={19} /><span>{de ? "Kann ohne erkannte Konflikte zusammengeführt werden." : "Can be merged without detected conflicts."}</span>{/if}<span class="merge-spacer"></span><small class="detail-change-stats"><b>+{selected.additions ?? ""}</b><em>{selected.deletions ?? ""}</em>{#if selected.changedFiles !== null}<span>{selected.changedFiles} {selected.changedFiles === 1 ? (de ? "Datei geändert" : "file changed") : (de ? "Dateien geändert" : "files changed")}</span>{/if}</small></div>
{#if hasConflicts(selected) || isLocalResolutionActive(selected)}
<div class:error={localResolutionPhase === "error"} class:complete={localResolutionPhase === "complete"} class="local-resolution">
<div>{#if localResolutionPhase === "preparing"}<LoaderCircle class="spin" size={14} />{:else if localResolutionPhase === "complete"}<Check size={14} />{:else}<GitMerge size={14} />{/if}<span>{isLocalResolutionActive(selected) && localResolutionMessage ? localResolutionMessage : (de ? "Löse den Konflikt mit dem lokalen Repository und Gittys Konflikt-Editor." : "Resolve this conflict with the local repository and Gitty's conflict editor.")}</span></div>
<footer>
{#if !isLocalResolutionActive(selected) || localResolutionPhase === "idle" || localResolutionPhase === "error"}<button type="button" onclick={() => void startLocalResolution(selected)} disabled={!selected.sourceBranch || !selected.targetBranch}>{de ? "Konflikt lokal lösen" : "Resolve locally"}</button>{/if}
{#if isLocalResolutionActive(selected) && localResolutionPhase === "conflicts"}<button type="button" onclick={() => void onOpenLocalResolver()}>{de ? "Konflikt-Editor öffnen" : "Open conflict editor"}</button><button class="secondary" type="button" onclick={() => void onAbortLocalResolution()}>{de ? "Abbrechen" : "Abort"}</button>{/if}
{#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-continue"}<button type="button" onclick={() => void onContinueLocalResolution()}>{de ? "Merge abschließen" : "Complete merge"}</button><button class="secondary" type="button" onclick={() => void onAbortLocalResolution()}>{de ? "Abbrechen" : "Abort"}</button>{/if}
{#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-push"}<button type="button" onclick={() => void onPushLocalResolution()}>{de ? "Gelösten Branch pushen" : "Push resolved branch"}</button>{/if}
</footer>
</div>
{/if}
<section class="description"><h3>{de ? "Beschreibung" : "Description"}</h3><p class:muted={!selected.description}>{selected.description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</p></section>
<section class="comments-section">
<header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header>
{#if detailLoadingId === selected.id && !(selected.comments?.length)}<div class="comments-loading"><LoaderCircle class="spin" size={13} />{de ? "Kommentare werden geladen …" : "Loading comments …"}</div>{:else if selected.comments?.length}<div class="comment-list">{#each selected.comments as comment (comment.id)}<article><div class="comment-card"><header><span class="avatar">{initials(comment.author)}</span><strong>{comment.author || (de ? "Unbekannt" : "Unknown")}</strong><time>{formatRelativeDate(comment.createdAt)}</time>{#if comment.author === selected.author}<span class="owner-badge" title={de ? "Autor dieses Requests" : "Author of this request"}>{de ? "Autor" : "Author"}</span>{/if}</header><p>{comment.body}</p></div></article>{/each}</div>{:else}<p class="no-comments">{de ? "Noch keine Kommentare." : "No comments yet."}</p>{/if}
<div class="review-comment-editor"><CommentEditor bind:value={commentDraft} language={de ? "de" : "en"} busy={commentPosting} onSend={submitComment} /></div>
</section>
</main>
<aside class="detail-sidebar">
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}{#if isWaitingForResolvedStatus(selected)}<button class="detail-action" type="button" disabled><LoaderCircle class="spin" size={14} />{de ? "Status wird geprüft" : "Checking status"}</button>{:else if hasConflicts(selected)}<button class="detail-action danger-action" type="button" disabled={isLocalResolutionActive(selected) && localResolutionPhase === "preparing"} onclick={() => void startLocalResolution(selected)}><GitMerge size={14} />{de ? "Konflikt lösen" : "Resolve conflict"}</button>{:else}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{reviewActionLabel("merge")}</button>{/if}<button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
<section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong></div></section>
<section class="detail-meta"><h3>Repository</h3><strong><GitBranch size={16} />{selected.repositoryName}</strong></section>
<section class="detail-meta"><h3>{de ? "Aktualisiert" : "Updated"}</h3><span><Clock3 size={16} />{formatDate(selected.updatedAt || selected.createdAt)}</span></section>
</aside>
</div>
</aside>
{/if}
</div>
{/if}
</section>
<style>
.create-review{margin-left:auto;display:flex;align-items:center;gap:7px;padding:7px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--color-accent-solid);color:var(--color-on-accent);cursor:pointer}.create-review:disabled{opacity:.5;cursor:default}
.review-center{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}.review-center button,.review-center input{font:inherit}.review-header{display:flex;min-height:48px;align-items:center;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.review-heading{display:flex;align-items:center;gap:9px}.review-heading>:global(svg){color:var(--color-accent)}.review-heading h1{margin:0;font-size:16px;font-weight:650}
.state-tabs{display:flex;min-height:42px;align-items:stretch;gap:18px;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.state-tabs button{position:relative;display:flex;align-items:center;gap:7px;padding:0 5px;border:0;color:var(--color-ink-muted);background:transparent;font-size:11px}.state-tabs button:hover{color:var(--color-ink)}.state-tabs button.active{color:var(--color-accent)}.state-tabs button.active:after{position:absolute;right:0;bottom:0;left:0;height:2px;background:var(--color-accent);content:""}.state-tabs span,.group-header>span{display:grid;min-width:18px;height:18px;place-items:center;padding:0 5px;border-radius:9px;color:var(--color-ink-muted);background:var(--color-surface-raised);font-size:9.5px}
.review-toolbar{display:flex;min-height:48px;align-items:center;gap:10px;padding:7px 18px;border-bottom:1px solid var(--color-border-subtle)}.group-actions{display:flex;align-items:center;gap:2px}.group-actions button,.icon-button{display:inline-flex;height:30px;align-items:center;gap:5px;padding:0 7px;border:1px solid transparent;color:var(--color-ink-muted);background:transparent}.group-actions button:hover,.icon-button:hover{border-color:var(--color-border);color:var(--color-ink);background:var(--color-surface-hover)}.group-actions .icon-button{width:30px;justify-content:center;padding:0;border-color:var(--color-border-subtle);margin-left:3px}.review-search{display:flex;min-width:180px;height:30px;align-items:center;gap:7px;flex:1;padding:0 9px;border:1px solid var(--color-border-input);color:var(--color-ink-faint);background:var(--app-input-bg)}.review-search:focus-within{border-color:var(--color-accent)}.review-search input{width:100%;min-width:0;height:100%;padding:0;border:0;outline:0;color:var(--color-ink);background:transparent}
.source-warning{padding:7px 18px;border-bottom:1px solid color-mix(in srgb,var(--color-warning) 28%,var(--color-border));color:var(--color-warning);background:color-mix(in srgb,var(--color-warning) 6%,var(--app-bg))}.source-warning summary{display:flex;align-items:center;gap:7px;cursor:pointer}.source-warning ul{display:grid;gap:4px;margin:7px 0 2px;padding-left:22px;color:var(--color-ink-muted);font-size:10.5px}.source-warning li strong{color:var(--color-ink)}.action-notice{display:flex;min-height:30px;align-items:center;gap:7px;padding:0 18px;border-bottom:1px solid color-mix(in srgb,var(--color-success) 28%,var(--color-border));color:var(--color-success);background:color-mix(in srgb,var(--color-success) 6%,var(--app-bg));font-size:10.5px}
.review-layout{display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}.review-layout.inspector-open{grid-template-columns:minmax(560px,1fr) minmax(360px,38%)}.table-pane{min-width:0;min-height:0;overflow:auto}.table-head,.request-row{display:grid;grid-template-columns:95px minmax(280px,1.5fr) 135px 145px minmax(210px,1fr) 190px;align-items:center}.table-head{position:sticky;z-index:2;top:0;min-height:30px;padding:0 12px;border-bottom:1px solid var(--color-border);color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px;font-weight:700;letter-spacing:.035em;text-transform:uppercase}.request-groups{min-width:1075px}.request-group{border-bottom:1px solid var(--color-border-subtle)}.group-header{display:flex;width:100%;height:34px;align-items:center;justify-content:flex-start;gap:7px;padding:0 12px;border:0;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink);background:var(--color-surface);text-align:left}.group-header:hover{background:var(--color-surface-hover)}.group-header>:global(svg){color:var(--color-ink-faint)}.group-header strong{font-size:11px}.group-header>span{margin-left:2px}.request-row{position:relative;min-height:52px;padding:0 12px;border-bottom:1px solid var(--color-border-subtle);outline:1px solid transparent;outline-offset:-1px;color:var(--color-ink-muted);background:transparent;cursor:default}.request-row:hover{background:var(--color-surface-hover)}.request-row.selected{z-index:1;outline-color:var(--color-accent);background:var(--color-surface-raised)}.request-status{display:flex;align-items:center;gap:6px;font-size:10px}.request-status>span{display:grid;gap:2px}.request-status small{color:var(--color-ink-faint);font-size:9px}.request-title{display:grid;min-width:0;gap:5px;padding-right:14px}.request-title>span{display:flex;min-width:0;gap:8px}.request-title code{color:var(--color-accent);font-size:10px}.request-title strong{overflow:hidden;color:var(--color-ink);font-size:11px;font-weight:560;text-overflow:ellipsis;white-space:nowrap}.change-stats{display:flex;align-items:center;gap:6px;font-size:9.5px}.change-stats b{color:var(--color-success)}.change-stats em{color:var(--color-danger);font-style:normal}.change-stats i{color:var(--color-ink-faint);font-style:normal}.change-stats span{color:var(--color-ink-faint)}.request-author{display:flex;min-width:0;align-items:center;gap:7px;padding-right:10px}.request-author i,.avatar,.collaborators i{display:grid;width:23px;height:23px;flex:0 0 auto;place-items:center;border:1px solid color-mix(in srgb,var(--color-accent) 35%,var(--color-border));border-radius:50%;color:var(--color-ink);background:color-mix(in srgb,var(--color-accent) 45%,var(--color-surface-raised));font-size:9px;font-style:normal;font-weight:750}.collaborators{display:flex;padding-left:3px}.collaborators i+ i{margin-left:-5px}.repo-branch{display:grid;min-width:0;gap:4px}.repo-branch>strong{overflow:hidden;color:var(--color-ink-muted);font-size:10px;text-overflow:ellipsis;white-space:nowrap}.branch-route{display:flex;min-width:0;align-items:center;gap:5px;padding-right:12px}.branch-route code{max-width:42%;overflow:hidden;color:var(--color-ink-faint);font-size:9px;text-overflow:ellipsis;white-space:nowrap}.branch-route b{color:var(--color-ink-faint);font-weight:400}.row-actions{display:flex;align-items:center;justify-content:flex-end;gap:5px}.provider-action{position:relative;display:flex}.provider-button,.action-toggle,.detail-action{display:inline-flex;height:28px;align-items:center;justify-content:center;gap:6px;border:1px solid var(--color-accent);color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,transparent)}.provider-button{min-width:120px;padding:0 8px;border-right:0}.provider-button.merge-primary{color:var(--color-success);border-color:var(--color-success);background:color-mix(in srgb,var(--color-success) 15%,var(--color-surface))}.provider-button.conflict{color:var(--color-danger);border-color:var(--color-danger)}.action-toggle{width:27px;padding:0}.provider-button:hover,.action-toggle:hover,.action-toggle.active,.detail-action:hover{color:var(--color-ink);background:color-mix(in srgb,var(--color-accent) 22%,var(--color-surface))}.action-menu{position:absolute;z-index:8;top:31px;right:0;display:grid;width:178px;padding:4px;border:1px solid var(--color-border);box-shadow:var(--app-menu-shadow);background:var(--color-surface-raised)}.action-menu button{display:flex;height:29px;align-items:center;gap:8px;padding:0 8px;border:0;color:var(--color-ink-muted);background:transparent;text-align:left}.action-menu button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.action-menu button.danger{color:var(--color-danger)}.panel-button{display:grid;width:28px;height:28px;place-items:center;border:1px solid var(--color-border);color:var(--color-ink-muted);background:var(--app-button-bg)}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:var(--color-accent)}
.detail-panel{min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--color-border);background:var(--color-surface)}.detail-header{display:flex;min-height:78px;align-items:flex-start;justify-content:space-between;gap:14px;padding:15px 16px;border-bottom:1px solid var(--color-border)}.detail-header>div{min-width:0}.detail-content{display:grid;gap:0;padding:0 16px}.detail-summary{display:flex;min-height:48px;align-items:center;gap:8px;border-bottom:1px solid var(--color-border-subtle)}.state-badge{padding:3px 7px;border:1px solid currentColor;font-size:9px;font-weight:700}.detail-summary .avatar{margin-left:4px}.detail-summary strong{font-size:11px}.description{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.description h3{margin:0 0 9px;font-size:11px}.description p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:11px;line-height:1.55}.description p.muted{color:var(--color-ink-faint)}.detail-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:16px}.detail-action{width:100%;height:32px;padding:0 8px}.detail-action.primary-action{color:var(--color-success);border-color:var(--color-success)}.detail-action.danger-action{color:var(--color-danger);border-color:color-mix(in srgb,var(--color-danger) 75%,var(--color-border))}.open{color:var(--color-success)}.draft{color:var(--color-ink-muted)}.merged{color:#b785e8}.closed{color:var(--color-danger)}
.loading-state,.list-empty,.empty-state{display:flex;min-height:180px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}.list-empty,.empty-state{flex-direction:column}.list-empty strong{color:var(--color-ink);font-size:12px}.list-empty span{font-size:10px}.empty-state{flex:1;text-align:center}.empty-state h2{margin:4px 0 0;font-size:14px}.empty-state p{max-width:430px;margin:0 0 8px;line-height:1.5}.empty-icon{display:grid;width:42px;height:42px;place-items:center;border:1px solid var(--color-border);color:var(--color-accent);background:var(--color-surface)}.primary{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-accent-solid);color:var(--color-on-accent);background:var(--color-accent-solid)}
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(500px,1fr) 360px}.table-head,.request-row{grid-template-columns:78px minmax(230px,1.5fr) minmax(180px,1fr) 90px 170px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
@media(max-width:760px){.review-toolbar{flex-wrap:wrap}.group-actions{order:2}.review-search{order:1;flex-basis:calc(100% - 200px)}.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 15%;z-index:5;box-shadow:var(--app-drawer-shadow)}.state-tabs{gap:8px}.group-actions button{font-size:0}.group-actions button>:global(svg){margin:0}.table-head,.request-row{grid-template-columns:72px minmax(220px,1fr) 90px 160px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.branch-route{display:none}.request-groups{min-width:580px}}
.merge-summary{display:flex;min-height:45px;align-items:center;gap:7px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-success)}.merge-summary.conflict{color:var(--color-danger)}.merge-summary>span,.merge-summary>strong{font-size:10.5px}
@media(max-width:1100px){.table-head,.request-row{grid-template-columns:78px minmax(270px,1fr) minmax(190px,1fr) 190px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.collaborators{display:none}.request-groups{min-width:780px}}
@media(max-width:760px){.table-head,.request-row{grid-template-columns:72px minmax(260px,1fr) 190px}.table-head>span:nth-child(5),.repo-branch{display:none}.request-groups{min-width:600px}.merge-summary{align-items:flex-start;flex-wrap:wrap;padding:10px 0}}
.branch-loading{display:flex;align-items:center;gap:5px;color:var(--color-ink-faint);font-size:9px}
.provider-action{overflow:visible;border-radius:2px;box-shadow:var(--app-edge-shadow)}.provider-button{border-radius:2px 0 0 2px;font-weight:600}.action-toggle{border-radius:0 2px 2px 0;background:color-mix(in srgb,var(--color-accent) 14%,var(--color-surface))}.panel-button{margin-left:2px;border-radius:2px}.review-layout.inspector-open{grid-template-columns:minmax(580px,1fr) minmax(420px,44%)}.detail-header{background:linear-gradient(180deg,color-mix(in srgb,var(--color-surface-raised) 72%,var(--color-surface)),var(--color-surface))}
.comments-section{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.comments-section>header{display:flex;align-items:center;gap:7px;margin-bottom:10px}.comments-section h3{margin:0;font-size:11px}.comments-section>header>span{display:grid;min-width:18px;height:18px;place-items:center;border-radius:9px;color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px}.comments-loading,.no-comments{margin:8px 0;color:var(--color-ink-faint);font-size:10px}.comments-loading{display:flex;align-items:center;gap:6px}.comment-list{display:grid;gap:10px;margin-bottom:12px}.comment-list article{display:flex;align-items:flex-start;gap:9px}.comment-list article>div{min-width:0;flex:1;padding:9px 10px;border:1px solid var(--color-border-subtle);border-radius:2px;background:var(--app-bg)}.comment-list article header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.comment-list article strong{font-size:10.5px}.comment-list article time{color:var(--color-ink-faint);font-size:9px}.comment-list article p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:10.5px;line-height:1.5}
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(480px,1fr) 380px}}
@media(max-width:760px){.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 12%;z-index:5;box-shadow:var(--app-drawer-shadow)}}
.comment-list{max-height:230px;overflow-y:auto;overscroll-behavior:contain;padding-right:5px;scrollbar-gutter:stable}.comment-list::-webkit-scrollbar{width:7px}.comment-list::-webkit-scrollbar-thumb{border:2px solid transparent;border-radius:4px;background:var(--color-border-input);background-clip:padding-box}.comment-list::-webkit-scrollbar-track{background:transparent}
.detail-panel{display:flex;flex-direction:column;overflow:hidden}.detail-header{flex:0 0 auto}.detail-content{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.detail-summary,.merge-summary,.description,.detail-actions{flex:0 0 auto}.description p{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3}.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.comments-section>header{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.detail-actions{margin-bottom:12px}
.review-center{position:relative}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}.detail-panel{position:absolute;z-index:20;inset:0;display:flex;width:100%;height:100%;flex-direction:column;overflow:hidden;border:0;background:var(--app-bg)}.detail-header{display:flex;min-height:48px;align-items:center;padding:0 20px;border-bottom:1px solid var(--color-border);background:var(--color-surface-raised)}.detail-provider{display:flex;align-items:center;gap:9px;color:var(--color-ink-muted)}.detail-provider>:global(svg){color:var(--color-accent)}.detail-provider strong{font-size:13px;font-weight:600}.detail-header-actions{display:flex;align-items:center;gap:3px;margin-left:auto}.detail-content{display:grid;min-height:0;flex:1;grid-template-columns:minmax(0,1fr) 300px;gap:28px;overflow:hidden;padding:0 28px}.detail-main{display:flex;min-width:0;min-height:0;flex-direction:column;overflow:hidden}.detail-title{flex:0 0 auto;padding:18px 0 0;border-bottom:1px solid var(--color-border-subtle)}.detail-title h2{margin:7px 0 10px;font-size:20px;font-weight:520;line-height:1.25}.detail-title .detail-summary{min-height:34px;border:0}.detail-title .detail-summary>span:last-child{color:var(--color-ink-muted);font-size:10.5px}.detail-main>.description{padding:13px 0}.detail-main>.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;padding:13px 0 18px}.detail-main .merge-summary{flex:0 0 auto;margin-bottom:11px;padding:0 12px;border:1px solid var(--color-border);background:var(--color-surface)}.detail-main .comment-list{min-height:0;flex:1}.detail-sidebar{display:flex;min-height:0;flex-direction:column;gap:0;overflow:hidden;padding:18px 0;border-left:1px solid var(--color-border-subtle);padding-left:22px;background:var(--color-surface)}.detail-sidebar .detail-actions{display:grid;grid-template-columns:1fr;gap:6px;margin:0 0 8px}.detail-sidebar section{padding:17px 0;border-bottom:1px solid var(--color-border-subtle)}.detail-sidebar section header{display:flex;align-items:center;justify-content:space-between;gap:12px}.detail-sidebar section h3{margin:0;color:var(--color-ink-muted);font-size:11px;font-weight:560}.people{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:8px;margin-top:12px}.people.compact{grid-template-columns:24px}
@media(max-width:900px){.detail-content{grid-template-columns:minmax(0,1fr) 270px;gap:18px;padding:0 18px}.detail-sidebar{padding-left:16px}.detail-title h2{font-size:17px}}
@media(max-width:680px){.detail-content{display:block;overflow:hidden;padding:0 15px}.detail-main{height:100%}.detail-sidebar{display:none}.detail-panel{inset:0}}
.comment-list{align-content:start;grid-auto-rows:max-content}
.detail-panel{inset:0 0 0 auto;width:50%;min-width:760px;max-width:100%;height:100%;border-left:1px solid var(--color-border);box-shadow:var(--app-drawer-shadow)}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}
.provider-action.merge-action .action-toggle{border-color:var(--color-success);color:var(--color-success);background:color-mix(in srgb,var(--color-success) 15%,var(--color-surface))}.provider-action.merge-action .action-toggle:hover,.provider-action.merge-action .action-toggle.active{color:var(--color-on-status);background:var(--color-success)}.provider-action.conflict-action .action-toggle{border-color:var(--color-danger);color:var(--color-danger);background:color-mix(in srgb,var(--color-danger) 12%,var(--color-surface))}.panel-button{border-color:color-mix(in srgb,var(--color-accent) 45%,var(--color-border));color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:var(--color-ink);background:color-mix(in srgb,var(--color-accent) 22%,var(--color-surface))}
.local-resolution{display:grid;flex:0 0 auto;gap:9px;margin:0 0 11px;padding:10px 12px;border:1px solid color-mix(in srgb,var(--color-danger) 52%,var(--color-border));background:color-mix(in srgb,var(--color-danger) 6%,var(--color-surface))}.local-resolution>div{display:flex;align-items:flex-start;gap:8px;color:var(--color-ink-muted);font-size:10.5px;line-height:1.45}.local-resolution>div>:global(svg){flex:0 0 auto;margin-top:1px;color:var(--color-danger)}.local-resolution footer{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.local-resolution button{display:inline-flex;min-height:28px;align-items:center;padding:0 9px;border:1px solid var(--color-danger);color:var(--color-danger);background:color-mix(in srgb,var(--color-danger) 12%,var(--color-surface))}.local-resolution button:hover:not(:disabled){color:var(--color-on-status);background:var(--color-danger)}.local-resolution button.secondary{border-color:var(--color-border-input);color:var(--color-ink-muted);background:var(--app-button-bg)}.local-resolution.error{border-color:var(--color-warning);background:color-mix(in srgb,var(--color-warning) 7%,var(--color-surface))}.local-resolution.error>div>:global(svg){color:var(--color-warning)}.local-resolution.complete{border-color:var(--color-success);background:color-mix(in srgb,var(--color-success) 7%,var(--color-surface))}.local-resolution.complete>div>:global(svg){color:var(--color-success)}
@media(max-width:760px){.detail-panel{inset:0;width:100%;min-width:0;max-width:none}}
/* Review Center B — compact hierarchy, overlay inspector, semantic actions. */
.review-center{font-size:11px}.review-header{min-height:44px}.review-heading h1{font-size:14px}
.review-toolbar{display:grid;min-height:50px;grid-template-columns:auto minmax(200px,1fr) 170px 190px auto;align-items:center;gap:10px;padding:0 14px;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
.state-tabs{height:100%;min-height:0;gap:3px;padding:0;border:0;background:transparent}.state-tabs button{min-width:58px;justify-content:center;padding:0 9px;font-size:10.5px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:7px;left:7px}.state-tabs span,.group-header>span{min-width:17px;height:17px;padding:0 4px;border:1px solid var(--color-border-subtle);border-radius:1px;font-size:9px}
.review-search{min-width:0;height:30px}.review-search:focus-within{box-shadow:inset 2px 0 0 var(--color-accent)}.group-actions{gap:4px}.group-actions .icon-button{width:30px;margin:0;padding:0;border-color:var(--color-border-subtle)}
.table-head,.request-row{grid-template-columns:92px minmax(285px,1.65fr) 132px 126px minmax(220px,1fr) 190px}.table-head{min-height:34px;padding:0 16px;border-top:1px solid var(--color-border-subtle);border-bottom-color:var(--color-border);color:color-mix(in srgb,var(--color-ink-faint) 82%,transparent);background:color-mix(in srgb,var(--color-surface-raised) 80%,var(--app-bg));font-size:8.5px;letter-spacing:.07em}.request-groups{min-width:1080px}.request-group{border-bottom:0}.group-header{height:39px;gap:8px;padding:0 16px;border-bottom-color:var(--color-border);color:var(--color-ink);background:color-mix(in srgb,var(--color-surface) 78%,var(--app-bg))}.group-header:hover{background:color-mix(in srgb,var(--color-surface-hover) 82%,var(--app-bg))}.group-header>:global(svg){color:var(--color-accent)}.group-header strong{font-size:11.5px;font-weight:680;letter-spacing:.005em}.group-header>span{margin-left:4px;color:var(--color-ink-muted);background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg))}
.request-row{min-height:64px;padding:0 16px;border-bottom-color:color-mix(in srgb,var(--color-border-subtle) 76%,transparent);background:color-mix(in srgb,var(--app-bg) 96%,var(--color-surface))}.request-row:before{position:absolute;top:0;bottom:0;left:0;width:3px;background:transparent;content:""}.request-row:hover{background:color-mix(in srgb,var(--color-surface-hover) 72%,var(--app-bg))}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 74%,var(--color-border));background:color-mix(in srgb,var(--color-accent) 5%,var(--color-surface-raised));box-shadow:inset 0 1px color-mix(in srgb,var(--color-accent) 15%,transparent),inset 0 -1px color-mix(in srgb,var(--color-accent) 15%,transparent)}.request-row.selected:before{background:var(--color-accent);box-shadow:2px 0 10px color-mix(in srgb,var(--color-accent) 24%,transparent)}.request-status{gap:8px}.request-status>:global(svg){width:15px;height:15px}.request-status>span{gap:4px;font-weight:600}.request-status small{font-weight:400}.request-title{gap:7px}.request-title>span{align-items:center}.request-title code{font-size:10.5px;font-weight:650}.request-title strong{font-size:11.5px;font-weight:680}.change-stats{gap:7px;font-size:9.5px}.request-author i,.avatar,.collaborators i{border-radius:1px}.repo-branch{gap:6px}.repo-branch>strong{color:var(--color-ink);font-size:10.5px;font-weight:650}.branch-route code{padding:2px 4px;color:var(--color-ink-muted);background:color-mix(in srgb,var(--color-accent) 6%,transparent)}
.provider-action{box-shadow:none}.provider-button,.action-toggle{height:32px}.provider-button{min-width:124px;padding:0 11px;font-weight:650}.provider-button.merge-primary{color:var(--color-success);border-color:var(--color-success);background:color-mix(in srgb,var(--color-success) 9%,var(--app-bg))}.provider-button.merge-primary:hover{color:var(--color-on-status);background:var(--color-success)}.provider-action.merge-action .action-toggle{background:color-mix(in srgb,var(--color-success) 20%,var(--app-bg))}.provider-button.conflict{color:var(--color-warning);border-color:var(--color-warning);background:color-mix(in srgb,var(--color-warning) 9%,var(--app-bg))}.provider-action.conflict-action .action-toggle{color:var(--color-warning);border-color:var(--color-warning);background:color-mix(in srgb,var(--color-warning) 17%,var(--app-bg))}.panel-button{width:32px;height:32px;margin-left:3px;background:color-mix(in srgb,var(--color-accent) 5%,var(--app-bg))}
.action-menu{top:36px;width:190px;padding:6px;border-color:color-mix(in srgb,var(--color-border) 86%,var(--color-accent));box-shadow:var(--app-float-shadow);background:color-mix(in srgb,var(--color-surface-raised) 92%,var(--app-bg))}.action-menu:before{position:absolute;top:-5px;right:10px;width:8px;height:8px;transform:rotate(45deg);border-top:1px solid var(--color-border);border-left:1px solid var(--color-border);background:inherit;content:""}.action-menu button{position:relative;height:34px;gap:10px;padding:0 10px;border-left:2px solid transparent;color:var(--color-ink-muted);font-size:10.5px}.action-menu button:hover:not(:disabled){color:var(--color-ink);border-left-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,var(--color-surface-hover))}.action-menu button>:global(svg){flex:0 0 auto}.action-menu .menu-merge>:global(svg){color:var(--color-success)}.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-accent)}.action-menu button.danger{margin-top:4px;border-top:1px solid var(--color-border-subtle);color:var(--color-danger)}.action-menu button.danger:hover{border-left-color:var(--color-danger);background:color-mix(in srgb,var(--color-danger) 8%,var(--color-surface-hover))}.action-menu .menu-conflict{color:var(--color-warning)}.action-menu button:disabled{opacity:.48}
.detail-panel{width:50%;min-width:720px;max-width:960px;box-shadow:var(--app-drawer-shadow)}.detail-header{min-height:47px;padding:0 16px}.detail-provider strong{font-size:12px}.detail-content{grid-template-columns:minmax(0,1fr) 250px;gap:20px;padding:0 16px}.detail-title{padding:15px 0 10px}.detail-title h2{margin:5px 0 8px;font-size:18px;font-weight:620}.detail-summary{min-height:27px;border:0}.detail-summary .avatar{margin-left:3px}
.detail-main>.merge-summary{min-height:43px;margin:11px 0 0;padding:0 10px;border:1px solid color-mix(in srgb,var(--color-success) 48%,var(--color-border));background:color-mix(in srgb,var(--color-success) 6%,var(--color-surface))}.detail-main>.merge-summary.conflict{border-color:color-mix(in srgb,var(--color-warning) 55%,var(--color-border));color:var(--color-warning);background:color-mix(in srgb,var(--color-warning) 7%,var(--color-surface))}.merge-spacer{flex:1}.detail-change-stats{display:flex;align-items:center;gap:7px;white-space:nowrap;font-size:9px}.detail-change-stats b{color:var(--color-success)}.detail-change-stats em{color:var(--color-danger);font-style:normal}.detail-change-stats span{color:var(--color-ink-faint)}
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}
.detail-sidebar{padding:15px 0 15px 16px;background:color-mix(in srgb,var(--color-surface) 58%,var(--app-bg))}.detail-sidebar .detail-actions{gap:6px;margin-bottom:9px}.detail-action{height:31px}.detail-sidebar section{padding:14px 0}.people{grid-template-columns:23px minmax(0,1fr);gap:8px;margin-top:10px}.people strong,.detail-meta strong,.detail-meta span{overflow:hidden;color:var(--color-ink-muted);font-size:10px;font-weight:500;text-overflow:ellipsis}.detail-meta{display:grid;gap:8px}.detail-actions>.danger-action:first-child{color:var(--color-warning);border-color:var(--color-warning);background:color-mix(in srgb,var(--color-warning) 8%,var(--color-surface))}
.local-resolution{margin:8px 0 0;border-color:color-mix(in srgb,var(--color-warning) 55%,var(--color-border));background:color-mix(in srgb,var(--color-warning) 7%,var(--color-surface))}.local-resolution>div>:global(svg){color:var(--color-warning)}.local-resolution button{color:var(--color-warning);border-color:var(--color-warning);background:color-mix(in srgb,var(--color-warning) 10%,var(--color-surface))}
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(160px,1fr) 150px 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.table-head,.request-groups{min-width:980px}.detail-panel{width:58%;min-width:680px}}
@media(max-width:900px){.review-toolbar{grid-template-columns:minmax(120px,1fr) 150px 150px auto;grid-template-rows:38px 38px;padding:0 10px}.state-tabs{grid-column:1/-1;grid-row:1}.review-search{grid-column:1;grid-row:2}.group-actions{grid-column:3;grid-row:2}.detail-panel{width:76%;min-width:620px}.detail-content{grid-template-columns:minmax(0,1fr) 220px;gap:14px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 180px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
@media(max-width:680px){.state-tabs button{min-width:0;flex:1}.group-actions .icon-button:nth-child(-n+2){display:none}.detail-panel{width:100%;min-width:0;max-width:none}.detail-content{display:block;padding:0 13px}.detail-main{height:100%}.detail-sidebar{display:none}}
/* Accepted Review Center concept — faithful final layout. */
.review-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.review-heading{gap:10px}.review-heading h1{font-size:14px;font-weight:700}
.review-toolbar{min-height:55px;grid-template-columns:350px minmax(200px,1fr) 155px 160px 34px;gap:10px;padding:0 16px;border-bottom-color:var(--color-border);background:color-mix(in srgb,var(--app-bg) 88%,var(--color-surface))}.state-tabs{gap:8px}.state-tabs button{min-width:72px;justify-content:flex-start;padding:0 8px;color:var(--color-ink-muted);font-size:11px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:0;left:0;height:2px}.state-tabs span{min-width:17px;height:17px;margin-left:auto;border:0;background:var(--color-surface-raised)}.review-search,.group-actions .icon-button{height:34px;background:color-mix(in srgb,var(--app-input-bg) 92%,var(--app-bg))}.review-search{padding:0 11px}.group-actions .icon-button{width:34px}
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.table-head,.request-groups{min-width:980px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,var(--app-bg))}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
.detail-panel{width:44%;min-width:680px;max-width:none;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}.detail-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.detail-provider strong{font-size:13px}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding:0 0 0 24px}.detail-main{padding-right:0}.detail-title{padding:18px 0 16px}.detail-title-line{display:flex;min-width:0;align-items:baseline;gap:10px}.detail-title-line>span{flex:0 0 auto;color:var(--color-accent);font-size:17px;font-weight:700}.detail-title h2{min-width:0;margin:0;overflow:hidden;font-size:19px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.detail-title .detail-summary{min-height:33px;gap:7px}.detail-summary .avatar{width:23px;height:23px;margin-left:5px}.detail-summary strong{font-size:10.5px}.summary-separator{color:var(--color-ink-faint)}.detail-branch-route{display:flex;min-width:0;align-items:center;gap:6px;color:var(--color-ink-muted)}.detail-branch-route>:global(svg){color:var(--color-ink-muted)}.detail-branch-route code{max-width:95px;overflow:hidden;color:var(--color-ink-muted);font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.detail-branch-route>span{color:var(--color-ink-faint)}.state-badge{padding:0;border:0;font-size:10.5px}
.detail-main>.merge-summary{min-height:55px;margin:0 0 4px;padding:0 12px;border-color:color-mix(in srgb,var(--color-success) 65%,var(--color-border));background:color-mix(in srgb,var(--color-success) 5%,var(--app-bg))}.detail-main>.description{padding:14px 0 18px}.description h3,.comments-section h3{font-size:11.5px}.description p{font-size:10.5px}.detail-main>.comments-section{padding:13px 0 0}.comments-section>header{min-height:28px;margin:0 0 8px}.comments-section>header>span{border-radius:1px}.comment-list{gap:10px;padding-right:0}.comment-list article{display:block}.comment-card{padding:0!important;border:1px solid var(--color-border)!important;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))!important}.comment-card header{min-height:42px;margin:0!important;padding:0 10px;border-bottom:0}.comment-card header .avatar{width:25px;height:25px;margin-right:2px}.comment-card header strong{font-size:10.5px}.comment-card header time{margin-left:auto}.owner-badge{padding:3px 6px;border:1px solid var(--color-border);color:var(--color-ink-faint);font-size:8.5px}.comment-card p{padding:0 44px 13px!important;color:var(--color-ink)!important}
.detail-sidebar{padding:28px 17px 16px;border-left-color:var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}.detail-sidebar .detail-actions{gap:10px;margin:0 0 12px}.detail-action{height:39px;font-size:11px}.detail-sidebar section{padding:17px 0}.detail-sidebar section h3{font-size:11.5px}.people{margin-top:12px}.detail-meta{gap:11px}
@media(max-width:1280px){.review-toolbar{grid-template-columns:310px minmax(160px,1fr) 140px 145px 34px}.state-tabs{gap:3px}.state-tabs button{min-width:64px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 105px minmax(190px,1fr) 150px}.request-groups{min-width:840px}.detail-panel{width:50%;min-width:650px}}
@media(max-width:900px){.review-toolbar{grid-template-columns:minmax(120px,1fr) 145px 145px 34px;grid-template-rows:40px 40px}.detail-panel{width:72%;min-width:600px}.detail-content{grid-template-columns:minmax(0,1fr) 205px;gap:14px;padding-left:16px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 150px}.request-groups{min-width:720px}}
@media(max-width:680px){.detail-panel{width:100%;min-width:0}.detail-content{display:block;padding:0 14px}.detail-sidebar{display:none}.detail-title h2{font-size:16px}.detail-title-line>span{font-size:14px}}
/* Match the approved reference at its 1672px desktop width. */
.review-center{--review-divider:color-mix(in srgb,var(--color-border) 82%,transparent);font-size:13px;background:color-mix(in srgb,var(--app-bg) 96%,var(--color-surface))}
.review-center.has-inspector{--inspector-width:44%}
.review-header,.detail-header{box-sizing:border-box;height:54px;min-height:54px;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}
.review-heading h1,.detail-provider strong{font-size:14px}.detail-provider>:global(svg){display:none}.detail-header{gap:4px}.detail-header-actions{display:contents}.detail-header-actions button:first-child{margin-right:auto}.detail-provider{flex:none}
.review-toolbar{height:60px;min-height:60px}.review-search input{font-size:13px}.review-center .state-tabs button{font-size:13px}.state-tabs span{font-size:11px}.table-pane{padding:0 7px;box-sizing:border-box;border-top:8px solid var(--app-bg)}.table-head{padding:0 16px;height:37px}.table-head,.request-row{grid-template-columns:100px minmax(210px,1fr) 106px 120px 165px 230px}.table-head>span{font-size:10px}.table-head,.request-groups{min-width:980px}.group-header{height:44px;padding:0 12px}.group-header strong{font-size:13px}.request-row{padding:0 10px;min-height:78px}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 50%,var(--color-border));background:color-mix(in srgb,var(--app-bg) 93%,var(--color-accent));box-shadow:none}.request-row.selected:before{width:2px;box-shadow:none}.request-status{font-size:13px;font-weight:400}.request-status>span{font-weight:400}.request-status small{font-size:11px}.request-title strong{font-size:13px;font-weight:650}.request-title code{font-size:12px}.change-stats{font-size:12px}.request-author{font-size:12px}.repo-branch>strong{font-size:12px;font-weight:500}.branch-route code{padding:0;background:none;font-size:11px}.branch-route{gap:6px}
.review-center .provider-button{min-width:130px;flex-shrink:0;padding:0 10px;font-size:13px;font-weight:500;white-space:nowrap;line-height:1.2}.provider-button>:global(svg){flex-shrink:0}.provider-action,.action-toggle,.panel-button{flex-shrink:0}.provider-button.merge-primary>:global(svg){display:none}.provider-action .action-toggle{width:32px;background:transparent}.row-actions{gap:7px}.panel-button{margin:0;width:32px}.action-menu{width:154px;padding:4px;background:color-mix(in srgb,var(--app-bg) 90%,var(--color-surface-raised));border:1px solid var(--color-border);box-shadow:var(--app-menu-shadow)}.action-menu button{font-size:13px;gap:10px;height:37px;padding:0 10px}.action-menu button.danger{margin:0;border-top:0;color:var(--color-ink)}.action-menu button.danger>:global(svg){color:var(--color-danger)}.action-menu .menu-merge>:global(svg),.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-ink-muted)}
.detail-panel{min-width:0;width:var(--inspector-width,44%);box-shadow:var(--app-edge-shadow)}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:0;padding:0}.detail-main{padding:0 18px 0 24px}.detail-sidebar{padding:28px 17px 16px;background:transparent;border-left:1px solid var(--review-divider)}.detail-title{padding:17px 0 9px;border:0}.detail-title h2{font-size:21px;font-weight:650}.detail-title-line>span{font-size:20px;color:var(--color-ink-muted)}.detail-title .detail-summary{min-height:34px;gap:7px}.detail-summary strong{font-size:12px;font-weight:400}.state-badge{font-size:13px;font-weight:400}.detail-branch-route code{font-size:13px}.detail-main>.merge-summary{min-height:54px;margin:0;padding:0 11px;gap:10px}.merge-summary>span,.merge-summary>strong{font-size:12px}.detail-change-stats{font-size:12px;gap:9px}.merge-summary>:global(svg){flex:none}.detail-main>.description{padding:20px 0 22px}.description h3{font-size:14px;font-weight:600;margin-bottom:10px}.description p{font-size:13px}.detail-main>.comments-section{padding:12px 0 0;overflow:visible;border:0}.comments-section>header{margin-bottom:10px}.comments-section h3{font-size:14px;font-weight:600}.comments-section>header>span{font-size:10px}.comment-list{gap:10px;margin:0;scrollbar-gutter:auto}.comment-list article .comment-card{min-height:78px;border-color:var(--review-divider)!important;background:transparent!important}.comment-list article header{justify-content:flex-start;gap:5px;padding:0 11px}.comment-card header .avatar{margin-right:5px;width:28px;height:28px}.comment-list article strong{font-size:12px;font-weight:500}.comment-list article time{font-size:11px;margin:0}.owner-badge{margin-left:auto;font-size:10px}.comment-list article p{font-size:12px;padding:2px 14px 15px 56px!important}.detail-sidebar .detail-actions{gap:15px;margin-bottom:27px}.review-center .detail-action{font-size:13px;position:relative}.detail-action>:global(svg){position:absolute;left:12px;width:18px;height:18px}.detail-action.primary-action{background:color-mix(in srgb,var(--color-success) 4%,transparent)}.detail-sidebar section{padding:16px 0;border-top:1px solid var(--review-divider);border-bottom:0}.detail-sidebar section h3{font-size:14px;font-weight:600}.people.compact{display:flex;gap:10px}.people strong,.detail-meta strong,.detail-meta span{font-size:12px}.detail-meta strong,.detail-meta span{display:flex;align-items:center;gap:10px}.detail-meta :global(svg){flex:none;color:var(--color-ink-muted)}
@media(max-width:1450px){.review-center.has-inspector{--inspector-width:52%}.detail-content{grid-template-columns:minmax(0,1fr) 190px}.detail-main{padding:0 14px}.detail-title h2{font-size:17px}.detail-title-line>span{font-size:17px}.detail-summary{flex-wrap:wrap}.detail-main>.merge-summary{flex-wrap:wrap;gap:7px;padding:9px}.detail-change-stats{font-size:10px}.detail-main>.merge-summary>span{font-size:11px}.detail-sidebar{padding:24px 12px}}
@media(max-width:900px){.review-center.has-inspector{--inspector-width:100%}.detail-content{display:grid;grid-template-columns:minmax(0,1fr) 200px}.detail-sidebar{display:flex}.detail-main{height:auto}.detail-panel{inset:0 0 0 auto;min-width:0}.group-actions .icon-button{display:flex!important}}
@media(max-width:520px){.detail-content{display:flex;flex-direction:column}.detail-main{flex:1;min-height:0}.detail-sidebar{flex:none;padding:8px 14px;border-top:1px solid var(--review-divider);border-left:0}.detail-sidebar section{display:none}.detail-sidebar .detail-actions{grid-template-columns:1fr 1fr;gap:6px;margin:0}.detail-action{height:32px}.detail-header{padding:0 14px}.detail-main>.description{padding:10px 0}.detail-title h2{white-space:normal}}
/* The inspector overlays an unchanged background; only viewport size reflows it. */
.review-toolbar{box-sizing:border-box;width:100%;height:56px;min-height:56px;grid-template-columns:316px minmax(140px,1fr) 150px 145px 30px;grid-template-rows:1fr;gap:10px;padding:0 14px 0 8px}
.review-toolbar .state-tabs{grid-column:1;grid-row:1;gap:8px;align-items:stretch}
.review-center .state-tabs button{min-width:0;flex:1;gap:6px;padding:0 6px;justify-content:center;font-size:12px;white-space:nowrap}
.state-tabs span{min-width:14px;height:16px;margin-left:0;padding:0 3px;font-size:10px}
.state-tabs button.active span{background:transparent}
.review-search{grid-column:2;grid-row:1;height:32px;order:0}
.review-search input{font-size:12px}
.repository-picker{position:relative;grid-column:3;grid-row:1;height:32px;min-width:0}
.integration-picker{position:relative;grid-column:4;grid-row:1;height:32px;min-width:0}
.review-toolbar .group-actions{grid-column:5;grid-row:1;order:0}
.review-toolbar .group-actions .icon-button{width:30px;height:32px}
@media(max-width:720px){.review-toolbar{grid-template-columns:minmax(110px,1fr) 130px 130px 30px;grid-template-rows:36px 38px;height:80px;min-height:80px;gap:0 10px}.review-toolbar .state-tabs{grid-column:1/-1;grid-row:1;max-width:316px}.review-search{grid-column:1;grid-row:2}.repository-picker{grid-column:2;grid-row:2}.integration-picker{grid-column:3;grid-row:2}.review-toolbar .group-actions{grid-column:4;grid-row:2}}
.integration-picker :global(.select-menu),
.repository-picker :global(.select-menu){width:100%;height:32px}
.integration-picker :global(.select-menu-trigger),
.repository-picker :global(.select-menu-trigger){height:32px;min-height:32px;font-size:12px}
.repository-picker :global(.select-menu-popup){min-width:290px;max-height:420px;padding:4px}
.repository-picker :global(.select-menu-option){min-height:34px;font-weight:650}
.repository-picker :global(.select-menu-group){margin:6px 3px 2px;padding:7px 6px 5px}
/* Compact request actions, matching the approved menu reference. */
.review-center .row-actions .provider-button{min-width:54px;height:27px;min-height:27px;padding:0 8px;font-size:11px;font-weight:500}
.review-center .row-actions .action-toggle{width:25px;height:27px;min-height:27px;background:transparent}
.review-center .row-actions .action-toggle.active{color:var(--color-success);background:color-mix(in srgb,var(--color-success) 8%,var(--app-bg))}
.review-center .row-actions .panel-button{width:27px;height:27px;min-height:27px}
.review-center .action-menu{top:36px;right:-2px;box-sizing:border-box;width:max-content;min-width:122px;padding:3px 0;border:1px solid var(--color-border);border-radius:0;background:color-mix(in srgb,var(--app-bg) 96%,var(--color-surface-raised));box-shadow:var(--app-menu-shadow)}
.review-center .action-menu:before{right:calc(50% - 4px)}
.review-center .action-menu button{box-sizing:border-box;display:flex;width:100%;height:30px;min-height:30px;justify-content:flex-start;align-items:center;gap:9px;padding:0 11px;margin:0;border:0;border-radius:0;font-size:11px;font-weight:400;line-height:1.2;text-align:left;white-space:nowrap;color:var(--color-ink)}
.review-center .action-menu button>:global(svg){width:14px;height:14px;flex:0 0 14px;color:var(--color-ink-muted)}
.review-center .action-menu button.danger>:global(svg){color:var(--color-danger)}
.review-center .action-menu button.menu-conflict{color:var(--color-warning)}
.review-center .action-menu button:hover:not(:disabled){background:var(--color-surface-hover)}
.review-comment-editor{flex:0 0 auto;min-width:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}
</style>
{#if reviewConfirmRequest}
<ConfirmDialog
request={reviewConfirmRequest}
onConfirm={({ value, checked }) => answerReviewConfirmation(true, value, checked)}
onCancel={() => answerReviewConfirmation(false)}
/>
{/if}