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

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

activity.tsxBlame766 lines · 1 contributor
e9aa4d8Claude1/**
2 * `/activity` — the user's personal timeline.
3 *
4 * One reverse-chronological feed of everything that touched the user's
5 * repos or was authored by them. The differentiator vs. github.com/
6 * <user> is the first-class AI lane: when Claude reviews a PR, triages
7 * an issue, or auto-merges, those events get pulled out of the noise
8 * with an AI-EVENT badge and a dedicated tab.
9 *
10 * Filters:
11 * - all — everything we have
12 * - ai — only `ai:*` actions (the differentiator)
13 * - code — push / merge / branch / commit events
14 * - social — stars / follows / forks
15 *
16 * Pagination: `?before=<ISO-timestamp>` walks back in pages of 100.
17 *
18 * Scoped CSS under `.act-*`.
19 */
20
21import { Hono } from "hono";
22import { and, desc, eq, inArray, lt, or } from "drizzle-orm";
23import { db } from "../db";
24import { activityFeed, repositories } from "../db/schema";
25import { Layout } from "../views/layout";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28
29const activity = new Hono<AuthEnv>();
30activity.use("*", softAuth);
31
32const styles = `
eed4684Claude33 .act-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
e9aa4d8Claude34
35 .act-hero {
36 position: relative;
37 margin-bottom: var(--space-5);
38 padding: var(--space-5) var(--space-6);
39 background: var(--bg-elevated);
40 border: 1px solid var(--border);
41 border-radius: 16px;
42 overflow: hidden;
43 }
44 .act-hero::before {
45 content: '';
46 position: absolute;
47 top: 0; left: 0; right: 0;
48 height: 2px;
49 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
50 opacity: 0.7;
51 pointer-events: none;
52 }
53 .act-orb {
54 position: absolute;
55 inset: -30% -10% auto auto;
56 width: 380px; height: 380px;
57 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
58 filter: blur(80px);
59 opacity: 0.7;
60 pointer-events: none;
61 }
62 .act-hero-inner { position: relative; z-index: 1; max-width: 780px; }
63 .act-eyebrow {
64 font-size: 12.5px;
65 color: var(--text-muted);
66 margin-bottom: 8px;
67 letter-spacing: 0.04em;
68 text-transform: uppercase;
69 font-weight: 600;
70 }
71 .act-title {
72 font-family: var(--font-display);
73 font-size: clamp(28px, 4vw, 40px);
74 font-weight: 800;
75 letter-spacing: -0.028em;
76 line-height: 1.05;
77 margin: 0 0 10px;
78 color: var(--text-strong);
79 }
80 .act-title-grad {
81 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
82 -webkit-background-clip: text;
83 background-clip: text;
84 -webkit-text-fill-color: transparent;
85 color: transparent;
86 }
87 .act-sub {
88 font-size: 15px;
89 color: var(--text-muted);
90 margin: 0;
91 line-height: 1.5;
92 }
93
94 .act-stats {
95 display: flex;
96 gap: 24px;
97 margin-top: 16px;
98 flex-wrap: wrap;
99 align-items: flex-end;
100 }
101 .act-stat { display: flex; flex-direction: column; gap: 2px; }
102 .act-stat-n {
103 font-family: var(--font-display);
104 font-size: 22px;
105 font-weight: 700;
106 color: var(--text-strong);
107 font-variant-numeric: tabular-nums;
108 }
109 .act-stat-l {
110 font-size: 11.5px;
111 color: var(--text-muted);
112 letter-spacing: 0.02em;
113 text-transform: uppercase;
114 }
115 .act-stat.ai .act-stat-n {
116 background-image: linear-gradient(135deg, #b69dff 0%, #36c5d6 100%);
117 -webkit-background-clip: text;
118 background-clip: text;
119 -webkit-text-fill-color: transparent;
120 color: transparent;
121 }
122
123 .act-spark {
124 display: inline-flex;
125 align-items: flex-end;
126 gap: 3px;
127 height: 28px;
128 margin-left: auto;
129 padding: 4px 8px;
130 border-radius: 6px;
131 background: rgba(255,255,255,0.02);
132 border: 1px solid var(--border);
133 }
134 .act-spark-bar {
135 width: 6px;
136 background: linear-gradient(180deg, #8c6dff 0%, #36c5d6 100%);
137 border-radius: 1px 1px 0 0;
138 opacity: 0.85;
139 min-height: 2px;
140 }
141 .act-spark-label {
142 font-size: 11px;
143 color: var(--text-muted);
144 margin-left: 8px;
145 letter-spacing: 0.04em;
146 text-transform: uppercase;
147 }
148
149 .act-tabs {
150 display: inline-flex;
151 background: var(--bg-elevated);
152 border: 1px solid var(--border);
153 border-radius: 9999px;
154 padding: 4px;
155 gap: 2px;
156 margin-bottom: var(--space-4);
157 flex-wrap: wrap;
158 }
159 .act-tab {
160 display: inline-flex;
161 align-items: center;
162 gap: 6px;
163 padding: 7px 14px;
164 border-radius: 9999px;
165 font-size: 13.5px;
166 font-weight: 500;
167 color: var(--text-muted);
168 text-decoration: none;
169 transition: color 120ms ease, background 120ms ease;
170 }
171 .act-tab:hover { color: var(--text-strong); text-decoration: none; }
172 .act-tab.is-active {
173 background: rgba(140,109,255,0.14);
174 color: var(--text-strong);
175 }
176 .act-tab-count {
177 font-variant-numeric: tabular-nums;
178 font-size: 11.5px;
179 background: rgba(255,255,255,0.04);
180 padding: 1px 7px;
181 border-radius: 9999px;
182 }
183 .act-tab.is-active .act-tab-count {
184 background: rgba(140,109,255,0.22);
185 color: var(--text);
186 }
187
188 .act-list {
189 list-style: none;
190 margin: 0;
191 padding: 0;
192 border: 1px solid var(--border);
193 border-radius: 12px;
194 overflow: hidden;
195 background: var(--bg-elevated);
196 }
197 .act-row {
198 display: flex;
199 align-items: flex-start;
200 gap: 14px;
201 padding: 14px 18px;
202 border-bottom: 1px solid var(--border);
203 transition: background 120ms ease;
204 text-decoration: none;
205 color: inherit;
206 }
207 .act-row:last-child { border-bottom: none; }
208 .act-row:hover { background: rgba(140,109,255,0.04); text-decoration: none; }
209 .act-row.is-ai {
210 background: linear-gradient(90deg, rgba(140,109,255,0.05) 0%, transparent 60%);
211 }
212 .act-row.is-ai:hover {
213 background: linear-gradient(90deg, rgba(140,109,255,0.10) 0%, rgba(140,109,255,0.03) 60%);
214 }
215
216 .act-row-icon {
217 width: 28px; height: 28px;
218 flex-shrink: 0;
219 border-radius: 8px;
220 display: inline-flex;
221 align-items: center;
222 justify-content: center;
223 font-size: 12px;
224 font-weight: 700;
225 letter-spacing: 0.04em;
226 text-transform: uppercase;
227 background: rgba(255,255,255,0.04);
228 color: var(--text-muted);
229 box-shadow: inset 0 0 0 1px var(--border);
230 }
231 .act-row-icon.kind-ai { background: rgba(140,109,255,0.18); color: #b69dff; box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40); }
232 .act-row-icon.kind-code { background: rgba(54,197,214,0.14); color: #6fd6e6; box-shadow: inset 0 0 0 1px rgba(54,197,214,0.32); }
233 .act-row-icon.kind-pr { background: rgba(52,211,153,0.13); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
234 .act-row-icon.kind-issue { background: rgba(251,191,36,0.12); color: #fde68a; box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30); }
235 .act-row-icon.kind-social { background: rgba(244,114,182,0.12); color: #f9a8d4; box-shadow: inset 0 0 0 1px rgba(244,114,182,0.30); }
236 .act-row-icon.kind-merge { background: rgba(140,109,255,0.16); color: #b69dff; box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32); }
237
238 .act-row-main { flex: 1; min-width: 0; }
239 .act-row-title {
240 font-family: var(--font-display);
241 font-size: 15px;
242 font-weight: 600;
243 line-height: 1.35;
244 letter-spacing: -0.012em;
245 margin: 0 0 4px;
246 display: flex;
247 flex-wrap: wrap;
248 align-items: center;
249 gap: 8px;
250 color: var(--text-strong);
251 }
252 .act-verb {
253 text-transform: lowercase;
254 font-weight: 600;
255 }
256 .act-verb.kind-ai { color: #b69dff; }
257 .act-verb.kind-code { color: #6fd6e6; }
258 .act-verb.kind-pr { color: #6ee7b7; }
259 .act-verb.kind-issue { color: #fde68a; }
260 .act-verb.kind-social { color: #f9a8d4; }
261 .act-verb.kind-merge { color: #b69dff; }
262
263 .act-badge {
264 display: inline-flex;
265 align-items: center;
266 gap: 4px;
267 padding: 2px 8px;
268 font-size: 10.5px;
269 font-weight: 700;
270 border-radius: 9999px;
271 letter-spacing: 0.06em;
272 text-transform: uppercase;
273 font-variant-numeric: tabular-nums;
274 }
275 .act-badge.ai-event {
276 color: #b69dff;
277 background: rgba(140,109,255,0.18);
278 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
279 }
280 .act-badge.ai-event .dot {
281 width: 6px; height: 6px;
282 border-radius: 9999px;
283 background: currentColor;
284 box-shadow: 0 0 8px currentColor;
285 }
286
287 .act-row-meta {
288 font-size: 12.5px;
289 color: var(--text-muted);
290 font-variant-numeric: tabular-nums;
291 display: flex;
292 gap: 10px;
293 flex-wrap: wrap;
294 align-items: center;
295 }
296 .act-row-meta .sep { opacity: 0.45; }
297 .act-row-repo {
298 font-family: var(--font-mono);
299 font-size: 12px;
300 color: var(--text);
301 }
302 .act-row-time {
303 font-variant-numeric: tabular-nums;
304 color: var(--text-muted);
305 }
306
307 .act-pager {
308 display: flex;
309 justify-content: center;
310 margin-top: var(--space-4);
311 }
312
313 .act-empty {
314 padding: 60px 20px;
315 text-align: center;
316 border: 1px dashed var(--border-strong);
317 border-radius: 14px;
318 background: var(--bg-elevated);
319 position: relative;
320 overflow: hidden;
321 }
322 .act-empty::before {
323 content: '';
324 position: absolute;
325 inset: -20% -10% auto auto;
326 width: 320px; height: 320px;
327 background: radial-gradient(circle, rgba(140,109,255,0.10), transparent 70%);
328 filter: blur(60px);
329 pointer-events: none;
330 }
331 .act-empty-title {
332 font-family: var(--font-display);
333 font-size: 22px;
334 font-weight: 700;
335 color: var(--text-strong);
336 margin: 0 0 6px;
337 position: relative;
338 }
339 .act-empty-sub {
340 color: var(--text-muted);
341 font-size: 14px;
342 margin: 0 0 18px;
343 position: relative;
344 }
345 .act-empty .btn { position: relative; }
346
347 @media (max-width: 720px) {
348 .act-hero { padding: 24px 20px; }
349 .act-row { padding: 12px 14px; }
350 .act-stats { gap: 16px; }
351 .act-spark { margin-left: 0; }
352 }
353`;
354
355type ActFilter = "all" | "ai" | "code" | "social";
356const VALID_FILTERS: ActFilter[] = ["all", "ai", "code", "social"];
357
358type ActKind = "ai" | "code" | "pr" | "issue" | "social" | "merge" | "other";
359
360interface Classified {
361 kind: ActKind;
362 isAi: boolean;
363 verb: string;
364 category: "ai" | "code" | "social" | "other";
365}
366
367/**
368 * Map a raw `action` string to a kind + display verb + filter category.
369 * The schema doesn't enforce a controlled vocabulary so we pattern-match
370 * defensively. `ai:` prefix wins regardless of the underlying action.
371 */
372function classify(action: string): Classified {
373 const a = (action || "").toLowerCase();
374 const isAi = a.startsWith("ai:") || a.includes(":ai") || a.includes(".ai.");
375 // The verb shown to the user — strip prefixes / normalise separators.
376 const verb = a.replace(/^ai:/, "").replace(/[._]/g, "-");
377
378 if (isAi) {
379 return { kind: "ai", isAi: true, verb, category: "ai" };
380 }
381 if (
382 a.includes("push") ||
383 a.includes("commit") ||
384 a.includes("branch") ||
385 a.includes("tag") ||
386 a.includes("merge") ||
387 a.includes("deploy")
388 ) {
389 const kind: ActKind = a.includes("merge") ? "merge" : "code";
390 return { kind, isAi: false, verb, category: "code" };
391 }
392 if (a.includes("pr") || a.includes("pull")) {
393 return { kind: "pr", isAi: false, verb, category: "code" };
394 }
395 if (a.includes("issue")) {
396 return { kind: "issue", isAi: false, verb, category: "other" };
397 }
398 if (
399 a.includes("star") ||
400 a.includes("fork") ||
401 a.includes("follow") ||
402 a.includes("watch")
403 ) {
404 return { kind: "social", isAi: false, verb, category: "social" };
405 }
406 return { kind: "other", isAi: false, verb, category: "other" };
407}
408
409function iconLabel(kind: ActKind): string {
410 switch (kind) {
411 case "ai":
412 return "AI";
413 case "code":
414 return "</>";
415 case "pr":
416 return "PR";
417 case "issue":
418 return "!";
419 case "social":
420 return "★";
421 case "merge":
422 return "M";
423 default:
424 return "·";
425 }
426}
427
428function relTime(d: Date): string {
429 const diff = Date.now() - d.getTime();
430 const s = Math.floor(diff / 1000);
431 if (s < 60) return `${s}s ago`;
432 const m = Math.floor(s / 60);
433 if (m < 60) return `${m}m ago`;
434 const h = Math.floor(m / 60);
435 if (h < 24) return `${h}h ago`;
436 const days = Math.floor(h / 24);
437 if (days < 30) return `${days}d ago`;
438 const months = Math.floor(days / 30);
439 if (months < 12) return `${months}mo ago`;
440 return `${Math.floor(months / 12)}y ago`;
441}
442
443/**
444 * Best-effort source URL for a row. Falls back to the repo page when the
445 * target type/id isn't enough to deep-link.
446 */
447function sourceHref(
448 ownerUsername: string,
449 repoName: string,
450 targetType: string | null,
451 targetId: string | null
452): string {
453 const base = `/${ownerUsername}/${repoName}`;
454 if (!targetId) return base;
455 switch (targetType) {
456 case "issue":
457 return `${base}/issues/${targetId}`;
458 case "pr":
459 case "pull_request":
460 return `${base}/pulls/${targetId}`;
461 case "commit":
462 return `${base}/commit/${targetId}`;
463 default:
464 return base;
465 }
466}
467
468activity.get("/activity", requireAuth, async (c) => {
469 const user = c.get("user")!;
470 const rawFilter = c.req.query("filter") || "all";
471 const filter: ActFilter = (VALID_FILTERS as string[]).includes(rawFilter)
472 ? (rawFilter as ActFilter)
473 : "all";
474
475 const beforeRaw = c.req.query("before");
476 let before: Date | null = null;
477 if (beforeRaw) {
478 const t = Date.parse(beforeRaw);
479 if (!Number.isNaN(t)) before = new Date(t);
480 }
481
482 // Build the candidate set: anything authored by the user OR scoped to
483 // a repo the user owns. Collaborator repos aren't pulled in here — the
484 // dashboard does that and it'd blow the timeline up with noise.
485 let ownedRepoIds: string[] = [];
486 try {
487 const owned = await db
488 .select({ id: repositories.id })
489 .from(repositories)
490 .where(eq(repositories.ownerId, user.id));
491 ownedRepoIds = owned.map((r) => r.id);
492 } catch (err) {
493 console.error("[activity] owned repo lookup failed:", err);
494 }
495
496 type Row = typeof activityFeed.$inferSelect;
497 let rows: Row[] = [];
498 try {
499 const scopeClause =
500 ownedRepoIds.length > 0
501 ? or(
502 eq(activityFeed.userId, user.id),
503 inArray(activityFeed.repositoryId, ownedRepoIds)
504 )!
505 : eq(activityFeed.userId, user.id);
506 const whereClause = before
507 ? and(scopeClause, lt(activityFeed.createdAt, before))!
508 : scopeClause;
509 rows = await db
510 .select()
511 .from(activityFeed)
512 .where(whereClause)
513 .orderBy(desc(activityFeed.createdAt))
514 .limit(100);
515 } catch (err) {
516 console.error("[activity] feed query failed:", err);
517 }
518
519 // Resolve repo names / owner usernames in one batched lookup so each
520 // row card can render the `owner/repo` mono pill + a deep link.
521 const repoIds = Array.from(new Set(rows.map((r) => r.repositoryId)));
522 const repoInfo = new Map<
523 string,
524 { name: string; ownerUsername: string }
525 >();
526 if (repoIds.length > 0) {
527 try {
528 const { users } = await import("../db/schema");
529 const repoRows = await db
530 .select({
531 id: repositories.id,
532 name: repositories.name,
533 ownerUsername: users.username,
534 })
535 .from(repositories)
536 .innerJoin(users, eq(users.id, repositories.ownerId))
537 .where(inArray(repositories.id, repoIds));
538 for (const r of repoRows) {
539 repoInfo.set(r.id, {
540 name: r.name,
541 ownerUsername: r.ownerUsername,
542 });
543 }
544 } catch (err) {
545 console.error("[activity] repo info lookup failed:", err);
546 }
547 }
548
549 // Classify once, then partition for tab counters + the active filter.
550 const classified = rows.map((r) => ({ row: r, info: classify(r.action) }));
551 const counts = {
552 all: classified.length,
553 ai: classified.filter((x) => x.info.category === "ai").length,
554 code: classified.filter((x) => x.info.category === "code").length,
555 social: classified.filter((x) => x.info.category === "social").length,
556 };
557
558 const visible = classified.filter((x) => {
559 if (filter === "all") return true;
560 return x.info.category === filter;
561 });
562
563 // "This week" mini sparkline — 7 buckets, 24h each, ending now. Cheap
564 // (operates on the already-fetched 100 rows; no extra query).
565 const weekBuckets = new Array<number>(7).fill(0);
566 const now = Date.now();
567 for (const x of classified) {
568 const ageMs = now - x.row.createdAt.getTime();
569 const day = Math.floor(ageMs / (24 * 3600 * 1000));
570 if (day >= 0 && day < 7) {
571 // Bucket 0 = oldest day in the week, 6 = today.
572 weekBuckets[6 - day] = (weekBuckets[6 - day] ?? 0) + 1;
573 }
574 }
575 const weekMax = Math.max(1, ...weekBuckets);
576 const weekTotal = weekBuckets.reduce((a, b) => a + b, 0);
577
578 const oldestVisible =
579 visible.length === 100 ? visible[visible.length - 1]!.row.createdAt : null;
580 const nextHref = oldestVisible
581 ? `/activity?filter=${filter}&before=${encodeURIComponent(
582 oldestVisible.toISOString()
583 )}`
584 : null;
585
586 const emptyCopy =
587 filter === "ai"
588 ? {
589 title: "No AI events yet.",
590 sub: "When Claude reviews a PR, triages an issue, or auto-merges in one of your repos, it'll land here with an AI-EVENT badge.",
591 }
592 : filter === "code"
593 ? {
594 title: "No code events yet.",
595 sub: "Pushes, merges, branches, and tags from your repos will appear here in real time.",
596 }
597 : filter === "social"
598 ? {
599 title: "No social activity yet.",
600 sub: "Stars, follows, and forks across your repos will surface here.",
601 }
602 : {
603 title: "Your timeline is quiet.",
604 sub: "Push a branch, open a PR, or star a repo — everything Gluecron sees lands here, ordered newest first.",
605 };
606
607 return c.html(
608 <Layout title="Activity · Gluecron" user={user}>
609 <div class="act-wrap">
610 <section class="act-hero">
611 <div class="act-orb" aria-hidden="true" />
612 <div class="act-hero-inner">
613 <div class="act-eyebrow">
614 Personal timeline · live ·{" "}
615 <span style="color:var(--accent);font-weight:600">
616 {user.username}
617 </span>
618 </div>
619 <h1 class="act-title">
620 <span class="act-title-grad">Your activity.</span>
621 </h1>
622 <p class="act-sub">
623 Everything that's happened across your repos — pushes, PRs,
624 issues, stars — plus the things Claude did on your behalf.
625 AI events surface separately so you always see the work that
626 shipped while you were away.
627 </p>
628 <div class="act-stats">
629 <div class="act-stat">
630 <div class="act-stat-n">{counts.all}</div>
631 <div class="act-stat-l">Total</div>
632 </div>
633 <div class="act-stat ai">
634 <div class="act-stat-n">{counts.ai}</div>
635 <div class="act-stat-l">AI events</div>
636 </div>
637 <div class="act-stat">
638 <div class="act-stat-n">{counts.code}</div>
639 <div class="act-stat-l">Code</div>
640 </div>
641 <div class="act-stat">
642 <div class="act-stat-n">{counts.social}</div>
643 <div class="act-stat-l">Social</div>
644 </div>
645 {weekTotal > 0 && (
646 <div class="act-spark" aria-label="Events per day, last 7 days">
647 {weekBuckets.map((n) => (
648 <div
649 class="act-spark-bar"
650 style={`height: ${Math.max(2, Math.round((n / weekMax) * 24))}px`}
651 title={`${n} event${n === 1 ? "" : "s"}`}
652 />
653 ))}
654 <span class="act-spark-label">7d · {weekTotal}</span>
655 </div>
656 )}
657 </div>
658 </div>
659 </section>
660
661 <nav class="act-tabs" aria-label="Activity filters">
662 <a
663 href="/activity?filter=all"
664 class={"act-tab " + (filter === "all" ? "is-active" : "")}
665 >
666 All <span class="act-tab-count">{counts.all}</span>
667 </a>
668 <a
669 href="/activity?filter=ai"
670 class={"act-tab " + (filter === "ai" ? "is-active" : "")}
671 >
672 AI <span class="act-tab-count">{counts.ai}</span>
673 </a>
674 <a
675 href="/activity?filter=code"
676 class={"act-tab " + (filter === "code" ? "is-active" : "")}
677 >
678 Code <span class="act-tab-count">{counts.code}</span>
679 </a>
680 <a
681 href="/activity?filter=social"
682 class={"act-tab " + (filter === "social" ? "is-active" : "")}
683 >
684 Social <span class="act-tab-count">{counts.social}</span>
685 </a>
686 </nav>
687
688 {visible.length === 0 ? (
689 <div class="act-empty">
690 <h2 class="act-empty-title">{emptyCopy.title}</h2>
691 <p class="act-empty-sub">{emptyCopy.sub}</p>
692 <a href="/explore" class="btn btn-primary">
693 Explore repos
694 </a>
695 </div>
696 ) : (
697 <>
698 <ul class="act-list">
699 {visible.map((x) => {
700 const info = repoInfo.get(x.row.repositoryId);
701 const repoName = info?.name || "unknown";
702 const ownerUsername = info?.ownerUsername || "unknown";
703 const href = sourceHref(
704 ownerUsername,
705 repoName,
706 x.row.targetType,
707 x.row.targetId
708 );
709 const kindClass = `kind-${x.info.kind}`;
710 return (
711 <li>
712 <a
713 href={href}
714 class={"act-row " + (x.info.isAi ? "is-ai" : "")}
715 >
716 <span
717 class={"act-row-icon " + kindClass}
718 aria-hidden="true"
719 >
720 {iconLabel(x.info.kind)}
721 </span>
722 <div class="act-row-main">
723 <h3 class="act-row-title">
724 <span class={"act-verb " + kindClass}>
725 {x.info.verb || "event"}
726 </span>
727 {x.info.isAi && (
728 <span
729 class="act-badge ai-event"
730 title="Claude did this for you"
731 >
732 <span class="dot" aria-hidden="true" /> AI-EVENT
733 </span>
734 )}
735 </h3>
736 <div class="act-row-meta">
737 <span class="act-row-repo">
738 {ownerUsername}/{repoName}
739 </span>
740 <span class="sep">·</span>
741 <span class="act-row-time">
742 {relTime(x.row.createdAt)}
743 </span>
744 </div>
745 </div>
746 </a>
747 </li>
748 );
749 })}
750 </ul>
751 {nextHref && (
752 <div class="act-pager">
753 <a href={nextHref} class="btn">
754 Older
755 </a>
756 </div>
757 )}
758 </>
759 )}
760 </div>
761 <style dangerouslySetInnerHTML={{ __html: styles }} />
762 </Layout>
763 );
764});
765
766export default activity;