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

issues-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.

issues-dashboard.tsxBlame798 lines · 2 contributors
e9aa4d8Claude1/**
2 * `/issues` — Global issues dashboard.
3 *
4 * Mirrors `/pulls`: hero with gradient hairline + orb, four tabular-nums
5 * stat counters, pill tab strip, and a list of issues. The differentiator
6 * over a GitHub-style "issues I authored" page is Gluecron-native AI
7 * signal on every row:
8 *
9 * - AI-TRIAGED — any label starting with `ai:` (autopilot has
10 * classified it)
11 * - SUGGESTED-FIX — an `issue_comments` row carries the
12 * ISSUE_TRIAGE_MARKER (Claude has posted a
13 * triage / suggested patch comment)
14 * - BUILD-IN-PROGRESS — labelled `ai:build` or `ai:in-progress`
15 * (autopilot is actively working on it)
16 * - COMMENT-COUNT — tabular-nums per row
17 *
18 * Filters:
19 * - mine — issues you opened
20 * - assigned — issues in repos you own / collaborate on
21 * - ai-working — autopilot is currently processing this one
22 * - needs-triage — open issues with NO ai:* label yet
23 *
24 * Scoped CSS under `.idash-*`. Does not touch shared layout / components.
25 */
26
27import { Hono } from "hono";
a33c82accantynz-alt28import { mapWithConcurrency, DB_FANOUT_LIMIT } from "../lib/concurrency";
e9aa4d8Claude29import {
30 and,
31 desc,
32 eq,
33 inArray,
34 isNotNull,
35 like,
36 sql,
37} from "drizzle-orm";
38import { db } from "../db";
39import {
40 issues,
41 issueComments,
42 issueLabels,
43 labels,
44 repositories,
45 repoCollaborators,
46 users,
47} from "../db/schema";
48import { Layout } from "../views/layout";
49import { softAuth, requireAuth } from "../middleware/auth";
50import type { AuthEnv } from "../middleware/auth";
51import { ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
52
53const issuesDashboard = new Hono<AuthEnv>();
54issuesDashboard.use("*", softAuth);
55
56const styles = `
eed4684Claude57 .idash-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
e9aa4d8Claude58
59 .idash-hero {
60 position: relative;
61 margin-bottom: var(--space-5);
62 padding: var(--space-5) var(--space-6);
63 background: var(--bg-elevated);
64 border: 1px solid var(--border);
65 border-radius: 16px;
66 overflow: hidden;
67 }
68 .idash-hero::before {
69 content: '';
70 position: absolute;
71 top: 0; left: 0; right: 0;
72 height: 2px;
6fd5915Claude73 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
e9aa4d8Claude74 opacity: 0.7;
75 pointer-events: none;
76 }
77 .idash-orb {
78 position: absolute;
79 inset: -30% -10% auto auto;
80 width: 380px; height: 380px;
6fd5915Claude81 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
e9aa4d8Claude82 filter: blur(80px);
83 opacity: 0.7;
84 pointer-events: none;
85 }
86 .idash-hero-inner { position: relative; z-index: 1; max-width: 760px; }
87 .idash-eyebrow {
88 font-size: 12.5px;
89 color: var(--text-muted);
90 margin-bottom: 8px;
91 letter-spacing: 0.04em;
92 text-transform: uppercase;
93 font-weight: 600;
94 }
95 .idash-title {
96 font-family: var(--font-display);
97 font-size: clamp(28px, 4vw, 40px);
98 font-weight: 800;
99 letter-spacing: -0.028em;
100 line-height: 1.05;
101 margin: 0 0 10px;
102 color: var(--text-strong);
103 }
104 .idash-title-grad {
6fd5915Claude105 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
e9aa4d8Claude106 -webkit-background-clip: text;
107 background-clip: text;
108 -webkit-text-fill-color: transparent;
109 color: transparent;
110 }
111 .idash-sub {
112 font-size: 15px;
113 color: var(--text-muted);
114 margin: 0;
115 line-height: 1.5;
116 }
117
118 .idash-stats {
119 display: flex;
120 gap: 24px;
121 margin-top: 16px;
122 flex-wrap: wrap;
123 }
124 .idash-stat {
125 display: flex;
126 flex-direction: column;
127 gap: 2px;
128 }
129 .idash-stat-n {
130 font-family: var(--font-display);
131 font-size: 22px;
132 font-weight: 700;
133 color: var(--text-strong);
134 font-variant-numeric: tabular-nums;
135 }
136 .idash-stat-l {
137 font-size: 11.5px;
138 color: var(--text-muted);
139 letter-spacing: 0.02em;
140 text-transform: uppercase;
141 }
142
143 .idash-tabs {
144 display: inline-flex;
145 background: var(--bg-elevated);
146 border: 1px solid var(--border);
147 border-radius: 9999px;
148 padding: 4px;
149 gap: 2px;
150 margin-bottom: var(--space-4);
151 flex-wrap: wrap;
152 }
153 .idash-tab {
154 display: inline-flex;
155 align-items: center;
156 gap: 6px;
157 padding: 7px 14px;
158 border-radius: 9999px;
159 font-size: 13.5px;
160 font-weight: 500;
161 color: var(--text-muted);
162 text-decoration: none;
163 transition: color 120ms ease, background 120ms ease;
164 }
165 .idash-tab:hover { color: var(--text-strong); text-decoration: none; }
166 .idash-tab.is-active {
6fd5915Claude167 background: rgba(91,110,232,0.14);
e9aa4d8Claude168 color: var(--text-strong);
169 }
170 .idash-tab-count {
171 font-variant-numeric: tabular-nums;
172 font-size: 11.5px;
173 background: rgba(255,255,255,0.04);
174 padding: 1px 7px;
175 border-radius: 9999px;
176 }
177 .idash-tab.is-active .idash-tab-count {
6fd5915Claude178 background: rgba(91,110,232,0.22);
e9aa4d8Claude179 color: var(--text);
180 }
181
182 .idash-list {
183 list-style: none;
184 margin: 0;
185 padding: 0;
186 border: 1px solid var(--border);
187 border-radius: 12px;
188 overflow: hidden;
189 background: var(--bg-elevated);
190 }
191 .idash-row {
192 display: flex;
193 align-items: flex-start;
194 gap: 14px;
195 padding: 14px 18px;
196 border-bottom: 1px solid var(--border);
197 transition: background 120ms ease;
198 }
199 .idash-row:last-child { border-bottom: none; }
6fd5915Claude200 .idash-row:hover { background: rgba(91,110,232,0.04); }
e9aa4d8Claude201 .idash-row-icon {
202 width: 18px; height: 18px;
203 flex-shrink: 0;
204 margin-top: 3px;
205 display: inline-flex;
206 align-items: center;
207 justify-content: center;
208 }
e589f77ccantynz-alt209 .idash-row-icon.is-open { color: var(--green); }
210 .idash-row-icon.is-closed { color: var(--accent); }
e9aa4d8Claude211 .idash-row-main { flex: 1; min-width: 0; }
212 .idash-row-title {
213 font-family: var(--font-display);
214 font-size: 15.5px;
215 font-weight: 600;
216 line-height: 1.35;
217 letter-spacing: -0.012em;
218 margin: 0 0 6px;
219 display: flex;
220 flex-wrap: wrap;
221 align-items: center;
222 gap: 8px;
223 }
224 .idash-row-title a {
225 color: var(--text-strong);
226 text-decoration: none;
227 }
228 .idash-row-title a:hover { color: var(--accent); }
229 .idash-row-repo {
230 font-family: var(--font-mono);
231 font-size: 12px;
232 color: var(--text-muted);
233 }
234 .idash-row-meta {
235 font-size: 12.5px;
236 color: var(--text-muted);
237 font-variant-numeric: tabular-nums;
238 display: flex;
239 gap: 10px;
240 flex-wrap: wrap;
241 align-items: center;
242 }
243 .idash-row-meta .sep { opacity: 0.45; }
244 .idash-row-comments {
245 display: inline-flex;
246 align-items: center;
247 gap: 4px;
248 font-variant-numeric: tabular-nums;
249 }
250
251 /* Gluecron-native badges: AI triage, suggested fix, in-progress */
252 .idash-badges {
253 display: inline-flex;
254 align-items: center;
255 gap: 6px;
256 flex-wrap: wrap;
257 margin-left: auto;
258 }
259 .idash-badge {
260 display: inline-flex;
261 align-items: center;
262 gap: 4px;
263 padding: 2px 8px;
264 font-size: 11px;
265 font-weight: 600;
266 border-radius: 9999px;
267 letter-spacing: 0.02em;
268 font-variant-numeric: tabular-nums;
269 }
270 .idash-badge .dot {
271 width: 6px; height: 6px;
272 border-radius: 9999px;
273 background: currentColor;
274 }
275 .idash-badge.ai-triaged { color: #93c5fd; background: rgba(96,165,250,0.12); box-shadow: inset 0 0 0 1px rgba(96,165,250,0.32); }
e589f77ccantynz-alt276 .idash-badge.ai-fix { color: var(--green); background: rgba(52,211,153,0.13); box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
277 .idash-badge.ai-progress { color: var(--accent); background: rgba(91,110,232,0.18); box-shadow: inset 0 0 0 1px rgba(91,110,232,0.36); }
e9aa4d8Claude278 .idash-badge.ai-progress .dot { animation: idashPulse 1.6s ease-in-out infinite; }
279
280 @keyframes idashPulse {
281 0%, 100% { opacity: 1; transform: scale(1); }
282 50% { opacity: 0.4; transform: scale(0.7); }
283 }
284
285 .idash-state-pill {
286 display: inline-flex;
287 align-items: center;
288 gap: 5px;
289 padding: 2px 9px;
290 font-size: 11px;
291 font-weight: 600;
292 border-radius: 9999px;
293 letter-spacing: 0.02em;
294 text-transform: uppercase;
295 }
e589f77ccantynz-alt296 .idash-state-pill.is-open { background: rgba(52,211,153,0.13); color: var(--green); box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
297 .idash-state-pill.is-closed { background: rgba(91,110,232,0.16); color: var(--accent); box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32); }
e9aa4d8Claude298
299 .idash-empty {
300 padding: 60px 20px;
301 text-align: center;
302 border: 1px dashed var(--border-strong);
303 border-radius: 14px;
304 background: var(--bg-elevated);
305 position: relative;
306 overflow: hidden;
307 }
308 .idash-empty::before {
309 content: '';
310 position: absolute;
311 inset: -20% -10% auto auto;
312 width: 320px; height: 320px;
6fd5915Claude313 background: radial-gradient(circle, rgba(91,110,232,0.10), transparent 70%);
e9aa4d8Claude314 filter: blur(60px);
315 pointer-events: none;
316 }
317 .idash-empty-title {
318 font-family: var(--font-display);
319 font-size: 22px;
320 font-weight: 700;
321 color: var(--text-strong);
322 margin: 0 0 6px;
323 position: relative;
324 }
325 .idash-empty-sub {
326 color: var(--text-muted);
327 font-size: 14px;
328 margin: 0 0 18px;
329 position: relative;
330 }
331 .idash-empty .btn { position: relative; }
332
333 @media (max-width: 720px) {
334 .idash-hero { padding: 24px 20px; }
335 .idash-row { padding: 12px 14px; flex-direction: column; }
336 .idash-badges { margin-left: 0; }
337 }
338`;
339
340type IssueFilter = "mine" | "assigned" | "ai-working" | "needs-triage";
341
342const VALID_FILTERS: IssueFilter[] = [
343 "mine",
344 "assigned",
345 "ai-working",
346 "needs-triage",
347];
348
349// Label-name prefixes that indicate autopilot has touched this issue.
350// `ai:build` and `ai:in-progress` specifically mean "currently working".
351const AI_LABEL_PREFIX = "ai:";
352const AI_WORKING_LABELS = ["ai:build", "ai:in-progress"];
353
354function relTime(d: Date): string {
355 const diff = Date.now() - d.getTime();
356 const s = Math.floor(diff / 1000);
357 if (s < 60) return `${s}s ago`;
358 const m = Math.floor(s / 60);
359 if (m < 60) return `${m}m ago`;
360 const h = Math.floor(m / 60);
361 if (h < 24) return `${h}h ago`;
362 const days = Math.floor(h / 24);
363 if (days < 30) return `${days}d ago`;
364 const months = Math.floor(days / 30);
365 if (months < 12) return `${months}mo ago`;
366 return `${Math.floor(months / 12)}y ago`;
367}
368
369function StateIcon({ state }: { state: string }) {
370 if (state === "closed") {
371 return (
372 <span class="idash-row-icon is-closed" aria-label="Closed">
373 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
374 <path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5z" />
375 <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0z" />
376 </svg>
377 </span>
378 );
379 }
380 return (
381 <span class="idash-row-icon is-open" aria-label="Open">
382 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
383 <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
384 <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0z" />
385 </svg>
386 </span>
387 );
388}
389
390function CommentIcon() {
391 return (
392 <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
393 <path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25z" />
394 </svg>
395 );
396}
397
398interface IssueRow {
399 issue: typeof issues.$inferSelect;
400 repo: typeof repositories.$inferSelect;
401 authorUsername: string;
402 ownerUsername: string;
403 aiTriaged: boolean;
404 suggestedFix: boolean;
405 buildInProgress: boolean;
406 commentCount: number;
407}
408
409/**
410 * Aggregate the Gluecron-native signal for one issue: AI-triaged?
411 * Suggested-fix comment? Build in progress? Comment count? All
412 * best-effort — any individual lookup failing returns the safest
413 * default (false / 0) rather than throwing.
414 */
415async function enrichIssue(issueId: string): Promise<{
416 aiTriaged: boolean;
417 suggestedFix: boolean;
418 buildInProgress: boolean;
419 commentCount: number;
420}> {
421 let aiTriaged = false;
422 let suggestedFix = false;
423 let buildInProgress = false;
424 let commentCount = 0;
425
426 try {
427 // One pass over the issue's labels — pulls names so we can check
428 // both the generic `ai:` prefix (triaged) and the specific
429 // "actively-working" labels in the same trip.
430 const labelRows = await db
431 .select({ name: labels.name })
432 .from(issueLabels)
433 .innerJoin(labels, eq(labels.id, issueLabels.labelId))
434 .where(eq(issueLabels.issueId, issueId));
435 for (const row of labelRows) {
436 const n = row.name.toLowerCase();
437 if (n.startsWith(AI_LABEL_PREFIX)) aiTriaged = true;
438 if (AI_WORKING_LABELS.includes(n)) buildInProgress = true;
439 }
440 } catch {
441 /* skip */
442 }
443
444 try {
445 // ISSUE_TRIAGE_MARKER lives in the rendered AI triage comment body.
446 // Presence == Claude has posted a suggested-fix / triage payload.
447 const [marker] = await db
448 .select({ id: issueComments.id })
449 .from(issueComments)
450 .where(
451 and(
452 eq(issueComments.issueId, issueId),
453 like(issueComments.body, `%${ISSUE_TRIAGE_MARKER}%`)
454 )
455 )
456 .limit(1);
457 if (marker) suggestedFix = true;
458 } catch {
459 /* skip */
460 }
461
462 try {
cb5a796Claude463 // Only approved comments count toward the public-facing dashboard
464 // tally — see drizzle/0066 and src/lib/comment-moderation.ts.
e9aa4d8Claude465 const countRows = await db
466 .select({ c: sql<number>`count(*)::int` })
467 .from(issueComments)
cb5a796Claude468 .where(
469 and(
470 eq(issueComments.issueId, issueId),
471 eq(issueComments.moderationStatus, "approved")
472 )
473 );
e9aa4d8Claude474 commentCount = Number(countRows[0]?.c ?? 0);
475 } catch {
476 /* skip */
477 }
478
479 return { aiTriaged, suggestedFix, buildInProgress, commentCount };
480}
481
482issuesDashboard.get("/issues", requireAuth, async (c) => {
483 const user = c.get("user")!;
484 const raw = c.req.query("filter") || "mine";
485 const filter: IssueFilter = (VALID_FILTERS as string[]).includes(raw)
486 ? (raw as IssueFilter)
487 : "mine";
488
489 // Resolve the candidate repo set up-front for the "assigned" /
490 // "ai-working" / "needs-triage" tabs. "mine" walks issues.authorId
491 // directly and skips this lookup.
492 let repoIds: string[] = [];
493 if (filter !== "mine") {
494 try {
495 const ownedRepos = await db
496 .select({ id: repositories.id })
497 .from(repositories)
498 .where(eq(repositories.ownerId, user.id));
499 const collabRepos = await db
500 .select({ id: repoCollaborators.repositoryId })
501 .from(repoCollaborators)
502 .where(
503 and(
504 eq(repoCollaborators.userId, user.id),
505 isNotNull(repoCollaborators.acceptedAt)
506 )
507 );
508 repoIds = [
509 ...ownedRepos.map((r) => r.id),
510 ...collabRepos.map((r) => r.id),
511 ];
512 } catch (err) {
513 console.error("[issues-dashboard] repo set lookup failed:", err);
514 }
515 }
516
517 // Broad candidate fetch; Gluecron-native predicates (ai-working,
518 // needs-triage) are applied post-enrichment so we don't push them
519 // into SQL.
520 let candidates: Array<{
521 issue: typeof issues.$inferSelect;
522 repo: typeof repositories.$inferSelect;
523 }> = [];
524 try {
525 if (filter === "mine") {
526 candidates = await db
527 .select({ issue: issues, repo: repositories })
528 .from(issues)
529 .innerJoin(repositories, eq(repositories.id, issues.repositoryId))
530 .where(eq(issues.authorId, user.id))
531 .orderBy(desc(issues.updatedAt))
532 .limit(100);
533 } else if (repoIds.length > 0) {
534 candidates = await db
535 .select({ issue: issues, repo: repositories })
536 .from(issues)
537 .innerJoin(repositories, eq(repositories.id, issues.repositoryId))
538 .where(
539 and(
540 inArray(issues.repositoryId, repoIds),
541 eq(issues.state, "open")
542 )
543 )
544 .orderBy(desc(issues.updatedAt))
545 .limit(200);
546 }
547 } catch (err) {
548 console.error("[issues-dashboard] candidate query failed:", err);
549 }
550
551 // Resolve author + owner usernames in one batched lookup.
552 const userIds = new Set<string>();
553 for (const cand of candidates) {
554 userIds.add(cand.issue.authorId);
555 userIds.add(cand.repo.ownerId);
556 }
557 const usersById = new Map<string, string>();
558 if (userIds.size > 0) {
559 try {
560 const rows = await db
561 .select({ id: users.id, username: users.username })
562 .from(users)
563 .where(inArray(users.id, Array.from(userIds)));
564 for (const r of rows) usersById.set(r.id, r.username);
565 } catch (err) {
566 console.error("[issues-dashboard] username lookup failed:", err);
567 }
568 }
569
570 // Enrich + filter to the active tab. The two AI-shaped filters
571 // (ai-working, needs-triage) only make sense after we know the
572 // per-issue label set, so the SQL stays generic.
a33c82accantynz-alt573 // enrichIssue issues 2 queries per issue and `candidates` holds up to 300,
574 // so awaiting inside a for-loop meant up to 600 SEQUENTIAL round-trips to
575 // Neon on every page load — the dominant cost of this route. Same queries,
576 // same results, just no longer serialised; the cap keeps us well inside the
577 // connection pool. Order is preserved, so the sort applied upstream still
578 // holds.
579 const enrichedAll = await mapWithConcurrency(
580 candidates,
581 DB_FANOUT_LIMIT,
582 (cand) => enrichIssue(cand.issue.id)
583 );
584
e9aa4d8Claude585 const rows: IssueRow[] = [];
a33c82accantynz-alt586 for (let i = 0; i < candidates.length; i++) {
587 const cand = candidates[i];
588 const enriched = enrichedAll[i];
e9aa4d8Claude589 const matches =
590 filter === "mine" ||
591 filter === "assigned" ||
592 (filter === "ai-working" && enriched.buildInProgress) ||
593 (filter === "needs-triage" &&
594 cand.issue.state === "open" &&
595 !enriched.aiTriaged);
596 if (!matches) continue;
597 rows.push({
598 issue: cand.issue,
599 repo: cand.repo,
600 authorUsername: usersById.get(cand.issue.authorId) || "unknown",
601 ownerUsername: usersById.get(cand.repo.ownerId) || "unknown",
602 ...enriched,
603 });
604 }
605
606 // Hero counters — best-effort. SQL-side for the cheap ones; derived
607 // from `rows` for the AI-shaped ones (which depend on enrichment
608 // and are scoped to the candidates we already pulled).
609 const counts = { mine: 0, assigned: 0, aiWorking: 0, needsTriage: 0 };
610 try {
611 const mineRows = await db
612 .select({ id: issues.id })
613 .from(issues)
614 .where(eq(issues.authorId, user.id));
615 counts.mine = mineRows.length;
616 if (repoIds.length > 0) {
617 const assigned = await db
618 .select({ id: issues.id })
619 .from(issues)
620 .where(
621 and(
622 inArray(issues.repositoryId, repoIds),
623 eq(issues.state, "open")
624 )
625 );
626 counts.assigned = assigned.length;
627 }
628 } catch {
629 /* keep 0s */
630 }
631
632 if (filter === "mine" || filter === "assigned") {
633 // For the AI-shaped counts we need enrichment. When the active
634 // tab IS one of those, `rows` is filtered down already — so do a
635 // cheap secondary pass over candidates for the headline.
636 let working = 0;
637 let triage = 0;
638 for (const cand of candidates) {
639 const enriched = await enrichIssue(cand.issue.id);
640 if (enriched.buildInProgress) working++;
641 if (cand.issue.state === "open" && !enriched.aiTriaged) triage++;
642 }
643 counts.aiWorking = working;
644 counts.needsTriage = triage;
645 } else {
646 counts.aiWorking = rows.filter((r) => r.buildInProgress).length;
647 counts.needsTriage = rows.filter(
648 (r) => r.issue.state === "open" && !r.aiTriaged
649 ).length;
650 }
651
652 return c.html(
653 <Layout title="Issues · Gluecron" user={user}>
654 <div class="idash-wrap">
655 <section class="idash-hero">
656 <div class="idash-orb" aria-hidden="true" />
657 <div class="idash-hero-inner">
658 <div class="idash-eyebrow">
659 Issue command center · live ·{" "}
660 <span style="color:var(--accent);font-weight:600">{user.username}</span>
661 </div>
662 <h1 class="idash-title">
663 <span class="idash-title-grad">Your issues.</span>
664 </h1>
665 <p class="idash-sub">
666 Every issue across every repo you touch — annotated with
667 the things only Gluecron knows. AI triage status, suggested
668 fixes, and whether autopilot is currently building one,
669 all live.
670 </p>
671 <div class="idash-stats">
672 <div class="idash-stat">
673 <div class="idash-stat-n">{counts.mine}</div>
674 <div class="idash-stat-l">Authored</div>
675 </div>
676 <div class="idash-stat">
677 <div class="idash-stat-n">{counts.assigned}</div>
678 <div class="idash-stat-l">In your repos</div>
679 </div>
680 <div class="idash-stat">
681 <div class="idash-stat-n">{counts.aiWorking}</div>
682 <div class="idash-stat-l">AI working</div>
683 </div>
684 <div class="idash-stat">
685 <div class="idash-stat-n">{counts.needsTriage}</div>
686 <div class="idash-stat-l">Needs triage</div>
687 </div>
688 </div>
689 </div>
690 </section>
691
692 <nav class="idash-tabs" aria-label="Issue filters">
693 <a href="/issues?filter=mine" class={"idash-tab " + (filter === "mine" ? "is-active" : "")}>
694 Mine <span class="idash-tab-count">{counts.mine}</span>
695 </a>
696 <a href="/issues?filter=assigned" class={"idash-tab " + (filter === "assigned" ? "is-active" : "")}>
697 Assigned <span class="idash-tab-count">{counts.assigned}</span>
698 </a>
699 <a href="/issues?filter=ai-working" class={"idash-tab " + (filter === "ai-working" ? "is-active" : "")}>
700 AI working <span class="idash-tab-count">{counts.aiWorking}</span>
701 </a>
702 <a href="/issues?filter=needs-triage" class={"idash-tab " + (filter === "needs-triage" ? "is-active" : "")}>
703 Needs triage <span class="idash-tab-count">{counts.needsTriage}</span>
704 </a>
705 </nav>
706
707 {rows.length === 0 ? (
708 <div class="idash-empty">
709 <h2 class="idash-empty-title">
710 {filter === "mine"
711 ? "You haven't opened any issues yet."
712 : filter === "assigned"
713 ? "No open issues in your repos."
714 : filter === "ai-working"
715 ? "Autopilot isn't building anything right now."
716 : "Triage queue is clear."}
717 </h2>
718 <p class="idash-empty-sub">
719 {filter === "mine"
720 ? "File one and it'll show up here with its full Gluecron signal — AI triage, suggested fix, build progress."
721 : filter === "assigned"
722 ? "When someone files an issue in a repo you own or collaborate on, it'll appear here already annotated by Claude."
723 : filter === "ai-working"
724 ? "Label any issue `ai:build` and Claude will pick it up on the next autopilot sweep — it'll show up here while the build runs."
725 : "Every open issue has an AI label. Gluecron's autopilot has classified the entire backlog."}
726 </p>
727 {filter !== "mine" && (
728 <a href="/issues?filter=mine" class="btn">View your authored</a>
729 )}
730 {filter === "mine" && (
731 <a href="/explore" class="btn btn-primary">Browse repos</a>
732 )}
733 </div>
734 ) : (
735 <ul class="idash-list">
736 {rows.map((r) => {
737 const stateClass = r.issue.state === "closed" ? "is-closed" : "is-open";
738 return (
739 <li class="idash-row">
740 <StateIcon state={r.issue.state} />
741 <div class="idash-row-main">
742 <h3 class="idash-row-title">
743 <a href={`/${r.ownerUsername}/${r.repo.name}/issues/${r.issue.number}`}>
744 {r.issue.title}
745 </a>
746 <span class={"idash-state-pill " + stateClass}>
747 {r.issue.state}
748 </span>
749 <span class="idash-badges">
750 {r.buildInProgress && (
751 <span class="idash-badge ai-progress" title="Autopilot is building a fix for this issue">
752 <span class="dot" aria-hidden="true" /> Build in progress
753 </span>
754 )}
755 {r.suggestedFix && (
756 <span class="idash-badge ai-fix" title="Claude has posted a suggested fix">
757 <span class="dot" aria-hidden="true" /> Suggested fix
758 </span>
759 )}
760 {r.aiTriaged && (
761 <span class="idash-badge ai-triaged" title="Autopilot has classified this issue">
762 <span class="dot" aria-hidden="true" /> AI-triaged
763 </span>
764 )}
765 </span>
766 </h3>
767 <div class="idash-row-meta">
768 <span class="idash-row-repo">
769 {r.ownerUsername}/{r.repo.name} #{r.issue.number}
770 </span>
771 <span class="sep">·</span>
772 <span>
773 by <strong style="color:var(--text)">{r.authorUsername}</strong>
774 </span>
775 <span class="sep">·</span>
776 <span>updated {relTime(r.issue.updatedAt)}</span>
777 {r.commentCount > 0 && (
778 <>
779 <span class="sep">·</span>
780 <span class="idash-row-comments" title={`${r.commentCount} comment${r.commentCount === 1 ? "" : "s"}`}>
781 <CommentIcon /> {r.commentCount}
782 </span>
783 </>
784 )}
785 </div>
786 </div>
787 </li>
788 );
789 })}
790 </ul>
791 )}
792 </div>
793 <style dangerouslySetInnerHTML={{ __html: styles }} />
794 </Layout>
795 );
796});
797
798export default issuesDashboard;