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