Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

dashboard.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

dashboard.tsxBlame1931 lines · 5 contributors
f1ab587Claude1/**
2 * Command Center — the live dashboard.
3 *
4 * This is the developer's mission control. One screen that shows:
5 * - All your repos with health scores at a glance
6 * - Live push feed (what just happened, risk scores, repairs)
7 * - CI status for every repo
8 * - Security alerts
9 * - Quick actions (rollback, repair, deploy)
10 *
11 * Plus intelligence settings — toggle auto-repair, scanning, etc.
12 *
13 * GitHub gives you a feed of stars and follows.
14 * gluecron gives you a COMMAND CENTER.
15 */
16
17import { Hono } from "hono";
2d78948Claude18import { eq, desc, and, inArray, ne, sql, gte } from "drizzle-orm";
c63b860Claude19import { getCookie, setCookie } from "hono/cookie";
f1ab587Claude20import { db } from "../db";
21import {
22 repositories,
23 users,
24 activityFeed,
2d78948Claude25 auditLog,
26 gateRuns,
f1ab587Claude27 issues,
28 pullRequests,
29} from "../db/schema";
30import { Layout } from "../views/layout";
febd4f0Claude31import { LiveFeed } from "../views/live-feed";
f1ab587Claude32import { softAuth, requireAuth } from "../middleware/auth";
33import type { AuthEnv } from "../middleware/auth";
34import {
35 computeHealthScore,
36 detectCIConfig,
37} from "../lib/intelligence";
38import {
39 repoExists,
40 getDefaultBranch,
41 listCommits,
42 listBranches,
43} from "../git/repository";
46d6165Claude44import {
45 computeAiSavingsForUser,
46 computeLifetimeAiSavingsForUser,
47 type AiSavingsReport,
48 type AiSavingsLifetimeReport,
49} from "../lib/ai-hours-saved";
dc32319ccantynz-alt50import { totalOpenIssues } from "../lib/issue-counts";
f1ab587Claude51
2d78948Claude52// ─── AI Activity — Last Hour ─────────────────────────────────────────────────
53
54const SECRET_GATE_NAMES_DASH = [
55 "Secret scan",
56 "Secret Scan",
57 "Security scan",
58 "Security Scan",
59];
60
61export type AiActivityItem = {
62 kind: "pr_auto_merged" | "spec_shipped" | "ci_healed" | "secret_repaired";
63 repoName: string;
64 repoOwner: string;
65 /** PR or issue number — null when it's a gate repair with no PR */
66 refNumber: number | null;
67 /** Short title for display (PR/issue title or gate name) */
68 label: string;
69 occurredAt: Date;
70};
71
72export type AiActivityReport = {
73 items: AiActivityItem[];
74 prsAutoMerged: number;
75 specsShipped: number;
76 ciHealed: number;
77 secretsRepaired: number;
78};
79
80/**
81 * Fetch autopilot-driven activity from the last 60 minutes for all repos
82 * owned by the given user. Never throws — on any DB error returns an
83 * empty report so the widget always renders.
84 */
85async function fetchAiActivityLastHour(
86 userId: string,
87 repoIds: string[],
88 repoMap: Map<string, { name: string; ownerUsername: string }>,
89): Promise<AiActivityReport> {
90 const empty: AiActivityReport = {
91 items: [],
92 prsAutoMerged: 0,
93 specsShipped: 0,
94 ciHealed: 0,
95 secretsRepaired: 0,
96 };
97 if (repoIds.length === 0) return empty;
98
99 const since = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
100
101 try {
102 // ── 1. Auto-merged PRs ─────────────────────────────────────
103 const autoMergeRows = await db
104 .select({
105 repositoryId: auditLog.repositoryId,
106 targetId: auditLog.targetId,
107 createdAt: auditLog.createdAt,
108 })
109 .from(auditLog)
110 .where(
111 and(
112 eq(auditLog.action, "auto_merge.merged"),
113 inArray(auditLog.repositoryId, repoIds as [string, ...string[]]),
114 gte(auditLog.createdAt, since),
115 )
116 )
117 .orderBy(desc(auditLog.createdAt))
118 .limit(20);
119
120 // Fetch PR titles for auto-merged rows
121 const autoMergePrIds = autoMergeRows
122 .map((r) => r.targetId)
123 .filter(Boolean) as string[];
124 const prTitleMap = new Map<string, { number: number; title: string }>();
125 if (autoMergePrIds.length > 0) {
126 const prRows = await db
127 .select({ id: pullRequests.id, number: pullRequests.number, title: pullRequests.title })
128 .from(pullRequests)
129 .where(inArray(pullRequests.id, autoMergePrIds as [string, ...string[]]));
130 for (const r of prRows) prTitleMap.set(r.id, { number: r.number, title: r.title });
131 }
132
133 // ── 2. AI-built specs dispatched ───────────────────────────
134 const specRows = await db
135 .select({
136 repositoryId: auditLog.repositoryId,
137 targetId: auditLog.targetId,
138 createdAt: auditLog.createdAt,
139 })
140 .from(auditLog)
141 .where(
142 and(
143 eq(auditLog.action, "ai_build.dispatched"),
144 inArray(auditLog.repositoryId, repoIds as [string, ...string[]]),
145 gte(auditLog.createdAt, since),
146 )
147 )
148 .orderBy(desc(auditLog.createdAt))
149 .limit(20);
150
151 // Fetch issue numbers/titles for dispatched specs
152 const specIssueIds = specRows
153 .map((r) => r.targetId)
154 .filter(Boolean) as string[];
155 const issueTitleMap = new Map<string, { number: number; title: string }>();
156 if (specIssueIds.length > 0) {
157 const issueRows = await db
158 .select({ id: issues.id, number: issues.number, title: issues.title })
159 .from(issues)
160 .where(inArray(issues.id, specIssueIds as [string, ...string[]]));
161 for (const r of issueRows) issueTitleMap.set(r.id, { number: r.number, title: r.title });
162 }
163
164 // ── 3. Gate auto-repairs (secrets) ─────────────────────────
165 const secretRepairRows = await db
166 .select({
167 repositoryId: gateRuns.repositoryId,
168 gateName: gateRuns.gateName,
169 createdAt: gateRuns.createdAt,
170 })
171 .from(gateRuns)
172 .where(
173 and(
174 eq(gateRuns.status, "repaired"),
175 inArray(gateRuns.repositoryId, repoIds as [string, ...string[]]),
176 gte(gateRuns.createdAt, since),
177 inArray(gateRuns.gateName, SECRET_GATE_NAMES_DASH as [string, ...string[]]),
178 )
179 )
180 .orderBy(desc(gateRuns.createdAt))
181 .limit(20);
182
183 // ── 4. Gate auto-repairs (CI / other) ──────────────────────
184 const ciHealRows = await db
185 .select({
186 repositoryId: gateRuns.repositoryId,
187 gateName: gateRuns.gateName,
188 createdAt: gateRuns.createdAt,
189 })
190 .from(gateRuns)
191 .where(
192 and(
193 eq(gateRuns.status, "repaired"),
194 inArray(gateRuns.repositoryId, repoIds as [string, ...string[]]),
195 gte(gateRuns.createdAt, since),
196 sql`${gateRuns.gateName} NOT IN ('Secret scan','Secret Scan','Security scan','Security Scan')`,
197 )
198 )
199 .orderBy(desc(gateRuns.createdAt))
200 .limit(20);
201
202 // ── Build item list ────────────────────────────────────────
203 const items: AiActivityItem[] = [];
204
205 for (const r of autoMergeRows) {
206 const repo = repoMap.get(r.repositoryId ?? "");
207 if (!repo) continue;
208 const pr = r.targetId ? prTitleMap.get(r.targetId) : undefined;
209 items.push({
210 kind: "pr_auto_merged",
211 repoName: repo.name,
212 repoOwner: repo.ownerUsername,
213 refNumber: pr?.number ?? null,
214 label: pr?.title ?? "Pull request",
215 occurredAt: r.createdAt,
216 });
217 }
218
219 for (const r of specRows) {
220 const repo = repoMap.get(r.repositoryId ?? "");
221 if (!repo) continue;
222 const issue = r.targetId ? issueTitleMap.get(r.targetId) : undefined;
223 items.push({
224 kind: "spec_shipped",
225 repoName: repo.name,
226 repoOwner: repo.ownerUsername,
227 refNumber: issue?.number ?? null,
228 label: issue?.title ?? "Issue",
229 occurredAt: r.createdAt,
230 });
231 }
232
233 for (const r of secretRepairRows) {
234 const repo = repoMap.get(r.repositoryId ?? "");
235 if (!repo) continue;
236 items.push({
237 kind: "secret_repaired",
238 repoName: repo.name,
239 repoOwner: repo.ownerUsername,
240 refNumber: null,
241 label: r.gateName,
242 occurredAt: r.createdAt,
243 });
244 }
245
246 for (const r of ciHealRows) {
247 const repo = repoMap.get(r.repositoryId ?? "");
248 if (!repo) continue;
249 items.push({
250 kind: "ci_healed",
251 repoName: repo.name,
252 repoOwner: repo.ownerUsername,
253 refNumber: null,
254 label: r.gateName,
255 occurredAt: r.createdAt,
256 });
257 }
258
259 // Sort newest first
260 items.sort((a, b) => b.occurredAt.getTime() - a.occurredAt.getTime());
261
262 return {
263 items,
264 prsAutoMerged: autoMergeRows.length,
265 specsShipped: specRows.length,
266 ciHealed: ciHealRows.length,
267 secretsRepaired: secretRepairRows.length,
268 };
269 } catch (err) {
270 console.error("[dashboard] ai-activity-last-hour degraded:", err);
271 return empty;
272 }
273}
274
f1ab587Claude275const dashboard = new Hono<AuthEnv>();
276
277dashboard.use("*", softAuth);
278
279// ─── COMMAND CENTER ──────────────────────────────────────────
280
281dashboard.get("/dashboard", requireAuth, async (c) => {
282 const user = c.get("user")!;
283
c63b860Claude284 // Block P2 — banner dismiss handler. Set a session cookie and re-redirect
285 // to the bare /dashboard URL so refreshing doesn't keep firing the dismiss.
286 if (c.req.query("p2_dismiss") === "1") {
287 setCookie(c, "p2_verify_dismissed", "1", {
288 path: "/",
289 httpOnly: true,
290 sameSite: "Lax",
291 });
292 return c.redirect("/dashboard");
293 }
294
f1dc7c7Claude295 // ── Loading skeleton (flag-gated) ──
296 // Render an SSR'd structural preview when `?skeleton=1` is present.
297 // Keeps the user oriented on first paint while DB warms up. Behind a
298 // flag until we wire it to streamed/replaced content — we don't ship
299 // a flash before the real markup lands.
300 if (c.req.query("skeleton") === "1") {
301 return c.html(
302 <Layout title="Command Center" user={user}>
303 <DashboardSkeleton />
304 </Layout>
305 );
306 }
307
f1ab587Claude308 // Get all user's repos
309 const repos = await db
310 .select()
311 .from(repositories)
312 .where(eq(repositories.ownerId, user.id))
313 .orderBy(desc(repositories.updatedAt));
314
dc32319ccantynz-alt315 // "Open Issues" below used to sum repositories.issueCount, which is
316 // incremented in eight places and decremented in none — closing an issue
317 // never touches it. It is an issues-ever-created total, so the card was
318 // showing a number that could only ever go up. Count the open rows
319 // instead; see lib/issue-counts.ts.
320 const openIssuesTotal = await totalOpenIssues(
321 repos.map((r) => r.id),
322 repos.reduce((s, r) => s + (r.issueCount || 0), 0)
323 );
324
f1ab587Claude325 // Compute health scores for all repos (in parallel)
326 const repoData = await Promise.all(
327 repos.map(async (repo) => {
328 let healthScore = 0;
329 let healthGrade = "?" as string;
330 let recentCommits = 0;
331 let branchCount = 0;
332 let ciConfig = null;
333
334 try {
335 if (await repoExists(user.username, repo.name)) {
336 const ref =
337 (await getDefaultBranch(user.username, repo.name)) || "main";
338 const [health, commits, branches, ci] = await Promise.all([
339 computeHealthScore(user.username, repo.name).catch(() => null),
340 listCommits(user.username, repo.name, ref, 5).catch(() => []),
341 listBranches(user.username, repo.name).catch(() => []),
342 detectCIConfig(user.username, repo.name, ref).catch(() => null),
343 ]);
344 if (health) {
345 healthScore = health.score;
346 healthGrade = health.grade;
347 }
348 recentCommits = commits.length;
349 branchCount = branches.length;
350 ciConfig = ci;
351 }
352 } catch {
353 // best effort
354 }
355
356 return {
357 repo,
358 healthScore,
359 healthGrade,
360 recentCommits,
361 branchCount,
362 ciConfig,
363 };
364 })
365 );
366
46d6165Claude367 // Block L9 — AI hours-saved counter. Pull both window + lifetime in
368 // parallel; both helpers swallow DB errors so the dashboard always renders.
2d78948Claude369 // Also fetch the last-hour autopilot activity for the new widget.
370 const repoIds = repos.map((r) => r.id);
371 const repoMap = new Map(
372 repos.map((r) => [r.id, { name: r.name, ownerUsername: user.username }])
373 );
374 const [savingsWeek, savingsLifetime, aiActivity] = await Promise.all([
46d6165Claude375 computeAiSavingsForUser(user.id, { windowHours: 168 }),
376 computeLifetimeAiSavingsForUser(user.id),
2d78948Claude377 fetchAiActivityLastHour(user.id, repoIds, repoMap),
46d6165Claude378 ]);
379
f1ab587Claude380 // Get recent activity
381 let recentActivity: Array<{
382 action: string;
383 repoName: string;
384 metadata: string | null;
385 createdAt: Date;
386 }> = [];
387
388 try {
389 const repoIds = repos.map((r) => r.id);
390 if (repoIds.length > 0) {
391 const activity = await db
392 .select({
393 action: activityFeed.action,
394 metadata: activityFeed.metadata,
395 createdAt: activityFeed.createdAt,
396 repoId: activityFeed.repositoryId,
397 })
398 .from(activityFeed)
399 .where(eq(activityFeed.userId, user.id))
400 .orderBy(desc(activityFeed.createdAt))
401 .limit(20);
402
403 recentActivity = activity.map((a) => ({
404 action: a.action,
405 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
406 metadata: a.metadata,
407 createdAt: a.createdAt,
408 }));
409 }
410 } catch {
411 // DB not required for dashboard
412 }
413
920812bClaude414 // Review queue — open non-draft PRs in user's repos, authored by others
415 let reviewQueuePrs: Array<{
416 prNumber: number;
417 prTitle: string;
418 repoName: string;
419 createdAt: Date;
420 }> = [];
421 // Open PRs the user authored that are still open (anywhere on the platform)
422 let myOpenPrs: Array<{
423 prNumber: number;
424 prTitle: string;
425 repoName: string;
426 ownerUsername: string;
427 createdAt: Date;
428 }> = [];
429 try {
430 const repoIds = repos.map((r) => r.id);
431 const prQueries: Promise<any>[] = [];
432 if (repoIds.length > 0) {
433 prQueries.push(
434 db
435 .select({
436 prNumber: pullRequests.number,
437 prTitle: pullRequests.title,
438 repoName: repositories.name,
439 createdAt: pullRequests.createdAt,
440 })
441 .from(pullRequests)
442 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
443 .where(
444 and(
445 inArray(pullRequests.repositoryId, repoIds),
446 eq(pullRequests.state, "open"),
447 ne(pullRequests.authorId, user.id),
448 eq(pullRequests.isDraft, false),
449 )
450 )
451 .orderBy(desc(pullRequests.createdAt))
452 .limit(6)
453 );
454 } else {
455 prQueries.push(Promise.resolve([]));
456 }
457 prQueries.push(
458 db
459 .select({
460 prNumber: pullRequests.number,
461 prTitle: pullRequests.title,
462 repoName: repositories.name,
463 ownerUsername: users.username,
464 createdAt: pullRequests.createdAt,
465 })
466 .from(pullRequests)
467 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
468 .innerJoin(users, eq(repositories.ownerId, users.id))
469 .where(
470 and(
471 eq(pullRequests.authorId, user.id),
472 eq(pullRequests.state, "open"),
473 )
474 )
475 .orderBy(desc(pullRequests.updatedAt))
476 .limit(6)
477 );
478 const [queueRows, myPrRows] = await Promise.all(prQueries);
479 reviewQueuePrs = queueRows;
480 myOpenPrs = myPrRows;
481 } catch { /* non-blocking */ }
482
f1ab587Claude483 const gradeColor = (grade: string) =>
484 grade === "A+" || grade === "A"
485 ? "var(--green)"
486 : grade === "B"
487 ? "#58a6ff"
488 : grade === "C"
489 ? "var(--yellow)"
490 : grade === "?"
491 ? "var(--text-muted)"
492 : "var(--red)";
493
c63b860Claude494 // Block P2 — email verification banner. Shows when the user hasn't
495 // verified yet AND they haven't dismissed it this session. Also surfaces
496 // transient resend feedback (`?verify=sent` / `?verify=rate_limited`)
497 // and the post-register hint (`?welcome=1`).
498 const verifyDismissed = getCookie(c, "p2_verify_dismissed") === "1";
499 const showVerifyBanner =
500 !(user as any).emailVerifiedAt && !verifyDismissed;
501 const verifyQuery = c.req.query("verify");
502 const welcomeQuery = c.req.query("welcome");
503
f1ab587Claude504 return c.html(
505 <Layout title="Command Center" user={user}>
c63b860Claude506 {showVerifyBanner && (
507 <div
dc26881CC LABS App508 style="background: rgba(210, 153, 34, 0.12); border: 1px solid rgba(210, 153, 34, 0.45); color: #e3b341; padding: var(--space-3) var(--space-4); border-radius: 8px; margin-bottom: var(--space-4); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: 14px"
c63b860Claude509 data-p2-verify-banner=""
510 >
511 <div style="flex: 1 1 auto; min-width: 0">
512 {welcomeQuery === "1" ? (
513 <span>
514 Welcome to Gluecron! Check your inbox to verify your email.
515 </span>
516 ) : verifyQuery === "sent" ? (
517 <span>
518 Verification link sent. It may take a minute to arrive.
519 </span>
520 ) : verifyQuery === "rate_limited" ? (
521 <span>
522 You've requested too many verification emails. Try again later.
523 </span>
826eccfTest User524 ) : verifyQuery === "not_configured" ? (
525 <span>
526 Email delivery isn't configured on this instance yet — your
527 site admin needs to set <code>EMAIL_PROVIDER=resend</code> and{" "}
528 <code>RESEND_API_KEY</code>. Until then the verification link
529 is written to the server log.
530 </span>
c63b860Claude531 ) : (
532 <span>Verify your email to keep using Gluecron.</span>
533 )}
534 </div>
535 <form
536 method="post"
537 action="/verify-email/resend"
dc26881CC LABS App538 style="display: inline-flex; gap: var(--space-2); align-items: center; margin: 0"
c63b860Claude539 >
540 <input
541 type="hidden"
542 name="_csrf"
543 value={(c.get("csrfToken") as string | undefined) || ""}
544 />
545 <button
546 type="submit"
547 class="btn"
548 style="padding: 4px 10px; font-size: 12px"
549 >
550 Resend verification link
551 </button>
552 <a
553 href="/dashboard?p2_dismiss=1"
554 class="btn"
555 style="padding: 4px 10px; font-size: 12px"
556 aria-label="Dismiss verification banner"
557 >
558 Dismiss
559 </a>
560 </form>
561 </div>
562 )}
a004c46Claude563 <div class="dash-hero">
564 <div class="dash-hero-bg" aria-hidden="true">
565 <div class="dash-hero-orb" />
f1ab587Claude566 </div>
a004c46Claude567 <div class="dash-hero-inner">
568 <div class="dash-hero-text">
569 <div class="dash-hero-eyebrow">
570 {(() => {
571 const hour = new Date().getHours();
572 if (hour < 5) return "Late night,";
573 if (hour < 12) return "Good morning,";
574 if (hour < 17) return "Good afternoon,";
575 if (hour < 21) return "Good evening,";
576 return "Late night,";
577 })()}{" "}
578 <span class="dash-hero-username">{user.username}</span>
579 </div>
580 <h1 class="dash-hero-title">
581 Your{" "}
582 <span class="gradient-text">command center</span>.
583 </h1>
584 <p class="dash-hero-sub">
585 {repos.length === 0
586 ? "Create your first repository to start shipping with AI."
587 : `${repos.length} repo${repos.length === 1 ? "" : "s"} · real-time health, AI activity, and gate status across everything you own.`}
588 </p>
589 </div>
590 <div class="dash-hero-actions">
591 <a href="/new" class="btn btn-primary">+ New repo</a>
592 <a href="/import" class="btn">Import from GitHub</a>
593 <a href="/settings" class="btn">Settings</a>
594 </div>
f1ab587Claude595 </div>
596 </div>
a004c46Claude597 <style
598 dangerouslySetInnerHTML={{
599 __html: `
600 .dash-hero {
601 position: relative;
602 margin-bottom: var(--space-6);
603 padding: var(--space-5) var(--space-6) var(--space-5);
604 background: var(--bg-elevated);
605 border: 1px solid var(--border);
606 border-radius: 16px;
607 overflow: hidden;
608 }
609 .dash-hero::before {
610 content: '';
611 position: absolute;
612 top: 0; left: 0; right: 0;
613 height: 2px;
6fd5915Claude614 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
a004c46Claude615 opacity: 0.7;
616 pointer-events: none;
617 }
618 .dash-hero-bg {
619 position: absolute;
620 inset: -20% -10% auto auto;
621 width: 380px;
622 height: 380px;
623 pointer-events: none;
624 z-index: 0;
625 }
626 .dash-hero-orb {
627 position: absolute;
628 inset: 0;
6fd5915Claude629 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
a004c46Claude630 filter: blur(80px);
631 opacity: 0.7;
632 animation: dashHeroOrb 14s ease-in-out infinite;
633 }
634 @keyframes dashHeroOrb {
635 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
636 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
637 }
638 @media (prefers-reduced-motion: reduce) {
639 .dash-hero-orb { animation: none; }
640 }
641 .dash-hero-inner {
642 position: relative;
643 z-index: 1;
644 display: flex;
645 justify-content: space-between;
646 align-items: flex-end;
647 gap: var(--space-4);
648 flex-wrap: wrap;
649 }
650 .dash-hero-text { flex: 1; min-width: 280px; }
651 .dash-hero-eyebrow {
652 font-size: 13px;
653 color: var(--text-muted);
654 margin-bottom: var(--space-2);
655 letter-spacing: -0.005em;
656 }
657 .dash-hero-username {
658 color: var(--accent);
659 font-weight: 600;
660 }
661 .dash-hero-title {
662 font-size: clamp(28px, 4vw, 40px);
663 font-family: var(--font-display);
664 font-weight: 800;
665 letter-spacing: -0.028em;
666 line-height: 1.05;
667 margin: 0 0 var(--space-2);
668 color: var(--text-strong);
669 }
670 .dash-hero-title .gradient-text {
6fd5915Claude671 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
a004c46Claude672 -webkit-background-clip: text;
673 background-clip: text;
674 -webkit-text-fill-color: transparent;
675 color: transparent;
676 }
677 .dash-hero-sub {
678 font-size: 15px;
679 color: var(--text-muted);
680 margin: 0;
681 line-height: 1.5;
682 max-width: 580px;
683 }
684 .dash-hero-actions {
685 display: flex;
686 gap: var(--space-2);
687 flex-wrap: wrap;
688 }
689 @media (max-width: 720px) {
690 .dash-hero-inner { flex-direction: column; align-items: flex-start; }
691 .dash-hero-actions { width: 100%; }
f1dc7c7Claude692 .dash-hero-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
693 .dash-hero { padding: var(--space-4); }
694 .dash-hero-text { min-width: 0; }
695 .dash-hero-bg { width: 220px; height: 220px; inset: -10% -20% auto auto; }
696 .ai-hours-saved-tabs { flex-wrap: wrap; }
a004c46Claude697 }
698 `,
699 }}
700 />
f1ab587Claude701
46d6165Claude702 {/* ─── L9: AI hours-saved hero widget ─── */}
703 <AiHoursSavedWidget week={savingsWeek} lifetime={savingsLifetime} />
704
2d78948Claude705 {/* ─── AI Activity — Last Hour ─── */}
706 <AiActivityWidget activity={aiActivity} username={user.username} />
707
c6018a5Claude708 {/* ─── Quick actions — surfaces the 5 most-leverage AI features so
709 they're discoverable from the dashboard without diving into the
710 AI dropdown in the top nav. Scoped CSS under .qa- prefix. ─── */}
711 <QuickActionsPanel firstRepo={repos[0]} username={user.username} />
712
f1ab587Claude713 {/* ─── Stats Bar ─── */}
dc26881CC LABS App714 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8)">
f1ab587Claude715 <StatBox
716 label="Repositories"
717 value={String(repos.length)}
718 color="var(--text-link)"
719 />
720 <StatBox
721 label="Avg Health"
722 value={
723 repos.length > 0
724 ? String(
725 Math.round(
726 repoData.reduce((s, r) => s + r.healthScore, 0) /
727 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
728 )
729 )
730 : "—"
731 }
732 color="var(--green)"
733 />
734 <StatBox
735 label="Total Stars"
736 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
737 color="var(--yellow)"
738 />
739 <StatBox
740 label="Open Issues"
dc32319ccantynz-alt741 value={String(openIssuesTotal)}
f1ab587Claude742 color="var(--red)"
743 />
744 </div>
745
746 {/* ─── Repo Grid ─── */}
747 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
748 {repos.length === 0 ? (
dc26881CC LABS App749 <div class="empty-state" style="text-align:left;padding:var(--space-6)">
80bed05Claude750 <div style="text-align:center;margin-bottom:20px">
751 <h2 style="margin-bottom:6px">Get started</h2>
752 <p style="color:var(--text-muted);font-size:14px;margin:0">
753 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
754 </p>
755 </div>
756 <div class="panel" style="margin-bottom:20px;text-align:left">
dc26881CC LABS App757 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude758 <div style="flex:1">
759 <div style="font-size:15px;font-weight:600">Create a new repository</div>
760 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
761 Start from scratch with green-ecosystem defaults.
762 </div>
763 </div>
764 <a href="/new" class="btn btn-primary">Create repo</a>
765 </div>
dc26881CC LABS App766 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude767 <div style="flex:1">
768 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
769 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
770 Mirror an existing repo — history, branches, tags.
771 </div>
772 </div>
773 <a href="/import" class="btn">Import repo</a>
774 </div>
dc26881CC LABS App775 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude776 <div style="flex:1">
777 <div style="font-size:15px;font-weight:600">Browse public repos</div>
778 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
779 See what others are building, fork what you like.
780 </div>
781 </div>
782 <a href="/explore" class="btn">Browse</a>
783 </div>
784 </div>
dc26881CC LABS App785 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-4)">
80bed05Claude786 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
787 Push an existing project (preview)
788 </div>
789 <pre style="margin:0;font-size:12px;overflow-x:auto;color:var(--text-muted)"><code># Once you create a repo, you'll see your real clone URL here.
790git remote add gluecron http://localhost:3000/{user.username}/&lt;your-repo&gt;.git
791git push -u gluecron main</code></pre>
792 </div>
f1ab587Claude793 </div>
794 ) : (
dc26881CC LABS App795 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8)">
f1ab587Claude796 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
797 <div class="card" style="padding: 0; overflow: hidden">
798 {/* Health bar at top */}
799 <div
800 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
801 />
dc26881CC LABS App802 <div style="padding: var(--space-4)">
f1ab587Claude803 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
804 <div>
805 <h3 style="font-size: 16px; margin-bottom: 2px">
806 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
807 </h3>
808 {repo.description && (
809 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
810 {repo.description}
811 </p>
812 )}
813 </div>
814 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
815 <div
816 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
817 >
818 {healthGrade}
819 </div>
820 <div style="font-size: 10px; color: var(--text-muted)">
821 {healthScore}/100
822 </div>
823 </div>
824 </div>
825
dc26881CC LABS App826 <div style="display: flex; gap: var(--space-4); font-size: 12px; color: var(--text-muted); margin-top: var(--space-2)">
f1ab587Claude827 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
828 <span>{"\u2606"} {repo.starCount}</span>
829 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
830 </div>
831
832 {ciConfig && ciConfig.commands.length > 0 && (
dc26881CC LABS App833 <div style="margin-top: var(--space-2); display: flex; gap: 6px; flex-wrap: wrap">
f1ab587Claude834 {ciConfig.detected.slice(0, 3).map((d) => (
835 <span
836 class="badge"
837 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
838 >
839 {d}
840 </span>
841 ))}
842 </div>
843 )}
844
dc26881CC LABS App845 <div style="display: flex; gap: 6px; margin-top: var(--space-3)">
f1ab587Claude846 <a
847 href={`/${user.username}/${repo.name}/health`}
848 class="btn btn-sm"
849 style="font-size: 11px; padding: 2px 8px"
850 >
851 Health
852 </a>
853 <a
854 href={`/${user.username}/${repo.name}/dependencies`}
855 class="btn btn-sm"
856 style="font-size: 11px; padding: 2px 8px"
857 >
858 Deps
859 </a>
860 <a
861 href={`/${user.username}/${repo.name}/coupling`}
862 class="btn btn-sm"
863 style="font-size: 11px; padding: 2px 8px"
864 >
865 Insights
866 </a>
867 <a
868 href={`/${user.username}/${repo.name}/settings`}
869 class="btn btn-sm"
870 style="font-size: 11px; padding: 2px 8px"
871 >
872 Settings
873 </a>
874 </div>
875 </div>
876 </div>
877 ))}
878 </div>
879 )}
880
881 {/* ─── Activity Feed ─── */}
882 {recentActivity.length > 0 && (
883 <>
884 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
885 <div class="issue-list">
886 {recentActivity.map((a) => (
887 <div class="issue-item">
dc26881CC LABS App888 <div style="display: flex; gap: var(--space-2); align-items: center">
f1ab587Claude889 <ActivityIcon action={a.action} />
890 <div>
891 <span style="font-size: 14px">
892 {formatAction(a.action)} in{" "}
893 <a href={`/${user.username}/${a.repoName}`}>
894 {a.repoName}
895 </a>
896 </span>
897 <div style="font-size: 12px; color: var(--text-muted)">
898 {formatRelative(a.createdAt)}
899 </div>
900 </div>
901 </div>
902 </div>
903 ))}
904 </div>
905 </>
906 )}
907
7a14837Claude908 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
909 <HealthCoach repoData={repoData} username={user.username} />
910
febd4f0Claude911 {/* ─── Live Activity (SSE) ─── */}
912 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
913
920812bClaude914 {/* ─── Review queue + My open PRs ─── */}
915 {(reviewQueuePrs.length > 0 || myOpenPrs.length > 0) && (
916 <>
917 <style dangerouslySetInnerHTML={{ __html: `
918 .dash-rq { border:1px solid var(--border); border-radius:12px; overflow:hidden; }
919 .dash-rq-head { display:flex; align-items:center; justify-content:space-between; padding:11px 16px; background:var(--bg-elevated); border-bottom:1px solid var(--border); font-size:13px; font-weight:600; }
920 .dash-rq-head-count { font-size:11px; font-weight:700; padding:1px 7px; border-radius:9999px; background:var(--bg-tertiary); color:var(--text-muted); }
921 .dash-rq-row { display:flex; align-items:center; gap:10px; padding:9px 16px; border-bottom:1px solid var(--border); font-size:13px; text-decoration:none; color:inherit; }
922 .dash-rq-row:last-child { border-bottom:none; }
923 .dash-rq-row:hover { background:var(--bg-hover); }
924 .dash-rq-repo { font-size:11px; color:var(--text-muted); flex:0 0 auto; }
925 .dash-rq-title { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
926 .dash-rq-age { font-size:11px; color:var(--text-muted); flex:0 0 auto; }
927 .dash-rq-empty { padding:24px; text-align:center; color:var(--text-muted); font-size:13px; }
928 ` }} />
929 <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:var(--space-4);margin-top:var(--space-8)">
930 {reviewQueuePrs.length > 0 && (
931 <div class="dash-rq">
932 <div class="dash-rq-head">
933 <span>{"⏳"} Needs your review</span>
934 <span class="dash-rq-head-count">{reviewQueuePrs.length}</span>
935 </div>
936 {reviewQueuePrs.map((pr) => (
937 <a
938 href={`/${user.username}/${pr.repoName}/pulls/${pr.prNumber}`}
939 class="dash-rq-row"
940 >
941 <span class="dash-rq-repo">{pr.repoName}</span>
942 <span class="dash-rq-title" title={pr.prTitle}>{pr.prTitle}</span>
943 <span class="dash-rq-age">
944 {formatRelative(pr.createdAt)}
945 </span>
946 </a>
947 ))}
948 </div>
949 )}
950 {myOpenPrs.length > 0 && (
951 <div class="dash-rq">
952 <div class="dash-rq-head">
953 <span>{"○"} Your open PRs</span>
954 <span class="dash-rq-head-count">{myOpenPrs.length}</span>
955 </div>
956 {myOpenPrs.map((pr) => (
957 <a
958 href={`/${pr.ownerUsername}/${pr.repoName}/pulls/${pr.prNumber}`}
959 class="dash-rq-row"
960 >
961 <span class="dash-rq-repo">{pr.repoName}</span>
962 <span class="dash-rq-title" title={pr.prTitle}>{pr.prTitle}</span>
963 <span class="dash-rq-age">
964 {formatRelative(pr.createdAt)}
965 </span>
966 </a>
967 ))}
968 </div>
969 )}
970 </div>
971 </>
972 )}
973
f1ab587Claude974 {/* ─── Quick Links ─── */}
dc26881CC LABS App975 <div style="margin-top: var(--space-8); display: flex; gap: var(--space-4); flex-wrap: wrap">
f1ab587Claude976 <a href="/explore" class="btn">Browse public repos</a>
977 <a href="/settings/tokens" class="btn">API tokens</a>
978 <a href="/settings/keys" class="btn">SSH keys</a>
979 </div>
980 </Layout>
981 );
982});
983
984// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
985
986dashboard.get(
987 "/:owner/:repo/settings/intelligence",
988 requireAuth,
989 async (c) => {
990 const { owner: ownerName, repo: repoName } = c.req.param();
991 const user = c.get("user")!;
992 const success = c.req.query("success");
993
994 return c.html(
995 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
996 <div style="max-width: 600px">
997 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
998 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
999 Control what gluecron does automatically when code is pushed to{" "}
1000 <strong>{ownerName}/{repoName}</strong>.
1001 </p>
1002 {success && (
1003 <div class="auth-success">{decodeURIComponent(success)}</div>
1004 )}
1005 <form
0316dbbClaude1006 method="post"
f1ab587Claude1007 action={`/${ownerName}/${repoName}/settings/intelligence`}
1008 >
1009 <ToggleSetting
1010 name="auto_repair"
1011 label="Auto-Repair"
1012 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
1013 defaultChecked={true}
1014 />
1015 <ToggleSetting
1016 name="security_scan"
1017 label="Security Scanning"
1018 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
1019 defaultChecked={true}
1020 />
1021 <ToggleSetting
1022 name="health_score"
1023 label="Health Score"
1024 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
1025 defaultChecked={true}
1026 />
1027 <ToggleSetting
1028 name="push_analysis"
1029 label="Push Risk Analysis"
1030 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
1031 defaultChecked={true}
1032 />
1033 <ToggleSetting
1034 name="dep_analysis"
1035 label="Dependency Analysis"
1036 description="Build import graph, detect unused deps, find circular dependencies"
1037 defaultChecked={true}
1038 />
1039 <ToggleSetting
1040 name="gatetest"
1041 label="GateTest Integration"
1042 description="Send push events to GateTest for external security scanning"
1043 defaultChecked={true}
1044 />
1045 <ToggleSetting
90fa787Claude1046 name="deploy_webhook"
1047 label="Auto-Deploy Webhook"
1048 description="POST to your configured deploy webhook when pushing to the default branch"
f1ab587Claude1049 defaultChecked={true}
1050 />
1051
1052 <button
1053 type="submit"
1054 class="btn btn-primary"
1055 style="margin-top: 12px"
1056 >
1057 Save settings
1058 </button>
1059 </form>
1060 </div>
1061 </Layout>
1062 );
1063 }
1064);
1065
1066dashboard.post(
1067 "/:owner/:repo/settings/intelligence",
1068 requireAuth,
1069 async (c) => {
1070 const { owner: ownerName, repo: repoName } = c.req.param();
1071 // In production, these would be saved to DB per-repo
1072 // For now, acknowledge the settings
1073 return c.redirect(
1074 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
1075 );
1076 }
1077);
1078
1079// ─── PUSH LOG ────────────────────────────────────────────────
1080
1081dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
1082 const { owner, repo } = c.req.param();
1083 const user = c.get("user");
1084
1085 if (!(await repoExists(owner, repo))) return c.notFound();
1086 const ref = (await getDefaultBranch(owner, repo)) || "main";
1087 const commits = await listCommits(owner, repo, ref, 30);
1088
1089 return c.html(
1090 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
1091 <div style="max-width: 900px">
1092 <h2 style="margin-bottom: 4px">Push Log</h2>
1093 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
1094 Every push analyzed in real-time — risk scores, repairs, security alerts
1095 </p>
1096 <div class="issue-list">
1097 {commits.map((commit) => {
1098 // Determine if this was an auto-repair commit
1099 const isRepair =
1100 commit.author === "gluecron[bot]" ||
1101 commit.message.includes("auto-repair");
1102 const isRollback = commit.message.startsWith("revert: rollback");
1103
1104 return (
1105 <div class="issue-item" style="flex-direction: column; align-items: stretch">
1106 <div style="display: flex; justify-content: space-between; align-items: start">
dc26881CC LABS App1107 <div style="display: flex; gap: var(--space-2); align-items: start">
f1ab587Claude1108 {isRepair ? (
1109 <span
1110 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
1111 title="Auto-repair"
1112 >
1113 {"⚡"}
1114 </span>
1115 ) : isRollback ? (
1116 <span
1117 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
1118 title="Rollback"
1119 >
1120 {"↩"}
1121 </span>
1122 ) : (
1123 <span
1124 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
1125 >
1126 {"→"}
1127 </span>
1128 )}
1129 <div>
1130 <a
1131 href={`/${owner}/${repo}/commit/${commit.sha}`}
1132 style="font-weight: 600; font-size: 14px"
1133 >
1134 {commit.message}
1135 </a>
1136 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
1137 {commit.author} —{" "}
1138 {new Date(commit.date).toLocaleString("en-US", {
1139 month: "short",
1140 day: "numeric",
1141 hour: "2-digit",
1142 minute: "2-digit",
1143 })}
1144 </div>
1145 </div>
1146 </div>
1147 <a
1148 href={`/${owner}/${repo}/commit/${commit.sha}`}
1149 class="commit-sha"
1150 >
1151 {commit.sha.slice(0, 7)}
1152 </a>
1153 </div>
1154 {isRepair && (
1155 <div
dc26881CC LABS App1156 style="margin-top: var(--space-2); padding: var(--space-2) var(--space-3); background: rgba(63, 185, 80, 0.1); border-radius: var(--radius); font-size: 12px; color: var(--green)"
f1ab587Claude1157 >
1158 Automatically repaired by gluecron
1159 </div>
1160 )}
1161 </div>
1162 );
1163 })}
1164 </div>
1165 </div>
1166 </Layout>
1167 );
1168});
1169
1170// ─── COMPONENTS ──────────────────────────────────────────────
1171
2d78948Claude1172// ─── AI Activity — Last Hour widget ─────────────────────────────────────────
1173
1174const AI_ACTIVITY_ICON: Record<AiActivityItem["kind"], string> = {
1175 pr_auto_merged: "⮌", // ⬌ (merged)
1176 spec_shipped: "\u{1F4E6}", // 📦
1177 ci_healed: "⚡", // ⚡
1178 secret_repaired: "\u{1F512}", // 🔒
1179};
1180
1181const AI_ACTIVITY_LABEL: Record<AiActivityItem["kind"], string> = {
1182 pr_auto_merged: "PR auto-merged",
1183 spec_shipped: "Spec shipped",
1184 ci_healed: "CI healed",
1185 secret_repaired: "Secret repaired",
1186};
1187
1188const AI_ACTIVITY_COLOR: Record<AiActivityItem["kind"], string> = {
1189 pr_auto_merged: "var(--accent)",
1190 spec_shipped: "var(--green)",
1191 ci_healed: "#58a6ff",
1192 secret_repaired: "var(--yellow)",
1193};
1194
1195const AiActivityWidget = ({
1196 activity,
1197 username,
1198}: {
1199 activity: AiActivityReport;
1200 username: string;
1201}) => {
1202 const total =
1203 activity.prsAutoMerged +
1204 activity.specsShipped +
1205 activity.ciHealed +
1206 activity.secretsRepaired;
1207
1208 const summaryParts: string[] = [];
1209 if (activity.prsAutoMerged > 0)
1210 summaryParts.push(
1211 `${activity.prsAutoMerged} PR${activity.prsAutoMerged === 1 ? "" : "s"} auto-merged`
1212 );
1213 if (activity.specsShipped > 0)
1214 summaryParts.push(
1215 `${activity.specsShipped} spec${activity.specsShipped === 1 ? "" : "s"} shipped`
1216 );
1217 if (activity.ciHealed > 0)
1218 summaryParts.push(
1219 `${activity.ciHealed} CI run${activity.ciHealed === 1 ? "" : "s"} healed`
1220 );
1221 if (activity.secretsRepaired > 0)
1222 summaryParts.push(
1223 `${activity.secretsRepaired} secret${activity.secretsRepaired === 1 ? "" : "s"} repaired`
1224 );
1225
1226 // Show at most 6 items in the expanded list to keep the card compact.
1227 const shownItems = activity.items.slice(0, 6);
1228
1229 return (
1230 <div
1231 class="card"
1232 style="margin-bottom: var(--space-6); padding: 0; overflow: hidden"
1233 >
1234 {/* Card header */}
1235 <div
1236 style="display:flex;align-items:center;justify-content:space-between;padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border);background:var(--bg-elevated)"
1237 >
1238 <div style="display:flex;align-items:center;gap:var(--space-2)">
1239 <span style="font-size:15px" aria-hidden="true">{"\u{1F916}"}</span>
1240 <div>
1241 <span style="font-size:14px;font-weight:600;color:var(--text-strong)">
1242 AI Activity
1243 </span>
1244 <span
1245 style="margin-left:6px;font-size:11px;color:var(--text-muted);font-weight:400"
1246 >
1247 last hour
1248 </span>
1249 </div>
1250 </div>
1251 {total > 0 && (
1252 <span
6fd5915Claude1253 style="font-size:11px;font-weight:700;padding:2px 8px;border-radius:9999px;background:rgba(91,110,232,0.14);color:var(--accent);border:1px solid rgba(91,110,232,0.25)"
2d78948Claude1254 >
1255 {total} action{total === 1 ? "" : "s"}
1256 </span>
1257 )}
1258 </div>
1259
1260 {total === 0 ? (
1261 /* Empty state */
1262 <div
1263 style="padding:var(--space-5) var(--space-4);text-align:center;color:var(--text-muted);font-size:13px"
1264 >
1265 <div style="font-size:22px;margin-bottom:6px" aria-hidden="true">
1266 {"\u{1F441}"}
1267 </div>
1268 All quiet — AI is watching.
1269 </div>
1270 ) : (
1271 <>
1272 {/* Summary pills */}
1273 <div
1274 style="display:flex;flex-wrap:wrap;gap:6px;padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)"
1275 >
1276 {summaryParts.map((p) => (
1277 <span
1278 class="badge"
6fd5915Claude1279 style="font-size:12px;padding:3px 9px;background:rgba(91,110,232,0.08);border-color:rgba(91,110,232,0.22);color:var(--text)"
2d78948Claude1280 >
1281 {p}
1282 </span>
1283 ))}
1284 </div>
1285
1286 {/* Item list */}
1287 <ul style="list-style:none;margin:0;padding:0">
1288 {shownItems.map((item) => {
1289 // Build a link to the relevant resource if we have a number
1290 let href: string | undefined;
1291 if (item.refNumber !== null) {
1292 const base = `/${item.repoOwner}/${item.repoName}`;
1293 if (item.kind === "pr_auto_merged") {
1294 href = `${base}/pulls/${item.refNumber}`;
1295 } else if (item.kind === "spec_shipped") {
1296 href = `${base}/issues/${item.refNumber}`;
1297 }
1298 }
1299 const color = AI_ACTIVITY_COLOR[item.kind];
1300 const icon = AI_ACTIVITY_ICON[item.kind];
1301 const kindLabel = AI_ACTIVITY_LABEL[item.kind];
1302 return (
1303 <li
1304 style="display:flex;align-items:center;gap:10px;padding:8px var(--space-4);border-bottom:1px solid var(--border);font-size:13px"
1305 >
1306 <span
1307 style={`font-size:15px;width:20px;text-align:center;flex-shrink:0;color:${color}`}
1308 aria-label={kindLabel}
1309 >
1310 {icon}
1311 </span>
1312 <span
1313 style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
1314 title={item.label}
1315 >
1316 <span style="color:var(--text-muted);font-size:11px;margin-right:4px">
1317 {kindLabel}
1318 </span>
1319 {href ? (
1320 <a
1321 href={href}
1322 style="font-weight:500;color:var(--text)"
1323 >
1324 {item.label}
1325 </a>
1326 ) : (
1327 <span style="font-weight:500">{item.label}</span>
1328 )}
1329 </span>
1330 <span
1331 style="font-size:11px;color:var(--text-muted);flex-shrink:0;white-space:nowrap"
1332 >
1333 <a
1334 href={`/${item.repoOwner}/${item.repoName}`}
1335 style="color:var(--text-muted)"
1336 >
1337 {item.repoName}
1338 </a>
1339 <span style="margin-left:4px">
1340 {formatRelative(item.occurredAt)}
1341 </span>
1342 </span>
1343 </li>
1344 );
1345 })}
1346 </ul>
1347
1348 {activity.items.length > 6 && (
1349 <div
1350 style="padding:8px var(--space-4);font-size:12px;color:var(--text-muted);border-top:1px solid var(--border)"
1351 >
1352 + {activity.items.length - 6} more action{activity.items.length - 6 === 1 ? "" : "s"} in the last hour
1353 </div>
1354 )}
1355 </>
1356 )}
1357 </div>
1358 );
1359};
1360
46d6165Claude1361/**
1362 * Block L9 — pure formatter used by the dashboard widget AND tests.
1363 * Turns the breakdown into the small stat-pill array shown under the
1364 * big number. Exported so the markup contract is testable without
1365 * importing JSX.
1366 */
1367export function formatSavingsPills(b: {
1368 prsAutoMerged: number;
1369 issuesBuiltByAi: number;
1370 aiReviewsPosted: number;
1371 aiTriagesPosted: number;
1372 aiCommitMsgs: number;
1373 secretsAutoRepaired: number;
1374 gateAutoRepairs: number;
1375}): string[] {
1376 const pills: string[] = [];
1377 if (b.prsAutoMerged) pills.push(`${b.prsAutoMerged} PR${b.prsAutoMerged === 1 ? "" : "s"} auto-merged`);
1378 if (b.issuesBuiltByAi) pills.push(`${b.issuesBuiltByAi} issue${b.issuesBuiltByAi === 1 ? "" : "s"} built`);
1379 if (b.aiReviewsPosted) pills.push(`${b.aiReviewsPosted} AI review${b.aiReviewsPosted === 1 ? "" : "s"}`);
1380 if (b.aiTriagesPosted) pills.push(`${b.aiTriagesPosted} triage${b.aiTriagesPosted === 1 ? "" : "s"}`);
1381 const fixes = b.secretsAutoRepaired + b.gateAutoRepairs;
1382 if (fixes) pills.push(`${fixes} auto-fix${fixes === 1 ? "" : "es"}`);
1383 if (b.aiCommitMsgs) pills.push(`${b.aiCommitMsgs} commit msg${b.aiCommitMsgs === 1 ? "" : "s"}`);
1384 return pills;
1385}
1386
1387const AiHoursSavedWidget = ({
1388 week,
1389 lifetime,
1390}: {
1391 week: AiSavingsReport;
1392 lifetime: AiSavingsLifetimeReport;
1393}) => {
1394 const weekPills = formatSavingsPills(week.breakdown);
1395 const lifetimePills = formatSavingsPills(lifetime.breakdown);
1396 const hasAnyWeek = week.hoursSaved > 0 || weekPills.length > 0;
1397 return (
1398 <div
1399 class="card ai-hours-saved-widget"
1400 style="margin-bottom: 24px; padding: 0; overflow: hidden; position: relative; background: var(--accent-gradient-faint, var(--bg-secondary)); border-color: var(--accent)"
1401 >
dc26881CC LABS App1402 <div style="padding: var(--space-6) var(--space-6) var(--space-5) var(--space-6)">
1403 <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-4);flex-wrap:wrap">
46d6165Claude1404 <div style="flex:1;min-width:240px">
1405 <div style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px">
1406 AI working for you
1407 </div>
1408 <div
1409 data-testid="ai-hours-saved-this-week"
1410 style="font-size: 56px; font-weight: 800; line-height: 1; background: var(--accent-gradient); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent"
1411 >
1412 {week.hoursSaved.toFixed(1)}h
1413 </div>
1414 <div style="margin-top: 6px; font-size: 14px; color: var(--text-muted)">
1415 Claude saved you{" "}
1416 <strong style="color: var(--text)">
1417 {week.hoursSaved.toFixed(1)} hours
1418 </strong>{" "}
1419 this week.
1420 {lifetime.hoursSaved > week.hoursSaved && (
1421 <span>
1422 {" — "}
1423 <strong style="color: var(--text)">
1424 {lifetime.hoursSaved.toFixed(1)}h
1425 </strong>{" "}
1426 all-time.
1427 </span>
1428 )}
1429 </div>
1430 </div>
1431 <div
1432 class="ai-hours-saved-tabs"
1433 style="display:flex;gap:4px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;padding:2px"
1434 >
1435 <span
1436 data-tab="this-week"
1437 style="padding:4px 10px;font-size:12px;font-weight:600;border-radius:4px;background:var(--bg);color:var(--text)"
1438 >
1439 This week
1440 </span>
1441 <span
1442 data-tab="all-time"
1443 style="padding:4px 10px;font-size:12px;color:var(--text-muted)"
1444 >
1445 All-time
1446 </span>
1447 </div>
1448 </div>
1449
1450 {hasAnyWeek ? (
dc26881CC LABS App1451 <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-4)">
46d6165Claude1452 {weekPills.map((p) => (
1453 <span
1454 class="badge"
6fd5915Claude1455 style="font-size:12px;padding:4px 10px;background:rgba(91,110,232,0.10);border-color:var(--accent);color:var(--text)"
46d6165Claude1456 >
1457 {p}
1458 </span>
1459 ))}
1460 </div>
1461 ) : (
1462 <div style="margin-top:16px;font-size:13px;color:var(--text-muted)">
1463 No AI activity this week yet — open a PR, label an issue{" "}
1464 <code>ai:build</code>, or let auto-merge sweep your branches.
1465 The counter will start climbing.
1466 </div>
1467 )}
1468
1469 <details style="margin-top:16px">
1470 <summary
1471 data-testid="ai-hours-saved-formula-toggle"
1472 style="cursor:pointer;font-size:12px;color:var(--text-muted);user-select:none"
1473 >
1474 How is this calculated?
1475 </summary>
1476 <div
1477 style="margin-top:10px;padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);font-size:12px;color:var(--text-muted);font-family:var(--font-mono, monospace);line-height:1.6"
1478 >
1479 <div>hoursSaved =</div>
1480 <div>&nbsp;&nbsp;{week.breakdown.prsAutoMerged} PRs auto-merged × 0.30</div>
1481 <div>+ {week.breakdown.issuesBuiltByAi} issues built by AI × 1.50</div>
1482 <div>+ {week.breakdown.aiReviewsPosted} AI reviews × 0.25</div>
1483 <div>+ {week.breakdown.aiTriagesPosted} AI triages × 0.10</div>
1484 <div>+ {week.breakdown.aiCommitMsgs} AI commit msgs × 0.05</div>
1485 <div>+ {week.breakdown.secretsAutoRepaired} secrets repaired × 0.50</div>
1486 <div>+ {week.breakdown.gateAutoRepairs} gates repaired × 0.40</div>
1487 <div style="margin-top:6px;color:var(--text)">
1488 = {week.hoursSaved.toFixed(1)}h (this week,{" "}
1489 {week.windowHours}h window)
1490 </div>
1491 <div style="margin-top:8px;font-size:11px">
1492 Lifetime: {lifetime.hoursSaved.toFixed(1)}h since{" "}
1493 {lifetime.sinceCreatedAt.toISOString().slice(0, 10)}.
1494 Constants are conservative on purpose — audit-friendly is
1495 the brand.
1496 </div>
1497 {lifetimePills.length > 0 && (
1498 <div style="margin-top:8px;font-size:11px">
1499 All-time breakdown: {lifetimePills.join(" · ")}
1500 </div>
1501 )}
1502 </div>
1503 </details>
1504 </div>
1505 </div>
1506 );
1507};
1508
7a14837Claude1509/**
1510 * Pure helper: pick the bottom-N repos by health score and return a
1511 * prioritized "fix this next" plan. Health=0 repos (couldn't be
1512 * computed) are excluded so the coach doesn't recommend ghost repos.
1513 *
1514 * Exported under __test for unit testing without touching the DB.
1515 */
1516export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
1517 repoData: T[],
1518 topN = 3
1519): T[] {
1520 return repoData
1521 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
1522 .sort((a, b) => a.healthScore - b.healthScore)
1523 .slice(0, topN);
1524}
1525
1526/** Module-scoped color picker for grade chips. Mirrors the inner
1527 * `gradeColor` defined in the request handler scope, exposed at module
1528 * level so HealthCoach (also module-scope) can reach it. */
1529function moduleGradeColor(grade: string): string {
1530 if (grade === "A+" || grade === "A") return "var(--green)";
1531 if (grade === "B") return "#58a6ff";
1532 if (grade === "C") return "var(--yellow)";
1533 if (grade === "?") return "var(--text-muted)";
1534 return "var(--red)";
1535}
1536
1537const HealthCoach = ({
1538 repoData,
1539 username,
1540}: {
1541 repoData: Array<{
1542 repo: { name: string; description: string | null };
1543 healthScore: number;
1544 healthGrade: string;
1545 }>;
1546 username: string;
1547}) => {
1548 const picks = pickRepoCoachPicks(repoData, 3);
1549 if (picks.length === 0) {
1550 return (
1551 <div
1552 class="card"
dc26881CC LABS App1553 style="margin-bottom: var(--space-8); padding: var(--space-4); background: rgba(63,185,80,0.08); border-color: var(--green)"
7a14837Claude1554 >
1555 <h3 style="margin: 0 0 4px; font-size: 15px">
1556 {"✨"} AI Health Coach
1557 </h3>
1558 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
1559 All your repos are healthy (score &ge; 90). Nothing to triage.
1560 </p>
1561 </div>
1562 );
1563 }
1564 return (
1565 <div
1566 class="card"
1567 style="margin-bottom: 32px; padding: 0; overflow: hidden"
1568 >
1569 <div
dc26881CC LABS App1570 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
7a14837Claude1571 >
1572 <div>
1573 <h3 style="margin: 0; font-size: 15px">
1574 {"✨"} AI Health Coach
1575 </h3>
1576 <p
dc26881CC LABS App1577 style="margin: var(--space-1) 0 0; color: var(--text-muted); font-size: 12px"
7a14837Claude1578 >
1579 Top {picks.length} repos that would benefit from attention
1580 this week.
1581 </p>
1582 </div>
1583 </div>
1584 <ul style="list-style: none; margin: 0; padding: 0">
1585 {picks.map((p) => (
1586 <li
dc26881CC LABS App1587 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: var(--space-3)"
7a14837Claude1588 >
1589 <div
1590 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
1591 >
1592 {p.healthGrade}
1593 </div>
1594 <div style="flex: 1; min-width: 0">
1595 <a
1596 href={`/${username}/${p.repo.name}`}
1597 style="font-weight: 500"
1598 >
1599 {p.repo.name}
1600 </a>
1601 <div
1602 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
1603 >
1604 Health score {p.healthScore}/100 — open the repo to see
1605 breakdown + AI suggestions.
1606 </div>
1607 </div>
1608 <a
f077ea5Claude1609 href={`/${username}/${p.repo.name}/health`}
7a14837Claude1610 class="btn"
1611 style="font-size: 12px; padding: 4px 10px"
f077ea5Claude1612 title="Open health score with AI suggestions"
7a14837Claude1613 >
1614 Coach me
1615 </a>
1616 </li>
1617 ))}
1618 </ul>
1619 </div>
1620 );
1621};
1622
f1ab587Claude1623const StatBox = ({
1624 label,
1625 value,
1626 color,
1627}: {
1628 label: string;
1629 value: string;
1630 color: string;
1631}) => (
1632 <div
dc26881CC LABS App1633 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); text-align: center"
f1ab587Claude1634 >
1635 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
1636 {value}
1637 </div>
1638 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
1639 {label}
1640 </div>
1641 </div>
1642);
1643
1644const ToggleSetting = ({
1645 name,
1646 label,
1647 description,
1648 defaultChecked,
1649}: {
1650 name: string;
1651 label: string;
1652 description: string;
1653 defaultChecked: boolean;
1654}) => (
1655 <div
dc26881CC LABS App1656 style="display: flex; justify-content: space-between; align-items: start; padding: var(--space-4) 0; border-bottom: 1px solid var(--border)"
f1ab587Claude1657 >
1658 <div style="flex: 1">
1659 <div style="font-size: 15px; font-weight: 600">{label}</div>
1660 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
1661 {description}
1662 </div>
1663 </div>
1664 <label class="toggle-switch">
2228c49copilot-swe-agent[bot]1665 <input type="checkbox" name={name} value="on" checked={defaultChecked} aria-label={label} />
f1ab587Claude1666 <span class="toggle-slider" />
1667 </label>
1668 </div>
1669);
1670
1671const ActivityIcon = ({ action }: { action: string }) => {
1672 const icons: Record<string, string> = {
1673 push: "→",
1674 issue_open: "\u25CB",
1675 issue_close: "\u2713",
1676 pr_open: "\u25CB",
1677 pr_merge: "\u2B8C",
1678 star: "\u2605",
1679 fork: "\u2442",
1680 comment: "\u{1F4AC}",
1681 };
1682 return (
1683 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
1684 {icons[action] || "•"}
1685 </span>
1686 );
1687};
1688
1689function formatAction(action: string): string {
1690 const labels: Record<string, string> = {
1691 push: "Pushed code",
1692 issue_open: "Opened issue",
1693 issue_close: "Closed issue",
1694 pr_open: "Opened pull request",
1695 pr_merge: "Merged pull request",
1696 star: "Starred",
1697 fork: "Forked",
1698 comment: "Commented",
1699 };
1700 return labels[action] || action;
1701}
1702
1703function formatRelative(date: Date | string): string {
1704 const d = typeof date === "string" ? new Date(date) : date;
1705 const now = new Date();
1706 const diffMs = now.getTime() - d.getTime();
1707 const diffMins = Math.floor(diffMs / 60000);
1708 if (diffMins < 1) return "just now";
1709 if (diffMins < 60) return `${diffMins}m ago`;
1710 const diffHours = Math.floor(diffMins / 60);
1711 if (diffHours < 24) return `${diffHours}h ago`;
1712 const diffDays = Math.floor(diffHours / 24);
1713 if (diffDays < 30) return `${diffDays}d ago`;
1714 return d.toLocaleDateString("en-US", {
1715 month: "short",
1716 day: "numeric",
1717 });
1718}
1719
f1dc7c7Claude1720// ─── Loading skeleton (flag-gated; renders when ?skeleton=1) ──────────
1721const DashboardSkeleton = () => (
1722 <>
1723 <style
1724 dangerouslySetInnerHTML={{
1725 __html: `
1726 .dash-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: dashSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
1727 @keyframes dashSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1728 @media (prefers-reduced-motion: reduce) { .dash-skel { animation: none; } }
1729 .dash-skel-hero { height: 168px; border-radius: 16px; margin-bottom: var(--space-6); }
1730 .dash-skel-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8); }
1731 .dash-skel-stat { height: 86px; border-radius: var(--radius); }
1732 .dash-skel-h { height: 18px; width: 180px; margin: 0 0 16px; border-radius: 5px; }
1733 .dash-skel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8); }
1734 .dash-skel-card { height: 168px; border-radius: var(--radius); }
1735 .dash-skel-feed { display: flex; flex-direction: column; gap: 8px; }
1736 .dash-skel-feed-row { height: 52px; border-radius: var(--radius); }
1737 `,
1738 }}
1739 />
1740 <div class="dash-skel dash-skel-hero" aria-hidden="true" />
1741 <div class="dash-skel-stats" aria-hidden="true">
1742 <div class="dash-skel dash-skel-stat" />
1743 <div class="dash-skel dash-skel-stat" />
1744 <div class="dash-skel dash-skel-stat" />
1745 <div class="dash-skel dash-skel-stat" />
1746 </div>
1747 <div class="dash-skel dash-skel-h" aria-hidden="true" />
1748 <div class="dash-skel-grid" aria-hidden="true">
1749 <div class="dash-skel dash-skel-card" />
1750 <div class="dash-skel dash-skel-card" />
1751 <div class="dash-skel dash-skel-card" />
1752 <div class="dash-skel dash-skel-card" />
1753 </div>
1754 <div class="dash-skel dash-skel-h" aria-hidden="true" />
1755 <div class="dash-skel-feed" aria-hidden="true">
1756 <div class="dash-skel dash-skel-feed-row" />
1757 <div class="dash-skel dash-skel-feed-row" />
1758 <div class="dash-skel dash-skel-feed-row" />
1759 <div class="dash-skel dash-skel-feed-row" />
1760 <div class="dash-skel dash-skel-feed-row" />
1761 </div>
1762 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
1763 Loading your command center…
1764 </span>
1765 </>
1766);
1767
c6018a5Claude1768// ─── Quick actions — 5-card panel of the most-leverage AI features.
1769// Surfaces the post-K1 AI surfaces (Voice, Standups, Refactors, repo
1770// chat, Pulls dashboard) so signed-in users can discover them without
1771// hunting through the top-nav dropdown. CSS scoped under `.qa-` to
1772// avoid bleed; no shared component touched.
1773const QuickActionsPanel = ({
1774 firstRepo,
1775 username,
1776}: {
1777 firstRepo: { name: string } | undefined;
1778 username: string;
1779}) => {
1780 const chatHref = firstRepo
1781 ? `/${username}/${firstRepo.name}/chat`
1782 : "/explore";
1783 const chatSub = firstRepo
1784 ? `Open ${firstRepo.name} rubber-duck chat`
1785 : "Pick a repo, then ask anything";
1786 return (
1787 <div class="qa-panel" aria-label="Quick AI actions">
1788 <style
1789 dangerouslySetInnerHTML={{
1790 __html: `
1791 .qa-panel {
1792 position: relative;
1793 margin-bottom: var(--space-6);
1794 padding: var(--space-4) var(--space-5) var(--space-5);
1795 background: var(--bg-elevated);
1796 border: 1px solid var(--border);
1797 border-radius: 14px;
1798 overflow: hidden;
1799 }
1800 .qa-panel::before {
1801 content: '';
1802 position: absolute;
1803 top: 0; left: 0; right: 0;
1804 height: 1px;
6fd5915Claude1805 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
c6018a5Claude1806 opacity: 0.65;
1807 pointer-events: none;
1808 }
1809 .qa-eyebrow {
1810 font-size: 11px;
1811 font-weight: 600;
1812 letter-spacing: 0.08em;
1813 text-transform: uppercase;
1814 color: var(--accent);
1815 margin-bottom: 4px;
1816 }
1817 .qa-title {
1818 font-family: var(--font-display);
1819 font-size: 17px;
1820 font-weight: 700;
1821 letter-spacing: -0.018em;
1822 margin: 0 0 var(--space-3);
1823 color: var(--text-strong);
1824 }
1825 .qa-grid {
1826 display: grid;
1827 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1828 gap: var(--space-2);
1829 }
1830 .qa-card {
1831 display: flex;
1832 flex-direction: column;
1833 gap: 4px;
1834 padding: var(--space-3) var(--space-3);
1835 background: var(--bg);
1836 border: 1px solid var(--border);
1837 border-radius: 10px;
1838 color: var(--text);
1839 text-decoration: none;
1840 transition: border-color 160ms ease, transform 160ms ease, background 160ms ease;
1841 position: relative;
1842 overflow: hidden;
1843 }
1844 .qa-card::before {
1845 content: '';
1846 position: absolute;
1847 top: 0; left: 0; right: 0;
1848 height: 1px;
6fd5915Claude1849 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.55) 30%, rgba(95,143,160,0.55) 70%, transparent 100%);
c6018a5Claude1850 opacity: 0;
1851 transition: opacity 160ms ease;
1852 }
1853 .qa-card:hover {
6fd5915Claude1854 border-color: rgba(91,110,232,0.40);
c6018a5Claude1855 text-decoration: none;
1856 transform: translateY(-1px);
6fd5915Claude1857 background: linear-gradient(180deg, rgba(91,110,232,0.04), transparent);
c6018a5Claude1858 }
1859 .qa-card:hover::before { opacity: 1; }
1860 .qa-card-label {
1861 font-size: 13.5px;
1862 font-weight: 600;
1863 color: var(--text-strong);
1864 display: flex;
1865 align-items: center;
1866 gap: 6px;
1867 }
1868 .qa-card-sub {
1869 font-size: 12px;
1870 color: var(--text-muted);
1871 line-height: 1.4;
1872 }
1873 .qa-card-icon {
1874 display: inline-flex;
1875 width: 18px; height: 18px;
1876 align-items: center;
1877 justify-content: center;
1878 border-radius: 5px;
6fd5915Claude1879 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.14));
e589f77ccantynz-alt1880 color: var(--accent);
c6018a5Claude1881 font-size: 11px;
1882 font-weight: 700;
1883 flex-shrink: 0;
1884 }
1885 `,
1886 }}
1887 />
1888 <div class="qa-eyebrow">Quick actions</div>
1889 <h3 class="qa-title">Ship faster with Claude</h3>
1890 <div class="qa-grid">
1891 <a href="/voice" class="qa-card">
1892 <span class="qa-card-label">
1893 <span class="qa-card-icon" aria-hidden="true">{"\u{1F3A4}"}</span>
1894 Talk to ship
1895 </span>
1896 <span class="qa-card-sub">Voice → PR in one take</span>
1897 </a>
1898 <a href="/standups" class="qa-card">
1899 <span class="qa-card-label">
1900 <span class="qa-card-icon" aria-hidden="true">{"\u{1F4DD}"}</span>
1901 Today's standup
1902 </span>
1903 <span class="qa-card-sub">Daily AI brief, fresh on demand</span>
1904 </a>
1905 <a href="/refactors" class="qa-card">
1906 <span class="qa-card-label">
1907 <span class="qa-card-icon" aria-hidden="true">{"⚡"}</span>
1908 Refactor everywhere
1909 </span>
1910 <span class="qa-card-sub">One brief → PRs across all repos</span>
1911 </a>
1912 <a href={chatHref} class="qa-card">
1913 <span class="qa-card-label">
1914 <span class="qa-card-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
1915 Chat with a repo
1916 </span>
1917 <span class="qa-card-sub">{chatSub}</span>
1918 </a>
1919 <a href="/pulls" class="qa-card">
1920 <span class="qa-card-label">
1921 <span class="qa-card-icon" aria-hidden="true">{"\u{1F500}"}</span>
1922 Watch the PR queue
1923 </span>
1924 <span class="qa-card-sub">Global pulls across your repos</span>
1925 </a>
1926 </div>
1927 </div>
1928 );
1929};
1930
f1ab587Claude1931export default dashboard;