Commit8c790e0unknown_key
Add pulsing Live indicator to repo header — Push Watch discoverability
Add pulsing Live indicator to repo header — Push Watch discoverability
- RepoHeader now accepts a `recentPush?: { sha, ageMs }` prop
- Push < 5 min old → pulsing red "● Live" badge links to /:owner/:repo/push/:sha
- Push < 24 hr old → dimmer "○ Watch" link to the same page
- `getRecentPush(repoId)` queries activity_feed (action='push') for the
last push within 24 h; wired into the repo home page and commits page
- Commits page also gains an eye-icon "watch" link on every commit row
as a second discovery path to Push Watch
- CSS: @keyframes pushWatchPulse + .repo-header-live-badge styles added
to layout.tsx; .commits-row-watch added to codeBrowseCss in web.tsx
- respects prefers-reduced-motion: animation disabled when set
- zero new dependencies; degrades gracefully on DB failure (null → no badge)
https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR3 files changed+242−718c790e0353be1c51c30ed7fa2cd915391273cfb8
3 changed files+242−71
Modifiedsrc/routes/web.tsx+103−15View fileUnifiedSplit
@@ -13,6 +13,7 @@ import {
1313 repositories,
1414 stars,
1515 commitVerifications,
16 activityFeed,
1617} from "../db/schema";
1718import { Layout } from "../views/layout";
1819import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
@@ -25,6 +26,7 @@ import {
2526 BranchSwitcher,
2627 HighlightedCode,
2728 PlainCode,
29 type RecentPush,
2830} from "../views/components";
2931import { DiffView } from "../views/diff-view";
3032import {
@@ -69,6 +71,42 @@ const web = new Hono<AuthEnv>();
6971// Soft auth on all web routes — c.get("user") available but may be null
7072web.use("*", softAuth);
7173
74/**
75 * Query the most recent push to a repo from the activity_feed table.
76 * Returns a RecentPush if there's been a push in the last 24 hours,
77 * or null otherwise. Used by the RepoHeader live/watch indicator.
78 *
79 * Queries activity_feed where action='push' and targetId holds the commit SHA.
80 */
81async function getRecentPush(repoId: string): Promise<RecentPush | null> {
82 try {
83 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
84 const [row] = await db
85 .select({
86 targetId: activityFeed.targetId,
87 createdAt: activityFeed.createdAt,
88 })
89 .from(activityFeed)
90 .where(
91 and(
92 eq(activityFeed.repositoryId, repoId),
93 eq(activityFeed.action, "push"),
94 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
95 )
96 )
97 .orderBy(desc(activityFeed.createdAt))
98 .limit(1);
99 if (!row || !row.targetId) return null;
100 return {
101 sha: row.targetId,
102 ageMs: Date.now() - new Date(row.createdAt).getTime(),
103 };
104 } catch {
105 // DB unavailable or schema mismatch — degrade gracefully.
106 return null;
107 }
108}
109
72110/**
73111 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
74112 *
@@ -882,6 +920,27 @@ const codeBrowseCss = `
882920 background: rgba(255,255,255,0.04);
883921 }
884922 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
923 /* Push Watch link — eye icon next to the SHA chip */
924 .commits-row-watch {
925 display: inline-flex;
926 align-items: center;
927 justify-content: center;
928 width: 28px;
929 height: 28px;
930 border-radius: 6px;
931 border: 1px solid var(--border);
932 color: var(--text-muted);
933 background: transparent;
934 cursor: pointer;
935 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
936 text-decoration: none !important;
937 }
938 .commits-row-watch:hover {
939 border-color: rgba(140,109,255,0.45);
940 color: var(--accent);
941 background: rgba(140,109,255,0.08);
942 text-decoration: none !important;
943 }
885944 .commits-empty {
886945 position: relative;
887946 overflow: hidden;
@@ -2409,6 +2468,12 @@ web.get("/:owner/:repo", async (c) => {
24092468 }
24102469 }
24112470
2471 // Push Watch discoverability — fetch the most recent push within 24 h.
2472 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
2473 const recentPush: RecentPush | null = repoId
2474 ? await getRecentPush(repoId)
2475 : null;
2476
24122477 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
24132478 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
24142479 const repoHomeCss = `
@@ -3039,6 +3104,7 @@ web.get("/:owner/:repo", async (c) => {
30393104 currentUser={user?.username}
30403105 archived={archived}
30413106 isTemplate={isTemplate}
3107 recentPush={recentPush}
30423108 />
30433109 {healthScore && (() => {
30443110 const gradeLabel: Record<string, string> = {
@@ -3157,6 +3223,7 @@ web.get("/:owner/:repo", async (c) => {
31573223 currentUser={user?.username}
31583224 archived={archived}
31593225 isTemplate={isTemplate}
3226 recentPush={recentPush}
31603227 />
31613228 {healthScore && (() => {
31623229 const gradeLabel: Record<string, string> = {
@@ -4363,7 +4430,10 @@ web.get("/:owner/:repo/commits/:ref?", async (c) => {
43634430 const commits = await listCommits(owner, repo, ref, 50);
43644431
43654432 // Block J3 — batch-fetch cached verification results for the page.
4433 // Also resolve repoId here so we can query the recent push for the
4434 // Push Watch live/watch indicator in the header.
43664435 let verifications: Record<string, { verified: boolean; reason: string }> = {};
4436 let commitsPageRecentPush: RecentPush | null = null;
43674437 try {
43684438 const [ownerRow] = await db
43694439 .select()
@@ -4381,25 +4451,32 @@ web.get("/:owner/:repo/commits/:ref?", async (c) => {
43814451 )
43824452 )
43834453 .limit(1);
4384 if (repoRow && commits.length > 0) {
4385 const rows = await db
4386 .select()
4387 .from(commitVerifications)
4388 .where(
4389 and(
4390 eq(commitVerifications.repositoryId, repoRow.id),
4391 inArray(
4392 commitVerifications.commitSha,
4393 commits.map((c) => c.sha)
4394 )
4395 )
4396 );
4397 for (const r of rows) {
4454 if (repoRow) {
4455 // Fetch verifications and recent push in parallel.
4456 const [verRows, rp] = await Promise.all([
4457 commits.length > 0
4458 ? db
4459 .select()
4460 .from(commitVerifications)
4461 .where(
4462 and(
4463 eq(commitVerifications.repositoryId, repoRow.id),
4464 inArray(
4465 commitVerifications.commitSha,
4466 commits.map((c) => c.sha)
4467 )
4468 )
4469 )
4470 : Promise.resolve([]),
4471 getRecentPush(repoRow.id),
4472 ]);
4473 for (const r of verRows) {
43984474 verifications[r.commitSha] = {
43994475 verified: r.verified,
44004476 reason: r.reason,
44014477 };
44024478 }
4479 commitsPageRecentPush = rp;
44034480 }
44044481 }
44054482 } catch {
@@ -4409,7 +4486,7 @@ web.get("/:owner/:repo/commits/:ref?", async (c) => {
44094486 return c.html(
44104487 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
44114488 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4412 <RepoHeader owner={owner} repo={repo} />
4489 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
44134490 <RepoNav owner={owner} repo={repo} active="commits" />
44144491 <div class="commits-hero">
44154492 <div class="commits-hero-orb-wrap" aria-hidden="true">
@@ -4568,6 +4645,17 @@ web.get("/:owner/:repo/commits/:ref?", async (c) => {
45684645 >
45694646 {cm.sha.slice(0, 7)}
45704647 </a>
4648 <a
4649 href={`/${owner}/${repo}/push/${cm.sha}`}
4650 class="commits-row-watch"
4651 title="Watch gate + deploy results for this push"
4652 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
4653 >
4654 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
4655 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
4656 <circle cx="12" cy="12" r="3" />
4657 </svg>
4658 </a>
45714659 <button
45724660 type="button"
45734661 class="commits-row-copy"
Modifiedsrc/views/components.tsx+101−56View fileUnifiedSplit
@@ -3,6 +3,19 @@ import { html } from "hono/html";
33import type { GitCommit, GitTreeEntry, GitDiffFile } from "../git/repository";
44import type { Repository } from "../db/schema";
55
6/**
7 * Describes the most recent push to a repo, used by RepoHeader to render
8 * the Push Watch discoverability indicator.
9 *
10 * - ageMs < 5 min \u2192 pulsing red "\u25cf Live" badge
11 * - ageMs < 24 hr \u2192 dimmer "\u25cb Watch" link
12 * - otherwise \u2192 nothing shown
13 */
14export interface RecentPush {
15 sha: string;
16 ageMs: number;
17}
18
619export const RepoHeader: FC<{
720 owner: string;
821 repo: string;
@@ -13,6 +26,8 @@ export const RepoHeader: FC<{
1326 forkedFrom?: string | null;
1427 archived?: boolean;
1528 isTemplate?: boolean;
29 /** Most recent push info for Push Watch discoverability indicator. */
30 recentPush?: RecentPush | null;
1631}> = ({
1732 owner,
1833 repo,
@@ -23,67 +38,97 @@ export const RepoHeader: FC<{
2338 forkedFrom,
2439 archived,
2540 isTemplate,
26}) => (
27 <div class="repo-header">
28 <div>
29 <div class="repo-header-title">
30 <a href={`/${owner}`} class="owner">
31 {owner}
32 </a>
33 <span class="separator">/</span>
34 <a href={`/${owner}/${repo}`} class="name">
35 {repo}
36 </a>
37 {archived && (
38 <span
39 class="repo-header-pill repo-header-pill-archived"
40 title="Read-only: pushes and new issues/PRs disabled"
41 >
42 Archived
43 </span>
44 )}
45 {isTemplate && (
46 <span
47 class="repo-header-pill repo-header-pill-template"
48 title="This repository can be used as a template"
49 >
50 Template
51 </span>
41 recentPush,
42}) => {
43 const FIVE_MIN = 5 * 60 * 1000;
44 const TWENTY_FOUR_HR = 24 * 60 * 60 * 1000;
45 const isLive = recentPush != null && recentPush.ageMs < FIVE_MIN;
46 const isRecent = recentPush != null && recentPush.ageMs < TWENTY_FOUR_HR;
47
48 return (
49 <div class="repo-header">
50 <div>
51 <div class="repo-header-title">
52 <a href={`/${owner}`} class="owner">
53 {owner}
54 </a>
55 <span class="separator">/</span>
56 <a href={`/${owner}/${repo}`} class="name">
57 {repo}
58 </a>
59 {archived && (
60 <span
61 class="repo-header-pill repo-header-pill-archived"
62 title="Read-only: pushes and new issues/PRs disabled"
63 >
64 Archived
65 </span>
66 )}
67 {isTemplate && (
68 <span
69 class="repo-header-pill repo-header-pill-template"
70 title="This repository can be used as a template"
71 >
72 Template
73 </span>
74 )}
75 {isLive && recentPush && (
76 <a
77 href={`/${owner}/${repo}/push/${recentPush.sha}`}
78 class="repo-header-live-badge repo-header-live-badge--live"
79 title="Push in progress \u2014 watch live gate + deploy status"
80 aria-label="Live push \u2014 click to watch status"
81 >
82 <span class="repo-header-live-dot" aria-hidden="true">{"\u25cf"}</span>
83 Live
84 </a>
85 )}
86 {!isLive && isRecent && recentPush && (
87 <a
88 href={`/${owner}/${repo}/push/${recentPush.sha}`}
89 class="repo-header-live-badge repo-header-live-badge--recent"
90 title="Watch the most recent push's gate + deploy results"
91 aria-label="Watch most recent push"
92 >
93 <span aria-hidden="true">{"\u25cb"}</span>
94 Watch
95 </a>
96 )}
97 </div>
98 {forkedFrom && (
99 <div class="repo-header-fork">
100 forked from <a href={`/${forkedFrom}`}>{forkedFrom}</a>
101 </div>
52102 )}
53103 </div>
54 {forkedFrom && (
55 <div class="repo-header-fork">
56 forked from <a href={`/${forkedFrom}`}>{forkedFrom}</a>
57 </div>
58 )}
59 </div>
60 <div class="repo-header-actions">
61 {currentUser && currentUser !== owner && (
62 <form method="post" action={`/${owner}/${repo}/fork`} style="display:inline">
63 <button type="submit" class="star-btn">
64 {"\u2442"} Fork {forkCount !== undefined && forkCount > 0 ? forkCount : ""}
65 </button>
66 </form>
67 )}
68 {starCount !== undefined && (
69 currentUser ? (
70 <form method="post" action={`/${owner}/${repo}/star`} style="display:inline">
71 <button
72 type="submit"
73 class={`star-btn${starred ? " starred" : ""}`}
74 >
75 {starred ? "\u2605" : "\u2606"} {starCount}
104 <div class="repo-header-actions">
105 {currentUser && currentUser !== owner && (
106 <form method="post" action={`/${owner}/${repo}/fork`} style="display:inline">
107 <button type="submit" class="star-btn">
108 {"\u2442"} Fork {forkCount !== undefined && forkCount > 0 ? forkCount : ""}
76109 </button>
77110 </form>
78 ) : (
79 <span class="star-btn">
80 {"\u2606"} {starCount}
81 </span>
82 )
83 )}
111 )}
112 {starCount !== undefined && (
113 currentUser ? (
114 <form method="post" action={`/${owner}/${repo}/star`} style="display:inline">
115 <button
116 type="submit"
117 class={`star-btn${starred ? " starred" : ""}`}
118 >
119 {starred ? "\u2605" : "\u2606"} {starCount}
120 </button>
121 </form>
122 ) : (
123 <span class="star-btn">
124 {"\u2606"} {starCount}
125 </span>
126 )
127 )}
128 </div>
84129 </div>
85 </div>
86);
130 );
131};
87132
88133export const RepoNav: FC<{
89134 owner: string;
Modifiedsrc/views/layout.tsx+38−0View fileUnifiedSplit
@@ -2347,6 +2347,44 @@ const css = `
23472347 }
23482348 .repo-header-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
23492349
2350 /* Push Watch discoverability — live/recent indicator in the repo header */
2351 pushWatchPulse {
2352 0%, 100% { opacity: 1; }
2353 50% { opacity: 0.3; }
2354 }
2355 .repo-header-live-badge {
2356 display: inline-flex;
2357 align-items: center;
2358 gap: 5px;
2359 padding: 2px 9px;
2360 border-radius: 999px;
2361 font-size: 12px;
2362 font-weight: 600;
2363 letter-spacing: 0.02em;
2364 text-decoration: none !important;
2365 vertical-align: 3px;
2366 transition: filter 140ms ease, opacity 140ms ease;
2367 }
2368 .repo-header-live-badge:hover { filter: brightness(1.15); text-decoration: none !important; }
2369 .repo-header-live-badge--live {
2370 background: rgba(218, 54, 51, 0.12);
2371 color: #f97171;
2372 border: 1px solid rgba(218, 54, 51, 0.35);
2373 }
2374 .repo-header-live-badge--live .repo-header-live-dot {
2375 animation: pushWatchPulse 1.2s ease-in-out infinite;
2376 display: inline-block;
2377 }
2378 .repo-header-live-badge--recent {
2379 background: rgba(140, 109, 255, 0.08);
2380 color: var(--text-muted);
2381 border: 1px solid rgba(140, 109, 255, 0.22);
2382 }
2383 .repo-header-live-badge--recent:hover { color: var(--accent); }
2384 (prefers-reduced-motion: reduce) {
2385 .repo-header-live-badge--live .repo-header-live-dot { animation: none; }
2386 }
2387
23502388 .repo-nav {
23512389 display: flex;
23522390 gap: 1px;
23532391