Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame784 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 = `
eed4684Claude56 .idash-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
e9aa4d8Claude57
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 {
cb5a796Claude462 // Only approved comments count toward the public-facing dashboard
463 // tally — see drizzle/0066 and src/lib/comment-moderation.ts.
e9aa4d8Claude464 const countRows = await db
465 .select({ c: sql<number>`count(*)::int` })
466 .from(issueComments)
cb5a796Claude467 .where(
468 and(
469 eq(issueComments.issueId, issueId),
470 eq(issueComments.moderationStatus, "approved")
471 )
472 );
e9aa4d8Claude473 commentCount = Number(countRows[0]?.c ?? 0);
474 } catch {
475 /* skip */
476 }
477
478 return { aiTriaged, suggestedFix, buildInProgress, commentCount };
479}
480
481issuesDashboard.get("/issues", requireAuth, async (c) => {
482 const user = c.get("user")!;
483 const raw = c.req.query("filter") || "mine";
484 const filter: IssueFilter = (VALID_FILTERS as string[]).includes(raw)
485 ? (raw as IssueFilter)
486 : "mine";
487
488 // Resolve the candidate repo set up-front for the "assigned" /
489 // "ai-working" / "needs-triage" tabs. "mine" walks issues.authorId
490 // directly and skips this lookup.
491 let repoIds: string[] = [];
492 if (filter !== "mine") {
493 try {
494 const ownedRepos = await db
495 .select({ id: repositories.id })
496 .from(repositories)
497 .where(eq(repositories.ownerId, user.id));
498 const collabRepos = await db
499 .select({ id: repoCollaborators.repositoryId })
500 .from(repoCollaborators)
501 .where(
502 and(
503 eq(repoCollaborators.userId, user.id),
504 isNotNull(repoCollaborators.acceptedAt)
505 )
506 );
507 repoIds = [
508 ...ownedRepos.map((r) => r.id),
509 ...collabRepos.map((r) => r.id),
510 ];
511 } catch (err) {
512 console.error("[issues-dashboard] repo set lookup failed:", err);
513 }
514 }
515
516 // Broad candidate fetch; Gluecron-native predicates (ai-working,
517 // needs-triage) are applied post-enrichment so we don't push them
518 // into SQL.
519 let candidates: Array<{
520 issue: typeof issues.$inferSelect;
521 repo: typeof repositories.$inferSelect;
522 }> = [];
523 try {
524 if (filter === "mine") {
525 candidates = await db
526 .select({ issue: issues, repo: repositories })
527 .from(issues)
528 .innerJoin(repositories, eq(repositories.id, issues.repositoryId))
529 .where(eq(issues.authorId, user.id))
530 .orderBy(desc(issues.updatedAt))
531 .limit(100);
532 } else if (repoIds.length > 0) {
533 candidates = await db
534 .select({ issue: issues, repo: repositories })
535 .from(issues)
536 .innerJoin(repositories, eq(repositories.id, issues.repositoryId))
537 .where(
538 and(
539 inArray(issues.repositoryId, repoIds),
540 eq(issues.state, "open")
541 )
542 )
543 .orderBy(desc(issues.updatedAt))
544 .limit(200);
545 }
546 } catch (err) {
547 console.error("[issues-dashboard] candidate query failed:", err);
548 }
549
550 // Resolve author + owner usernames in one batched lookup.
551 const userIds = new Set<string>();
552 for (const cand of candidates) {
553 userIds.add(cand.issue.authorId);
554 userIds.add(cand.repo.ownerId);
555 }
556 const usersById = new Map<string, string>();
557 if (userIds.size > 0) {
558 try {
559 const rows = await db
560 .select({ id: users.id, username: users.username })
561 .from(users)
562 .where(inArray(users.id, Array.from(userIds)));
563 for (const r of rows) usersById.set(r.id, r.username);
564 } catch (err) {
565 console.error("[issues-dashboard] username lookup failed:", err);
566 }
567 }
568
569 // Enrich + filter to the active tab. The two AI-shaped filters
570 // (ai-working, needs-triage) only make sense after we know the
571 // per-issue label set, so the SQL stays generic.
572 const rows: IssueRow[] = [];
573 for (const cand of candidates) {
574 const enriched = await enrichIssue(cand.issue.id);
575 const matches =
576 filter === "mine" ||
577 filter === "assigned" ||
578 (filter === "ai-working" && enriched.buildInProgress) ||
579 (filter === "needs-triage" &&
580 cand.issue.state === "open" &&
581 !enriched.aiTriaged);
582 if (!matches) continue;
583 rows.push({
584 issue: cand.issue,
585 repo: cand.repo,
586 authorUsername: usersById.get(cand.issue.authorId) || "unknown",
587 ownerUsername: usersById.get(cand.repo.ownerId) || "unknown",
588 ...enriched,
589 });
590 }
591
592 // Hero counters — best-effort. SQL-side for the cheap ones; derived
593 // from `rows` for the AI-shaped ones (which depend on enrichment
594 // and are scoped to the candidates we already pulled).
595 const counts = { mine: 0, assigned: 0, aiWorking: 0, needsTriage: 0 };
596 try {
597 const mineRows = await db
598 .select({ id: issues.id })
599 .from(issues)
600 .where(eq(issues.authorId, user.id));
601 counts.mine = mineRows.length;
602 if (repoIds.length > 0) {
603 const assigned = await db
604 .select({ id: issues.id })
605 .from(issues)
606 .where(
607 and(
608 inArray(issues.repositoryId, repoIds),
609 eq(issues.state, "open")
610 )
611 );
612 counts.assigned = assigned.length;
613 }
614 } catch {
615 /* keep 0s */
616 }
617
618 if (filter === "mine" || filter === "assigned") {
619 // For the AI-shaped counts we need enrichment. When the active
620 // tab IS one of those, `rows` is filtered down already — so do a
621 // cheap secondary pass over candidates for the headline.
622 let working = 0;
623 let triage = 0;
624 for (const cand of candidates) {
625 const enriched = await enrichIssue(cand.issue.id);
626 if (enriched.buildInProgress) working++;
627 if (cand.issue.state === "open" && !enriched.aiTriaged) triage++;
628 }
629 counts.aiWorking = working;
630 counts.needsTriage = triage;
631 } else {
632 counts.aiWorking = rows.filter((r) => r.buildInProgress).length;
633 counts.needsTriage = rows.filter(
634 (r) => r.issue.state === "open" && !r.aiTriaged
635 ).length;
636 }
637
638 return c.html(
639 <Layout title="Issues · Gluecron" user={user}>
640 <div class="idash-wrap">
641 <section class="idash-hero">
642 <div class="idash-orb" aria-hidden="true" />
643 <div class="idash-hero-inner">
644 <div class="idash-eyebrow">
645 Issue command center · live ·{" "}
646 <span style="color:var(--accent);font-weight:600">{user.username}</span>
647 </div>
648 <h1 class="idash-title">
649 <span class="idash-title-grad">Your issues.</span>
650 </h1>
651 <p class="idash-sub">
652 Every issue across every repo you touch — annotated with
653 the things only Gluecron knows. AI triage status, suggested
654 fixes, and whether autopilot is currently building one,
655 all live.
656 </p>
657 <div class="idash-stats">
658 <div class="idash-stat">
659 <div class="idash-stat-n">{counts.mine}</div>
660 <div class="idash-stat-l">Authored</div>
661 </div>
662 <div class="idash-stat">
663 <div class="idash-stat-n">{counts.assigned}</div>
664 <div class="idash-stat-l">In your repos</div>
665 </div>
666 <div class="idash-stat">
667 <div class="idash-stat-n">{counts.aiWorking}</div>
668 <div class="idash-stat-l">AI working</div>
669 </div>
670 <div class="idash-stat">
671 <div class="idash-stat-n">{counts.needsTriage}</div>
672 <div class="idash-stat-l">Needs triage</div>
673 </div>
674 </div>
675 </div>
676 </section>
677
678 <nav class="idash-tabs" aria-label="Issue filters">
679 <a href="/issues?filter=mine" class={"idash-tab " + (filter === "mine" ? "is-active" : "")}>
680 Mine <span class="idash-tab-count">{counts.mine}</span>
681 </a>
682 <a href="/issues?filter=assigned" class={"idash-tab " + (filter === "assigned" ? "is-active" : "")}>
683 Assigned <span class="idash-tab-count">{counts.assigned}</span>
684 </a>
685 <a href="/issues?filter=ai-working" class={"idash-tab " + (filter === "ai-working" ? "is-active" : "")}>
686 AI working <span class="idash-tab-count">{counts.aiWorking}</span>
687 </a>
688 <a href="/issues?filter=needs-triage" class={"idash-tab " + (filter === "needs-triage" ? "is-active" : "")}>
689 Needs triage <span class="idash-tab-count">{counts.needsTriage}</span>
690 </a>
691 </nav>
692
693 {rows.length === 0 ? (
694 <div class="idash-empty">
695 <h2 class="idash-empty-title">
696 {filter === "mine"
697 ? "You haven't opened any issues yet."
698 : filter === "assigned"
699 ? "No open issues in your repos."
700 : filter === "ai-working"
701 ? "Autopilot isn't building anything right now."
702 : "Triage queue is clear."}
703 </h2>
704 <p class="idash-empty-sub">
705 {filter === "mine"
706 ? "File one and it'll show up here with its full Gluecron signal — AI triage, suggested fix, build progress."
707 : filter === "assigned"
708 ? "When someone files an issue in a repo you own or collaborate on, it'll appear here already annotated by Claude."
709 : filter === "ai-working"
710 ? "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."
711 : "Every open issue has an AI label. Gluecron's autopilot has classified the entire backlog."}
712 </p>
713 {filter !== "mine" && (
714 <a href="/issues?filter=mine" class="btn">View your authored</a>
715 )}
716 {filter === "mine" && (
717 <a href="/explore" class="btn btn-primary">Browse repos</a>
718 )}
719 </div>
720 ) : (
721 <ul class="idash-list">
722 {rows.map((r) => {
723 const stateClass = r.issue.state === "closed" ? "is-closed" : "is-open";
724 return (
725 <li class="idash-row">
726 <StateIcon state={r.issue.state} />
727 <div class="idash-row-main">
728 <h3 class="idash-row-title">
729 <a href={`/${r.ownerUsername}/${r.repo.name}/issues/${r.issue.number}`}>
730 {r.issue.title}
731 </a>
732 <span class={"idash-state-pill " + stateClass}>
733 {r.issue.state}
734 </span>
735 <span class="idash-badges">
736 {r.buildInProgress && (
737 <span class="idash-badge ai-progress" title="Autopilot is building a fix for this issue">
738 <span class="dot" aria-hidden="true" /> Build in progress
739 </span>
740 )}
741 {r.suggestedFix && (
742 <span class="idash-badge ai-fix" title="Claude has posted a suggested fix">
743 <span class="dot" aria-hidden="true" /> Suggested fix
744 </span>
745 )}
746 {r.aiTriaged && (
747 <span class="idash-badge ai-triaged" title="Autopilot has classified this issue">
748 <span class="dot" aria-hidden="true" /> AI-triaged
749 </span>
750 )}
751 </span>
752 </h3>
753 <div class="idash-row-meta">
754 <span class="idash-row-repo">
755 {r.ownerUsername}/{r.repo.name} #{r.issue.number}
756 </span>
757 <span class="sep">·</span>
758 <span>
759 by <strong style="color:var(--text)">{r.authorUsername}</strong>
760 </span>
761 <span class="sep">·</span>
762 <span>updated {relTime(r.issue.updatedAt)}</span>
763 {r.commentCount > 0 && (
764 <>
765 <span class="sep">·</span>
766 <span class="idash-row-comments" title={`${r.commentCount} comment${r.commentCount === 1 ? "" : "s"}`}>
767 <CommentIcon /> {r.commentCount}
768 </span>
769 </>
770 )}
771 </div>
772 </div>
773 </li>
774 );
775 })}
776 </ul>
777 )}
778 </div>
779 <style dangerouslySetInnerHTML={{ __html: styles }} />
780 </Layout>
781 );
782});
783
784export default issuesDashboard;