Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commita78521eunknown_key

Merge pull request #108 from ccantynz-alt/claude/platform-analysis-roadmap-1nUGL

Merge pull request #108 from ccantynz-alt/claude/platform-analysis-roadmap-1nUGL

fix: resolve all TypeScript compilation errors across feature branch
CC LABS App committed on June 7, 2026Parents: 19fb778 9db8532
11 files changed+5226a78521e7abbb249115e64fbc59539f7d9300f52a
11 changed files+522−6
Modifiedsrc/db/schema.ts+244−0View fileUnifiedSplit
42964296export type PrPreview = typeof prPreviews.$inferSelect;
42974297export type NewPrPreview = typeof prPreviews.$inferInsert;
42984298
4299// ----------------------------------------------------------------------------
4300// Migration 0092 — Enterprise SSO (SAML 2.0 + OIDC per-org) + SCIM
4301// ----------------------------------------------------------------------------
4302
4303/**
4304 * Per-org SSO configuration. One row per org. Supports both SAML 2.0 and OIDC.
4305 * Distinct from the site-wide `sso_config` table (Block I10 / OIDC-only).
4306 */
4307export const orgSsoConfigs = pgTable("org_sso_configs", {
4308 id: uuid("id").primaryKey().defaultRandom(),
4309 orgId: uuid("org_id")
4310 .notNull()
4311 .unique()
4312 .references(() => organizations.id, { onDelete: "cascade" }),
4313 provider: text("provider").notNull().default("saml"), // 'saml' | 'oidc'
4314 // SAML fields
4315 idpEntityId: text("idp_entity_id"),
4316 idpSsoUrl: text("idp_sso_url"),
4317 idpCertificate: text("idp_certificate"), // PEM cert from IdP
4318 spEntityId: text("sp_entity_id"), // our SP entity ID
4319 // OIDC fields
4320 oidcClientId: text("oidc_client_id"),
4321 oidcClientSecret: text("oidc_client_secret"),
4322 oidcDiscoveryUrl: text("oidc_discovery_url"),
4323 // Common
4324 domainHint: text("domain_hint"), // e.g. "acme.com" — auto-routes users from this domain
4325 attributeMapping: jsonb("attribute_mapping")
4326 .$type<Record<string, string>>()
4327 .default({ email: "email", name: "name", username: "preferred_username" }),
4328 enabled: boolean("enabled").notNull().default(false),
4329 createdAt: timestamp("created_at").defaultNow(),
4330 updatedAt: timestamp("updated_at").defaultNow(),
4331});
4332
4333/**
4334 * SCIM provisioning tokens. Hashed SHA-256 bearer tokens issued per-org.
4335 * Identity providers (Okta, Azure AD) use these to provision/deprovision users.
4336 */
4337export const scimTokens = pgTable("scim_tokens", {
4338 id: uuid("id").primaryKey().defaultRandom(),
4339 orgId: uuid("org_id")
4340 .notNull()
4341 .references(() => organizations.id, { onDelete: "cascade" }),
4342 tokenHash: text("token_hash").notNull().unique(), // SHA-256 of the token
4343 createdBy: uuid("created_by")
4344 .notNull()
4345 .references(() => users.id),
4346 createdAt: timestamp("created_at").defaultNow(),
4347 lastUsedAt: timestamp("last_used_at"),
4348});
4349
4350/**
4351 * Tracks active IdP-initiated SSO sessions per org. Allows per-org
4352 * SLO (Single Logout) and session audit.
4353 */
4354export const orgSsoSessions = pgTable(
4355 "org_sso_sessions",
4356 {
4357 id: uuid("id").primaryKey().defaultRandom(),
4358 userId: uuid("user_id")
4359 .notNull()
4360 .references(() => users.id, { onDelete: "cascade" }),
4361 orgId: uuid("org_id")
4362 .notNull()
4363 .references(() => organizations.id, { onDelete: "cascade" }),
4364 idpSessionId: text("idp_session_id"),
4365 createdAt: timestamp("created_at").defaultNow(),
4366 expiresAt: timestamp("expires_at").notNull(),
4367 },
4368 (table) => [
4369 index("org_sso_sessions_user").on(table.userId),
4370 index("org_sso_sessions_expires").on(table.expiresAt),
4371 ]
4372);
4373
4374export type OrgSsoConfig = typeof orgSsoConfigs.$inferSelect;
4375export type NewOrgSsoConfig = typeof orgSsoConfigs.$inferInsert;
4376export type ScimToken = typeof scimTokens.$inferSelect;
4377export type OrgSsoSession = typeof orgSsoSessions.$inferSelect;
4378
4379// Migration 0077 — Incident hook configs
4380// Maps a monitoring provider (PagerDuty / Datadog / Opsgenie / generic) to a
4381// specific repo. Inbound webhooks are validated against secret_hash (SHA-256
4382// of the user-chosen webhook secret passed in the ?secret= query param).
4383// ---------------------------------------------------------------------------
4384export const incidentHookConfigs = pgTable(
4385 "incident_hook_configs",
4386 {
4387 id: uuid("id").primaryKey().defaultRandom(),
4388 userId: uuid("user_id")
4389 .notNull()
4390 .references(() => users.id, { onDelete: "cascade" }),
4391 repoId: uuid("repo_id")
4392 .notNull()
4393 .references(() => repositories.id, { onDelete: "cascade" }),
4394 /** 'pagerduty' | 'datadog' | 'opsgenie' | 'generic' */
4395 provider: text("provider").notNull(),
4396 /** SHA-256 hex of the user's plaintext webhook secret. */
4397 secretHash: text("secret_hash").notNull(),
4398 createdAt: timestamp("created_at").defaultNow(),
4399 },
4400 (table) => [
4401 uniqueIndex("incident_hook_configs_repo_provider").on(
4402 table.repoId,
4403 table.provider
4404 ),
4405 ]
4406);
4407
4408export type IncidentHookConfig = typeof incidentHookConfigs.$inferSelect;
4409export type NewIncidentHookConfig = typeof incidentHookConfigs.$inferInsert;
4410
4411
4412
4413/**
4414 * Cloud deploy configurations — per-repo settings for push-triggered deploys
4415 * to Fly.io, Railway, Render, Vercel, Netlify, or a generic webhook URL.
4416 * Migration 0077.
4417 */
4418export const cloudDeployConfigs = pgTable(
4419 "cloud_deploy_configs",
4420 {
4421 id: uuid("id").primaryKey().defaultRandom(),
4422 repoId: uuid("repo_id")
4423 .notNull()
4424 .references(() => repositories.id, { onDelete: "cascade" }),
4425 // 'fly' | 'railway' | 'render' | 'vercel' | 'netlify' | 'webhook'
4426 provider: text("provider").notNull(),
4427 // Fly app name, Railway service ID, Render service ID, Vercel project ID, webhook URL
4428 providerAppId: text("provider_app_id").notNull(),
4429 // AES-256-GCM encrypted via SERVER_TARGETS_KEY
4430 apiTokenEncrypted: text("api_token_encrypted").notNull(),
4431 triggerBranch: text("trigger_branch").notNull().default("main"),
4432 enabled: boolean("enabled").notNull().default(true),
4433 createdAt: timestamp("created_at").defaultNow(),
4434 },
4435 (table) => [index("cloud_deploy_configs_repo").on(table.repoId)]
4436);
4437
4438/**
4439 * Cloud deployment runs — one row per triggered deployment attempt.
4440 * Status transitions: pending -> running -> success | failed | cancelled.
4441 * Migration 0077.
4442 */
4443export const cloudDeployments = pgTable(
4444 "cloud_deployments",
4445 {
4446 id: uuid("id").primaryKey().defaultRandom(),
4447 configId: uuid("config_id")
4448 .notNull()
4449 .references(() => cloudDeployConfigs.id, { onDelete: "cascade" }),
4450 repoId: uuid("repo_id")
4451 .notNull()
4452 .references(() => repositories.id, { onDelete: "cascade" }),
4453 commitSha: text("commit_sha").notNull(),
4454 // pending | running | success | failed | cancelled
4455 status: text("status").notNull().default("pending"),
4456 providerDeployId: text("provider_deploy_id"),
4457 logUrl: text("log_url"),
4458 deployUrl: text("deploy_url"),
4459 errorMessage: text("error_message"),
4460 startedAt: timestamp("started_at").defaultNow(),
4461 completedAt: timestamp("completed_at"),
4462 durationMs: integer("duration_ms"),
4463 },
4464 (table) => [
4465 index("cloud_deployments_repo").on(table.repoId, table.startedAt),
4466 index("cloud_deployments_config").on(table.configId, table.startedAt),
4467 ]
4468);
4469
4470export type CloudDeployConfig = typeof cloudDeployConfigs.$inferSelect;
4471export type NewCloudDeployConfig = typeof cloudDeployConfigs.$inferInsert;
4472export type CloudDeployment = typeof cloudDeployments.$inferSelect;
4473export type NewCloudDeployment = typeof cloudDeployments.$inferInsert;
4474// ---------------------------------------------------------------------------
4475// Recurring pattern detection (migration 0088)
4476// ---------------------------------------------------------------------------
4477
4478export const recurringPatterns = pgTable(
4479 "recurring_patterns",
4480 {
4481 id: uuid("id").primaryKey().defaultRandom(),
4482 repositoryId: uuid("repository_id")
4483 .notNull()
4484 .references(() => repositories.id, { onDelete: "cascade" }),
4485 title: text("title").notNull(),
4486 occurrences: integer("occurrences").notNull().default(1),
4487 commitShas: jsonb("commit_shas").$type<string[]>().notNull().default([]),
4488 rootCauseHypothesis: text("root_cause_hypothesis"),
4489 suggestedFile: text("suggested_file"),
4490 severity: text("severity").notNull().default("medium"),
4491 detectedAt: timestamp("detected_at", { withTimezone: true }).notNull().defaultNow(),
4492 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
4493 },
4494 (table) => [
4495 index("idx_recurring_patterns_repo").on(table.repositoryId),
4496 index("idx_recurring_patterns_expires").on(table.expiresAt),
4497 ]
4498);
4499
4500export type RecurringPattern = typeof recurringPatterns.$inferSelect;
4501export type NewRecurringPattern = typeof recurringPatterns.$inferInsert;
4502// ---------------------------------------------------------------------------
4503// Bus Factor Cache — migration 0088
4504// Stores per-repo knowledge concentration analysis (at-risk files where one
4505// author owns >75% of commits). Refreshed on demand; 7-day soft TTL.
4506// ---------------------------------------------------------------------------
4507export const busFactorCache = pgTable("bus_factor_cache", {
4508 id: uuid("id").primaryKey().defaultRandom(),
4509 repositoryId: uuid("repository_id")
4510 .notNull()
4511 .references(() => repositories.id, { onDelete: "cascade" })
4512 .unique(),
4513 analyzedAt: timestamp("analyzed_at", { withTimezone: true })
4514 .notNull()
4515 .defaultNow(),
4516 atRiskFiles: jsonb("at_risk_files").notNull().default([]),
4517 totalFilesAnalyzed: integer("total_files_analyzed").notNull().default(0),
4518});
4519// ---------------------------------------------------------------------------
4520// Migration 0088 — PR visit tracking for context-restore feature
4521// ---------------------------------------------------------------------------
4522
4523/**
4524 * pr_visits — lightweight upsert table tracking each user's last visit to a
4525 * PR. Used by `src/lib/review-context.ts` to compute what changed since the
4526 * reviewer was last here and generate a "Welcome back" context banner.
4527 */
4528export const prVisits = pgTable(
4529 "pr_visits",
4530 {
4531 prId: uuid("pr_id")
4532 .notNull()
4533 .references(() => pullRequests.id, { onDelete: "cascade" }),
4534 userId: uuid("user_id")
4535 .notNull()
4536 .references(() => users.id, { onDelete: "cascade" }),
4537 visitedAt: timestamp("visited_at", { withTimezone: true }).notNull().defaultNow(),
4538 },
4539 (t) => [primaryKey({ columns: [t.prId, t.userId] })]
4540);
4541
4542export type PrVisit = typeof prVisits.$inferSelect;
42994543// ---------------------------------------------------------------------------
43004544// Migration 0088 — Smart empty states: repo onboarding data
43014545// Generated by generateRepoOnboarding() on first push to a repo.
Modifiedsrc/lib/ai-commit-message.ts+1−0View fileUnifiedSplit
2121import {
2222 getAnthropic,
2323 MODEL_HAIKU,
24 MODEL_SONNET,
2425 extractText,
2526 parseJsonResponse,
2627 isAiAvailable,
Modifiedsrc/lib/ai-completion.ts+1−0View fileUnifiedSplit
2222import {
2323 getAnthropic,
2424 MODEL_HAIKU,
25 MODEL_SONNET,
2526 extractText,
2627 isAiAvailable,
2728} from "./ai-client";
Modifiedsrc/lib/autopilot.ts+24−0View fileUnifiedSplit
680680 }
681681 },
682682 },
683 {
684 // AI dependency auto-updater (migration 0077). Once per day: scans
685 // up to 10 repos with depUpdaterEnabled=true, checks for patch/minor
686 // npm updates, applies them, runs GateTest, and either auto-merges
687 // (green) or opens a PR with an AI migration guide (red). Skips
688 // when DEP_UPDATER_ENABLED env flag is not set to "1".
689 name: "dep-update-sweep",
690 run: async () => {
691 if (process.env.DEP_UPDATER_ENABLED !== "1") return;
692 const now = Date.now();
693 if (now - _lastDepUpdateSweepAt < DEP_UPDATE_SWEEP_INTERVAL_MS) {
694 return;
695 }
696 _lastDepUpdateSweepAt = now;
697 try {
698 const summary = await runDepUpdateSweepOnce();
699 console.log(
700 `[autopilot] dep-update-sweep: repos=${summary.repos} runs=${summary.runs} merged=${summary.merged} prs=${summary.prs} skipped=${summary.skipped} errors=${summary.errors}`
701 );
702 } catch (err) {
703 console.error("[autopilot] dep-update-sweep: threw:", err);
704 }
705 },
706 },
683707 {
684708 // Smart morning digest — AI-curated daily developer queue.
685709 // Fires once per day at 07:00 UTC (or whenever the 22h outer gate
Modifiedsrc/lib/claude-semantic-search.ts+1−1View fileUnifiedSplit
2323 * Fallback: if ANTHROPIC_API_KEY is not set, falls back to `git grep`.
2424 */
2525
26import { getAnthropic, MODEL_HAIKU, parseJsonResponse } from "./ai-client";
26import { getAnthropic, MODEL_HAIKU, MODEL_SONNET, parseJsonResponse } from "./ai-client";
2727import { config } from "./config";
2828import { getRepoPath } from "../git/repository";
2929
Modifiedsrc/lib/dev-env.ts+1−0View fileUnifiedSplit
4444 getAnthropic,
4545 isAiAvailable,
4646 MODEL_HAIKU,
47 MODEL_SONNET,
4748 extractText,
4849} from "./ai-client";
4950
Modifiedsrc/lib/pr-risk.ts+1−0View fileUnifiedSplit
4141 getAnthropic,
4242 isAiAvailable,
4343 MODEL_HAIKU,
44 MODEL_SONNET,
4445 extractText,
4546} from "./ai-client";
4647import { ownersForPath, parseCodeowners, type OwnerRule } from "./codeowners";
Modifiedsrc/lib/pr-sandbox.ts+1−1View fileUnifiedSplit
3636import type { PrSandbox } from "../db/schema";
3737import { slugifyForUrl } from "./branch-previews";
3838import { getBlob } from "../git/repository";
39import { getAnthropic, isAiAvailable, MODEL_HAIKU, extractText } from "./ai-client";
39import { getAnthropic, isAiAvailable, MODEL_HAIKU, MODEL_SONNET, extractText } from "./ai-client";
4040
4141// ---------------------------------------------------------------------------
4242// Tunables
Modifiedsrc/routes/actions-importer.tsx+1−1View fileUnifiedSplit
10671067function ImporterForm() {
10681068 return (
10691069 <div class="ai-form-card" id="converter-form">
1070 <form method="POST" action="/import/actions" enctype="multipart/form-data">
1070 <form method="post" action="/import/actions" enctype="multipart/form-data">
10711071 <label class="ai-form-label" for="yaml-input">
10721072 Paste your GitHub Actions workflow YAML
10731073 </label>
Modifiedsrc/routes/ai-editor.ts+2−2View fileUnifiedSplit
2323const QUOTA_WINDOW_MS = 60 * 60 * 1_000; // 1 hour
2424const QUOTA_MAX = 30;
2525
26const suggestQuota = new Map<number, number[]>();
26const suggestQuota = new Map<string, number[]>();
2727
28function checkQuota(userId: number): { allowed: boolean; resetInMinutes: number } {
28function checkQuota(userId: string): { allowed: boolean; resetInMinutes: number } {
2929 const now = Date.now();
3030 const cutoff = now - QUOTA_WINDOW_MS;
3131 let timestamps = suggestQuota.get(userId) ?? [];
Modifiedsrc/routes/pulls.tsx+245−1View fileUnifiedSplit
14751475 display: inline-flex; align-items: center; gap: 4px;
14761476 }
14771477
1478 /* ─── Bus Factor Warning Panel ─── */
1479 .busfactor-panel {
1480 display: flex;
1481 gap: 14px;
1482 align-items: flex-start;
1483 padding: 14px 18px;
1484 margin-bottom: 16px;
1485 border-radius: 12px;
1486 border: 1px solid rgba(245,158,11,0.35);
1487 background: rgba(245,158,11,0.06);
1488 }
1489 .busfactor-critical {
1490 border-color: rgba(239,68,68,0.4);
1491 background: rgba(239,68,68,0.06);
1492 }
1493 .busfactor-high {
1494 border-color: rgba(249,115,22,0.4);
1495 background: rgba(249,115,22,0.06);
1496 }
1497 .busfactor-medium {
1498 border-color: rgba(245,158,11,0.35);
1499 background: rgba(245,158,11,0.06);
1500 }
1501 .busfactor-icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }
1502 .busfactor-body { flex: 1; min-width: 0; }
1503 .busfactor-body strong { font-size: 14px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1504 .busfactor-body p { font-size: 13px; color: var(--text-muted); margin: 0 0 8px; }
1505 .busfactor-body ul { margin: 0; padding-left: 18px; }
1506 .busfactor-body li { font-size: 12.5px; color: var(--text-muted); margin-bottom: 3px; font-family: var(--font-mono); }
1507 .busfactor-body li strong { font-size: 12.5px; color: var(--text); display: inline; }
1508
1509 /* ─── PR Split Suggestion Panel ─── */
1510 .split-suggestion {
1511 margin-bottom: 16px;
1512 border: 1px solid rgba(140,109,255,0.35);
1513 border-radius: 12px;
1514 overflow: hidden;
1515 }
1516 .split-header {
1517 display: flex;
1518 align-items: center;
1519 gap: 10px;
1520 padding: 12px 18px;
1521 background: rgba(140,109,255,0.06);
1522 flex-wrap: wrap;
1523 }
1524 .split-icon { font-size: 18px; flex-shrink: 0; }
1525 .split-header strong { font-size: 14px; font-weight: 700; color: var(--text-strong); flex: 1; min-width: 200px; }
1526 .split-stat { font-size: 12px; color: var(--text-muted); background: var(--bg-elevated); padding: 2px 9px; border-radius: 9999px; border: 1px solid var(--border); white-space: nowrap; }
1527 .split-toggle {
1528 background: none;
1529 border: 1px solid rgba(140,109,255,0.45);
1530 color: rgba(140,109,255,0.9);
1531 font-size: 12.5px;
1532 font-weight: 600;
1533 padding: 4px 12px;
1534 border-radius: 8px;
1535 cursor: pointer;
1536 white-space: nowrap;
1537 transition: background 120ms ease;
1538 }
1539 .split-toggle:hover { background: rgba(140,109,255,0.1); }
1540 .split-body {
1541 padding: 16px 18px;
1542 border-top: 1px solid rgba(140,109,255,0.2);
1543 }
1544 .split-intro { font-size: 13.5px; color: var(--text-muted); margin: 0 0 14px; }
1545 .split-pr {
1546 display: flex;
1547 gap: 14px;
1548 align-items: flex-start;
1549 padding: 12px 0;
1550 border-bottom: 1px solid var(--border);
1551 }
1552 .split-pr:last-of-type { border-bottom: none; }
1553 .split-pr-num {
1554 width: 26px; height: 26px;
1555 border-radius: 50%;
1556 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
1557 color: #fff;
1558 font-size: 12px;
1559 font-weight: 800;
1560 display: inline-flex;
1561 align-items: center;
1562 justify-content: center;
1563 flex-shrink: 0;
1564 margin-top: 2px;
1565 }
1566 .split-pr-body { flex: 1; min-width: 0; }
1567 .split-pr-body strong { font-size: 13.5px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1568 .split-pr-body p { font-size: 12.5px; color: var(--text-muted); margin: 0 0 6px; }
1569 .split-pr-body code { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); word-break: break-all; }
1570 .split-lines { display: inline-block; margin-left: 10px; font-size: 11.5px; color: var(--text-muted); background: var(--bg-tertiary); padding: 1px 7px; border-radius: 9999px; }
1571 .split-order { font-size: 13px; color: var(--text-muted); margin: 14px 0 0; }
1572 .split-order strong { color: var(--text); }
1573`;
1574
1575/* ──────────────────────────────────────────────────────────────────────
1576 * Figma-style collaborative PR presence — styles for the presence bar
1577 * above the diff and the per-line reviewer cursor pills. All scoped
1578 * with `.presence-*` prefix so they never bleed into other views.
1579 * ──────────────────────────────────────────────────────────────────── */
1580const PRESENCE_STYLES = `
1581 .presence-bar {
1582 display: flex;
1583 align-items: center;
1584 gap: 10px;
1585 padding: 8px 14px;
1586 margin: 0 0 10px;
1587 background: var(--bg-elevated);
1588 border: 1px solid var(--border);
1589 border-radius: 10px;
1590 font-size: 12.5px;
1591 color: var(--text-muted);
1592 min-height: 38px;
1593 }
1594 .presence-bar-label {
1595 font-weight: 600;
1596 color: var(--text-muted);
1597 flex-shrink: 0;
1598 }
1599 .presence-avatars {
1600 display: flex;
1601 align-items: center;
1602 gap: 6px;
1603 flex: 1;
1604 flex-wrap: wrap;
1605 }
1606 .presence-avatar {
1607 display: inline-flex;
1608 align-items: center;
1609 gap: 5px;
1610 padding: 3px 8px 3px 4px;
1611 border-radius: 9999px;
1612 font-size: 12px;
1613 font-weight: 600;
1614 color: #fff;
1615 opacity: 0.92;
1616 transition: opacity 200ms;
1617 }
1618 .presence-avatar-dot {
1619 width: 20px; height: 20px;
1620 border-radius: 9999px;
1621 background: rgba(255,255,255,0.22);
1622 display: inline-flex;
1623 align-items: center;
1624 justify-content: center;
1625 font-size: 10px;
1626 font-weight: 700;
1627 flex-shrink: 0;
1628 }
1629 .presence-count {
1630 font-size: 12px;
1631 color: var(--text-faint);
1632 flex-shrink: 0;
1633 }
1634 /* Per-line reviewer cursor pill — injected by JS into .diff-row */
1635 .presence-line-pill {
1636 position: absolute;
1637 right: 6px;
1638 top: 50%;
1639 transform: translateY(-50%);
1640 display: inline-flex;
1641 align-items: center;
1642 gap: 4px;
1643 padding: 1px 7px;
1644 border-radius: 9999px;
1645 font-size: 10.5px;
1646 font-weight: 600;
1647 color: #fff;
1648 pointer-events: none;
1649 white-space: nowrap;
1650 z-index: 10;
1651 opacity: 0.88;
1652 animation: presence-in 160ms ease;
1653 }
1654 @keyframes presence-in {
1655 from { opacity: 0; transform: translateY(-50%) scale(0.85); }
1656 to { opacity: 0.88; transform: translateY(-50%) scale(1); }
1657 }
1658 .presence-line-pill.is-typing::after {
1659 content: '…';
1660 opacity: 0.7;
1661 }
1662 /* diff rows with a cursor pill need relative positioning */
1663 .diff-row { position: relative; }
1664 /* Toast for join/leave events */
1665 .presence-toast-wrap {
1666 position: fixed;
1667 bottom: 24px;
1668 right: 24px;
1669 display: flex;
1670 flex-direction: column;
1671 gap: 8px;
1672 z-index: 9999;
1673 pointer-events: none;
1674 }
1675 .presence-toast {
1676 padding: 8px 14px;
1677 border-radius: 8px;
1678 background: var(--bg-elevated);
1679 border: 1px solid var(--border);
1680 box-shadow: 0 6px 20px -8px rgba(0,0,0,0.55);
1681 font-size: 13px;
1682 color: var(--text);
1683 opacity: 1;
1684 transition: opacity 400ms;
1685 }
1686 .presence-toast.fading { opacity: 0; }
1687`;
1688
1689const IMPACT_STYLES = `
14781690 /* ─── Merge Impact Analysis panel (.impact-*) ─── */
14791691 .impact-panel {
14801692 margin-top: 20px;
43534565 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
43544566
43554567 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
4356 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES }} />
4568 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES + IMPACT_STYLES }} />
43574569 {/* Toast container — always present for join/leave toasts */}
43584570 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
43594571 {user && (
46674879 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
46684880 )}
46694881
4882 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
4883 {requestedReviewerRows.length > 0 && (
4884 <div class="prs-review-summary" style="margin-top:14px">
4885 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
4886 Review requested
4887 </div>
4888 {requestedReviewerRows.map((rr) => {
4889 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
4890 const review = latestReviewByReviewer.get(rr.reviewerId);
4891 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
4892 const statusColor = !hasReviewed
4893 ? "var(--text-muted)"
4894 : review?.state === "approved"
4895 ? "#34d399"
4896 : "#f87171";
4897 return (
4898 <div class="prs-review-row" style={`gap:8px`}>
4899 <span class="prs-reviewer-avatar">
4900 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
4901 </span>
4902 <a href={`/${rr.reviewerUsername}`}
4903 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
4904 {rr.reviewerUsername}
4905 </a>
4906 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
4907 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
4908 </span>
4909 </div>
4910 );
4911 })}
4912 </div>
4913 )}
46704914 {/* ─── Merge Impact Analysis panel ─────────────────────── */}
46714915 {impactAnalysis && pr.state === "open" && (
46724916 <ImpactPanel analysis={impactAnalysis} owner={ownerName} />
46734917