Commitb93fc3eunknown_key
feat: smart morning digest + review context restore — AI-curated daily queue and stale-review recovery
feat: smart morning digest + review context restore — AI-curated daily queue and stale-review recovery https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
10 files changed+1519−0b93fc3e66f043c58741ee5bdfd59b0d7ed2a2bd9
10 changed files+1519−0
Addeddrizzle/0088_pr_visits.sql+6−0View fileUnifiedSplit
@@ -0,0 +1,6 @@
1CREATE TABLE IF NOT EXISTS pr_visits (
2 pr_id UUID NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
3 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4 visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
5 PRIMARY KEY (pr_id, user_id)
6);
Addeddrizzle/0089_smart_digest_pref.sql+2−0View fileUnifiedSplit
@@ -0,0 +1,2 @@
1ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_smart_digest BOOLEAN NOT NULL DEFAULT true;
2ALTER TABLE users ADD COLUMN IF NOT EXISTS last_smart_digest_sent_at TIMESTAMPTZ;
Modifiedsrc/app.tsx+3−0View fileUnifiedSplit
@@ -16,6 +16,7 @@ import buildAgentSpecRoutes from "./routes/build-agent-spec";
1616import pullsDashboardRoutes from "./routes/pulls-dashboard";
1717import issuesDashboardRoutes from "./routes/issues-dashboard";
1818import inboxRoutes from "./routes/inbox";
19import digestRoutes from "./routes/digest";
1920import activityRoutes from "./routes/activity";
2021import authRoutes from "./routes/auth";
2122import passwordResetRoutes from "./routes/password-reset";
@@ -411,6 +412,8 @@ app.route("/", activityRoutes);
411412app.route("/", inboxRoutes);
412413// AI standup feed — daily / weekly Claude-generated team brief
413414app.route("/", standupRoutes);
415// Smart morning digest — AI-curated daily developer queue (/digest)
416app.route("/", digestRoutes);
414417
415418// Auth routes (register, login, logout)
416419app.route("/", authRoutes);
Modifiedsrc/db/schema.ts+32−0View fileUnifiedSplit
@@ -12,6 +12,7 @@ import {
1212 jsonb,
1313 numeric,
1414 customType,
15 primaryKey,
1516} from "drizzle-orm/pg-core";
1617
1718// Postgres `bytea` — drizzle-orm doesn't ship a first-class bytea column, so
@@ -131,6 +132,12 @@ export const users = pgTable("users", {
131132 // drip schedule and sends any outstanding emails. Never null — defaults to
132133 // an empty array at insert time.
133134 onboardingEmailsSent: jsonb("onboarding_emails_sent").$type<string[]>().default([]).notNull(),
135 // Migration 0089 — Smart morning digest (AI-curated daily notification queue).
136 // When true, the autopilot `smart-digest` task delivers one AI-curated
137 // digest notification per day at 07:00 UTC. Independent cooldown via
138 // `lastSmartDigestSentAt` so it doesn't interact with weekly email digest.
139 notifySmartDigest: boolean("notify_smart_digest").default(true).notNull(),
140 lastSmartDigestSentAt: timestamp("last_smart_digest_sent_at", { withTimezone: true }),
134141 createdAt: timestamp("created_at").defaultNow().notNull(),
135142 updatedAt: timestamp("updated_at").defaultNow().notNull(),
136143});
@@ -4254,3 +4261,28 @@ export const prPreviews = pgTable(
42544261
42554262export type PrPreview = typeof prPreviews.$inferSelect;
42564263export type NewPrPreview = typeof prPreviews.$inferInsert;
4264
4265// ---------------------------------------------------------------------------
4266// Migration 0088 — PR visit tracking for context-restore feature
4267// ---------------------------------------------------------------------------
4268
4269/**
4270 * pr_visits — lightweight upsert table tracking each user's last visit to a
4271 * PR. Used by `src/lib/review-context.ts` to compute what changed since the
4272 * reviewer was last here and generate a "Welcome back" context banner.
4273 */
4274export const prVisits = pgTable(
4275 "pr_visits",
4276 {
4277 prId: uuid("pr_id")
4278 .notNull()
4279 .references(() => pullRequests.id, { onDelete: "cascade" }),
4280 userId: uuid("user_id")
4281 .notNull()
4282 .references(() => users.id, { onDelete: "cascade" }),
4283 visitedAt: timestamp("visited_at", { withTimezone: true }).notNull().defaultNow(),
4284 },
4285 (t) => [primaryKey({ columns: [t.prId, t.userId] })]
4286);
4287
4288export type PrVisit = typeof prVisits.$inferSelect;
Modifiedsrc/lib/autopilot.ts+34−0View fileUnifiedSplit
@@ -71,6 +71,7 @@ import { expireIdleEnvs } from "./dev-env";
7171import { expireOldPreviews } from "./branch-previews";
7272import { getBotUserIdOrFallback } from "./bot-user";
7373import { runOnboardingDripTaskOnce } from "./onboarding-drip";
74import { sendSmartDigestsToAll } from "./smart-digest";
7475
7576export interface AutopilotTaskResult {
7677 name: string;
@@ -185,6 +186,15 @@ function advancementScanDayOfWeek(): number {
185186 return Math.floor(n);
186187}
187188
189/**
190 * Smart-digest cadence. Designed to run once per day at 07:00 UTC.
191 * The task itself checks `lastSmartDigestSentAt` per user (20h cooldown),
192 * so even if the outer loop fires multiple times near 07:00, only one
193 * digest is ever sent per day per user.
194 */
195const SMART_DIGEST_INTERVAL_MS = 22 * 60 * 60 * 1000; // 22h between outer checks
196let _lastSmartDigestAt = 0;
197
188198/**
189199 * Default task set. Each task is a thin wrapper around an existing locked
190200 * helper — no gate/merge logic is duplicated here.
@@ -662,6 +672,30 @@ export function defaultTasks(): AutopilotTask[] {
662672 }
663673 },
664674 },
675 {
676 // Smart morning digest — AI-curated daily developer queue.
677 // Fires once per day at 07:00 UTC (or whenever the 22h outer gate
678 // next opens after 07:00). Per-user 20h cooldown in sendSmartDigest
679 // ensures no user receives more than one digest per day even if the
680 // outer gate fires multiple times. Requires ANTHROPIC_API_KEY but
681 // degrades gracefully to a rule-based prioritisation when unset.
682 name: "smart-digest",
683 run: async () => {
684 const now = Date.now();
685 const nowDate = new Date(now);
686 // Only fire at hour=7 UTC or if last run was >22h ago (catch-up)
687 const isDigestHour = nowDate.getUTCHours() === 7;
688 const pastCooldown = now - _lastSmartDigestAt >= SMART_DIGEST_INTERVAL_MS;
689 if (!isDigestHour && !pastCooldown) return;
690 _lastSmartDigestAt = now;
691 try {
692 await sendSmartDigestsToAll();
693 console.log("[autopilot] smart-digest: completed");
694 } catch (err) {
695 console.error("[autopilot] smart-digest: threw:", err);
696 }
697 },
698 },
665699 ];
666700}
667701
Addedsrc/lib/review-context.ts+293−0View fileUnifiedSplit
@@ -0,0 +1,293 @@
1/**
2 * Context Restore for Stale Reviews.
3 *
4 * When a user opens a PR they previously reviewed (or started reviewing),
5 * this module provides an AI-generated summary of what changed since their
6 * last visit and where they left off. Shown as a dismissible banner on the
7 * PR detail page.
8 *
9 * Tracks visits via the `pr_visits` table (migration 0088). On each PR page
10 * load we upsert the visit timestamp so the next visit can compute the delta.
11 *
12 * The AI summary is only generated when:
13 * - The user has visited this PR before (non-first-visit)
14 * - The last visit was >4 hours ago
15 * - There are commits or new comments since the last visit
16 *
17 * Never throws — all paths are wrapped in try/catch.
18 */
19
20import { and, eq, gt, sql } from "drizzle-orm";
21import { db } from "../db";
22import { prComments, prVisits } from "../db/schema";
23import {
24 getAnthropic,
25 isAiAvailable,
26 MODEL_SONNET,
27 extractText,
28} from "./ai-client";
29import { commitsBetween } from "../git/repository";
30
31// ---------------------------------------------------------------------------
32// Public interface
33// ---------------------------------------------------------------------------
34
35export interface ReviewContext {
36 /** ISO string of the user's last visit */
37 lastVisitedAt: string;
38 /** Number of commits pushed since last visit */
39 commitsSince: number;
40 /** Number of new comments since last visit */
41 newComments: number;
42 /** Number of comments with unresolved threads */
43 unresolvedThreads: number;
44 /** AI-written 2-3 sentence summary of what changed */
45 summary: string;
46 /** Optional file:line hint for where to start reviewing */
47 suggestedStartLine?: string;
48}
49
50/** Minimum hours since last visit before we show the context banner. */
51const MIN_STALENESS_HOURS = 4;
52
53// ---------------------------------------------------------------------------
54// Visit tracking
55// ---------------------------------------------------------------------------
56
57/**
58 * Record (or refresh) the user's visit to this PR. Call on every PR page load
59 * for authenticated users. Uses an upsert so the row always reflects the
60 * most recent visit.
61 *
62 * Never throws.
63 */
64export async function recordPrVisit(
65 prId: string,
66 userId: string
67): Promise<void> {
68 try {
69 await db
70 .insert(prVisits)
71 .values({ prId, userId, visitedAt: new Date() })
72 .onConflictDoUpdate({
73 target: [prVisits.prId, prVisits.userId],
74 set: { visitedAt: new Date() },
75 });
76 } catch (err) {
77 console.error("[review-context] recordPrVisit error:", err);
78 }
79}
80
81// ---------------------------------------------------------------------------
82// Context computation
83// ---------------------------------------------------------------------------
84
85/**
86 * Compute context for a returning visitor. Returns null when:
87 * - No previous visit exists (first-time visit)
88 * - Visit was too recent (<4h ago)
89 * - No changes since last visit
90 * - AI unavailable AND no new comments (nothing to say)
91 *
92 * NOTE: Call `recordPrVisit` AFTER this function so the previous timestamp
93 * is still available when computing the delta.
94 */
95export async function getReviewContext(
96 prId: string,
97 userId: string,
98 opts?: {
99 ownerName?: string;
100 repoName?: string;
101 baseBranch?: string;
102 headBranch?: string;
103 }
104): Promise<ReviewContext | null> {
105 try {
106 // --- Look up previous visit ---
107 const [visit] = await db
108 .select()
109 .from(prVisits)
110 .where(and(eq(prVisits.prId, prId), eq(prVisits.userId, userId)))
111 .limit(1);
112
113 if (!visit) return null; // First visit
114
115 const lastVisitedAt = new Date(visit.visitedAt);
116 const hoursSince =
117 (Date.now() - lastVisitedAt.getTime()) / 3_600_000;
118
119 if (hoursSince < MIN_STALENESS_HOURS) return null; // Too recent
120
121 // --- Count new comments since last visit ---
122 let newComments = 0;
123 try {
124 const [countRow] = await db
125 .select({ count: sql<number>`count(*)::int` })
126 .from(prComments)
127 .where(
128 and(
129 eq(prComments.pullRequestId, prId),
130 gt(prComments.createdAt, lastVisitedAt)
131 )
132 );
133 newComments = countRow?.count || 0;
134 } catch {
135 /* non-fatal */
136 }
137
138 // --- Count commits since last visit (via git log) ---
139 let commitsSince = 0;
140 let changedFiles: string[] = [];
141 if (opts?.ownerName && opts?.repoName && opts?.baseBranch && opts?.headBranch) {
142 try {
143 const allCommits = await commitsBetween(
144 opts.ownerName,
145 opts.repoName,
146 opts.baseBranch,
147 opts.headBranch
148 );
149 const newCommits = allCommits.filter(
150 (c) => new Date(c.date) > lastVisitedAt
151 );
152 commitsSince = newCommits.length;
153 // Rough file list from commit messages (best-effort)
154 changedFiles = newCommits
155 .flatMap((c) => (c.message || "").split("\n"))
156 .filter((l) => l.startsWith("M\t") || l.startsWith("A\t") || l.startsWith("D\t"))
157 .map((l) => l.slice(2))
158 .filter(Boolean)
159 .slice(0, 5);
160 } catch {
161 /* swallow — git ops can fail */
162 }
163 }
164
165 // Nothing changed — skip the banner
166 if (commitsSince === 0 && newComments === 0) return null;
167
168 // --- AI summary (optional) ---
169 let summary = buildFallbackSummary(commitsSince, newComments, hoursSince);
170 let suggestedStartLine: string | undefined;
171
172 if (isAiAvailable() && (commitsSince > 0 || newComments > 0)) {
173 try {
174 const aiResult = await callClaudeForContext({
175 commitsSince,
176 newComments,
177 changedFiles,
178 hoursSince,
179 lastVisitedAt: lastVisitedAt.toISOString(),
180 });
181 if (aiResult) {
182 summary = aiResult.summary;
183 suggestedStartLine = aiResult.suggestedStartLine;
184 }
185 } catch (err) {
186 console.error("[review-context] Claude call failed:", err);
187 // Keep fallback summary
188 }
189 }
190
191 // Unresolved threads — comments without a resolution marker (heuristic)
192 const unresolvedThreads = newComments; // simple proxy for now
193
194 return {
195 lastVisitedAt: lastVisitedAt.toISOString(),
196 commitsSince,
197 newComments,
198 unresolvedThreads,
199 summary,
200 suggestedStartLine,
201 };
202 } catch (err) {
203 console.error("[review-context] getReviewContext error:", err);
204 return null;
205 }
206}
207
208// ---------------------------------------------------------------------------
209// Helpers
210// ---------------------------------------------------------------------------
211
212function buildFallbackSummary(
213 commitsSince: number,
214 newComments: number,
215 hoursSince: number
216): string {
217 const parts: string[] = [];
218 const hoursLabel =
219 hoursSince >= 24
220 ? `${Math.floor(hoursSince / 24)} day${Math.floor(hoursSince / 24) === 1 ? "" : "s"}`
221 : `${Math.round(hoursSince)} hour${Math.round(hoursSince) === 1 ? "" : "s"}`;
222
223 if (commitsSince > 0) {
224 parts.push(
225 `${commitsSince} new commit${commitsSince === 1 ? " was" : "s were"} pushed since your last visit ${hoursLabel} ago`
226 );
227 }
228 if (newComments > 0) {
229 parts.push(
230 `${newComments} new comment${newComments === 1 ? " was" : "s were"} added`
231 );
232 }
233 return parts.join(". ") + ".";
234}
235
236interface ClaudeContextResponse {
237 summary: string;
238 suggestedStartLine?: string;
239}
240
241async function callClaudeForContext(input: {
242 commitsSince: number;
243 newComments: number;
244 changedFiles: string[];
245 hoursSince: number;
246 lastVisitedAt: string;
247}): Promise<ClaudeContextResponse | null> {
248 const client = getAnthropic();
249 const hoursLabel =
250 input.hoursSince >= 24
251 ? `${Math.floor(input.hoursSince / 24)} day${Math.floor(input.hoursSince / 24) === 1 ? "" : "s"}`
252 : `${Math.round(input.hoursSince)} hours`;
253
254 const prompt = `A developer is returning to a pull request they last reviewed ${hoursLabel} ago.
255
256Changes since their last visit:
257- New commits pushed: ${input.commitsSince}
258- New comments added: ${input.newComments}
259${input.changedFiles.length > 0 ? `- Files changed: ${input.changedFiles.join(", ")}` : ""}
260
261Write a brief 2-3 sentence "welcome back" summary telling the reviewer what happened.
262If there are changed files, suggest one as the best starting point.
263
264Respond with JSON:
265{"summary": "...", "suggestedStartLine": "src/foo.ts:42 — reason (optional, omit if no files)"}
266
267Rules:
268- summary must be specific and human, not generic
269- No more than 3 sentences
270- If no changed files, omit suggestedStartLine
271- Return only valid JSON, no prose`;
272
273 const msg = await client.messages.create({
274 model: MODEL_SONNET,
275 max_tokens: 500,
276 messages: [{ role: "user", content: prompt }],
277 });
278
279 const text = extractText(msg);
280
281 // Extract JSON from response
282 try {
283 const jsonMatch = text.match(/\{[\s\S]*\}/);
284 if (jsonMatch) {
285 const parsed = JSON.parse(jsonMatch[0]) as ClaudeContextResponse;
286 if (typeof parsed.summary === "string") return parsed;
287 }
288 } catch {
289 /* fall through */
290 }
291
292 return null;
293}
Addedsrc/lib/smart-digest.ts+589−0View fileUnifiedSplit
@@ -0,0 +1,589 @@
1/**
2 * Smart Morning Digest — AI-curated daily developer notification queue.
3 *
4 * Replaces notification spam with a single, prioritised digest delivered
5 * once per day via the in-app notification system AND surfaced on /digest.
6 *
7 * Data sources (last 48h / 24h where noted):
8 * 1. Unread notifications (48h)
9 * 2. Open PRs where the user is a requested reviewer
10 * 3. Open PRs authored by the user with unread comments
11 * 4. Failed gate runs for repos the user owns (24h)
12 * 5. Dependency-update PRs on user's repos
13 *
14 * Claude Sonnet 4.6 prioritises the items and writes the headline + insight.
15 * Never throws — every path is wrapped in try/catch.
16 */
17
18import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm";
19import { db } from "../db";
20import {
21 gateRuns,
22 issues,
23 notifications,
24 prComments,
25 prReviews,
26 pullRequests,
27 repositories,
28 users,
29} from "../db/schema";
30import {
31 getAnthropic,
32 isAiAvailable,
33 MODEL_SONNET,
34 extractText,
35 parseJsonResponse,
36} from "./ai-client";
37import { config } from "./config";
38
39// ---------------------------------------------------------------------------
40// Public interfaces
41// ---------------------------------------------------------------------------
42
43export interface DigestItem {
44 priority: "blocking" | "important" | "fyi";
45 type:
46 | "pr_review"
47 | "pr_comment"
48 | "ci_failure"
49 | "mention"
50 | "dep_update"
51 | "new_issue";
52 title: string;
53 /** e.g. "waiting 2 days · 3 files changed" */
54 subtitle: string;
55 url: string;
56 repoName: string;
57}
58
59export interface SmartDigest {
60 userId: string;
61 generatedAt: string;
62 headline: string;
63 queue: DigestItem[];
64 stats: {
65 prsReviewed: number;
66 issuesClosed: number;
67 commitsThisWeek: number;
68 };
69 insight?: string;
70}
71
72// ---------------------------------------------------------------------------
73// Internal data loader
74// ---------------------------------------------------------------------------
75
76interface RawItem {
77 type: DigestItem["type"];
78 title: string;
79 subtitle: string;
80 url: string;
81 repoName: string;
82 createdAt: Date;
83}
84
85async function loadRawItems(
86 userId: string,
87 now: Date
88): Promise<RawItem[]> {
89 const base = config.appBaseUrl || "https://gluecron.com";
90 const items: RawItem[] = [];
91
92 // --- 1. Unread notifications (last 48h) ---
93 const since48h = new Date(now.getTime() - 48 * 60 * 60 * 1000);
94 try {
95 const notifs = await db
96 .select()
97 .from(notifications)
98 .where(
99 and(
100 eq(notifications.userId, userId),
101 gte(notifications.createdAt, since48h),
102 sql`${notifications.readAt} IS NULL`
103 )
104 )
105 .orderBy(desc(notifications.createdAt))
106 .limit(20);
107
108 for (const n of notifs) {
109 const ageH = Math.round(
110 (now.getTime() - new Date(n.createdAt).getTime()) / 3_600_000
111 );
112 items.push({
113 type: n.kind === "mention" ? "mention" : n.kind === "gate_failed" ? "ci_failure" : "pr_review",
114 title: n.title || "(untitled)",
115 subtitle: `${n.kind} · ${ageH}h ago`,
116 url: n.url ? (n.url.startsWith("http") ? n.url : `${base}${n.url}`) : `${base}/inbox`,
117 repoName: "—",
118 createdAt: new Date(n.createdAt),
119 });
120 }
121 } catch (err) {
122 console.error("[smart-digest] notifications query failed:", err);
123 }
124
125 // --- 2. PRs where user is requested reviewer (open) ---
126 try {
127 // We use pr_reviews to detect review requests: look for PRs where user
128 // has a review_requested state and the PR is still open.
129 const reviewRows = await db
130 .select({
131 pr: pullRequests,
132 repoName: repositories.name,
133 ownerUsername: users.username,
134 })
135 .from(prReviews)
136 .innerJoin(pullRequests, eq(pullRequests.id, prReviews.pullRequestId))
137 .innerJoin(
138 repositories,
139 eq(repositories.id, pullRequests.repositoryId)
140 )
141 .innerJoin(users, eq(users.id, repositories.ownerId))
142 .where(
143 and(
144 eq(prReviews.reviewerId, userId),
145 eq(pullRequests.state, "open"),
146 // Only include PRs they haven't reviewed yet (no approved/changes_requested)
147 sql`${prReviews.state} NOT IN ('approved','changes_requested')`
148 )
149 )
150 .orderBy(desc(pullRequests.createdAt))
151 .limit(10);
152
153 for (const row of reviewRows) {
154 const ageH = Math.round(
155 (now.getTime() - new Date(row.pr.createdAt).getTime()) / 3_600_000
156 );
157 const ageDays = Math.floor(ageH / 24);
158 const ageLabel = ageDays >= 1 ? `waiting ${ageDays}d` : `waiting ${ageH}h`;
159 items.push({
160 type: "pr_review",
161 title: `Review requested: ${row.pr.title}`,
162 subtitle: `${ageLabel} · ${row.repoName}`,
163 url: `${base}/${row.ownerUsername}/${row.repoName}/pulls/${row.pr.number}`,
164 repoName: row.repoName,
165 createdAt: new Date(row.pr.createdAt),
166 });
167 }
168 } catch (err) {
169 console.error("[smart-digest] review-requested query failed:", err);
170 }
171
172 // --- 3. Open PRs authored by user with unread comments ---
173 try {
174 const authoredPrs = await db
175 .select({
176 pr: pullRequests,
177 repoName: repositories.name,
178 ownerUsername: users.username,
179 commentCount: sql<number>`count(${prComments.id})::int`,
180 })
181 .from(pullRequests)
182 .innerJoin(
183 repositories,
184 eq(repositories.id, pullRequests.repositoryId)
185 )
186 .innerJoin(users, eq(users.id, repositories.ownerId))
187 .innerJoin(prComments, eq(prComments.pullRequestId, pullRequests.id))
188 .where(
189 and(
190 eq(pullRequests.authorId, userId),
191 eq(pullRequests.state, "open"),
192 ne(prComments.authorId, userId),
193 gte(prComments.createdAt, since48h)
194 )
195 )
196 .groupBy(pullRequests.id, repositories.name, users.username)
197 .orderBy(desc(pullRequests.updatedAt))
198 .limit(10);
199
200 for (const row of authoredPrs) {
201 const count = row.commentCount || 0;
202 if (count === 0) continue;
203 items.push({
204 type: "pr_comment",
205 title: `New comments on your PR: ${row.pr.title}`,
206 subtitle: `${count} new comment${count === 1 ? "" : "s"} · ${row.repoName}`,
207 url: `${base}/${row.ownerUsername}/${row.repoName}/pulls/${row.pr.number}`,
208 repoName: row.repoName,
209 createdAt: new Date(row.pr.updatedAt),
210 });
211 }
212 } catch (err) {
213 console.error("[smart-digest] pr-comments query failed:", err);
214 }
215
216 // --- 4. Failed gate runs for repos the user owns (last 24h) ---
217 const since24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
218 try {
219 const ownedRepos = await db
220 .select({ id: repositories.id, name: repositories.name })
221 .from(repositories)
222 .where(eq(repositories.ownerId, userId));
223
224 if (ownedRepos.length > 0) {
225 const repoIds = ownedRepos.map((r) => r.id);
226 const repoById = new Map(ownedRepos.map((r) => [r.id, r.name]));
227
228 const failedGates = await db
229 .select()
230 .from(gateRuns)
231 .where(
232 and(
233 inArray(gateRuns.repositoryId, repoIds),
234 eq(gateRuns.status, "failed"),
235 gte(gateRuns.createdAt, since24h)
236 )
237 )
238 .orderBy(desc(gateRuns.createdAt))
239 .limit(10);
240
241 for (const gate of failedGates) {
242 const repoName = repoById.get(gate.repositoryId) || "?";
243 items.push({
244 type: "ci_failure",
245 title: `Gate failed: ${gate.gateName} in ${repoName}`,
246 subtitle: `commit ${gate.commitSha.slice(0, 7)} · ${repoName}`,
247 url: `${base}/${userId}/${repoName}/pulls`,
248 repoName,
249 createdAt: new Date(gate.createdAt),
250 });
251 }
252
253 // --- 5. Dependency update PRs ---
254 if (repoIds.length > 0) {
255 const depPrs = await db
256 .select({
257 pr: pullRequests,
258 repoName: repositories.name,
259 })
260 .from(pullRequests)
261 .innerJoin(
262 repositories,
263 eq(repositories.id, pullRequests.repositoryId)
264 )
265 .where(
266 and(
267 inArray(pullRequests.repositoryId, repoIds),
268 eq(pullRequests.state, "open"),
269 sql`${pullRequests.headBranch} LIKE 'gluecron/dep-update%'`
270 )
271 )
272 .orderBy(desc(pullRequests.createdAt))
273 .limit(5);
274
275 for (const row of depPrs) {
276 items.push({
277 type: "dep_update",
278 title: `Dependency update ready: ${row.pr.title}`,
279 subtitle: `auto-PR · ${row.repoName}`,
280 url: `${base}/${userId}/${row.repoName}/pulls/${row.pr.number}`,
281 repoName: row.repoName,
282 createdAt: new Date(row.pr.createdAt),
283 });
284 }
285 }
286 }
287 } catch (err) {
288 console.error("[smart-digest] gate/dep-update query failed:", err);
289 }
290
291 return items;
292}
293
294// ---------------------------------------------------------------------------
295// Stats loader
296// ---------------------------------------------------------------------------
297
298async function loadStats(
299 userId: string,
300 now: Date
301): Promise<SmartDigest["stats"]> {
302 const since7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
303
304 let prsReviewed = 0;
305 let issuesClosed = 0;
306 const commitsThisWeek = 0; // Git log not easily queryable via SQL; default 0
307
308 try {
309 const [reviewCountRow] = await db
310 .select({ count: sql<number>`count(*)::int` })
311 .from(prReviews)
312 .where(
313 and(
314 eq(prReviews.reviewerId, userId),
315 gte(prReviews.createdAt, since7d),
316 sql`${prReviews.state} IN ('approved','changes_requested')`
317 )
318 );
319 prsReviewed = reviewCountRow?.count || 0;
320 } catch {
321 /* swallow */
322 }
323
324 try {
325 const [issueCountRow] = await db
326 .select({ count: sql<number>`count(*)::int` })
327 .from(issues)
328 .where(
329 and(
330 gte(issues.updatedAt, since7d),
331 eq(issues.state, "closed")
332 )
333 );
334 issuesClosed = issueCountRow?.count || 0;
335 } catch {
336 /* swallow */
337 }
338
339 return { prsReviewed, issuesClosed, commitsThisWeek };
340}
341
342// ---------------------------------------------------------------------------
343// Claude call
344// ---------------------------------------------------------------------------
345
346interface ClaudeDigestResponse {
347 headline: string;
348 queue: Array<{
349 priority: "blocking" | "important" | "fyi";
350 type: DigestItem["type"];
351 title: string;
352 subtitle: string;
353 url: string;
354 repoName: string;
355 }>;
356 insight?: string;
357}
358
359async function callClaude(
360 rawItems: RawItem[],
361 stats: SmartDigest["stats"],
362 username: string
363): Promise<ClaudeDigestResponse | null> {
364 if (!isAiAvailable()) return null;
365 try {
366 const client = getAnthropic();
367 const itemsJson = JSON.stringify(
368 rawItems.map((i) => ({
369 type: i.type,
370 title: i.title,
371 subtitle: i.subtitle,
372 url: i.url,
373 repoName: i.repoName,
374 })),
375 null,
376 2
377 );
378 const statsJson = JSON.stringify(stats, null, 2);
379
380 const prompt = `You are a developer productivity assistant for ${username}. Create a morning digest.
381
382Their pending items:
383${itemsJson}
384
385Their recent stats (last 7 days):
386${statsJson}
387
388Return JSON only (no markdown wrapper):
389{
390 "headline": "...",
391 "queue": [{"priority": "blocking"|"important"|"fyi", "type": "pr_review"|"pr_comment"|"ci_failure"|"mention"|"dep_update"|"new_issue", "title": "...", "subtitle": "...", "url": "...", "repoName": "..."}, ...],
392 "insight": "..."
393}
394
395Rules:
396- Max 8 items in queue
397- blocking = someone explicitly waiting on this person, or a CI failure blocking a deploy
398- important = needs action today
399- fyi = nice to know
400- headline should be specific and human ("PR #45 is blocking a deploy" not "You have notifications")
401- insight should be personal and brief (skip if nothing interesting)
402- If no items, headline = "All caught up — nothing needs your attention right now"
403- Return valid JSON only, no commentary`;
404
405 const msg = await client.messages.create({
406 model: MODEL_SONNET,
407 max_tokens: 1024,
408 messages: [{ role: "user", content: prompt }],
409 });
410
411 const text = extractText(msg);
412 const parsed = parseJsonResponse<ClaudeDigestResponse>(text);
413 return parsed;
414 } catch (err) {
415 console.error("[smart-digest] Claude call failed:", err);
416 return null;
417 }
418}
419
420// ---------------------------------------------------------------------------
421// Fallback (no AI)
422// ---------------------------------------------------------------------------
423
424function buildFallbackDigest(
425 rawItems: RawItem[],
426 stats: SmartDigest["stats"],
427 userId: string
428): Omit<SmartDigest, "userId" | "generatedAt" | "stats"> {
429 const queue: DigestItem[] = rawItems.slice(0, 8).map((item) => ({
430 priority:
431 item.type === "ci_failure"
432 ? "blocking"
433 : item.type === "pr_review"
434 ? "important"
435 : "fyi",
436 type: item.type,
437 title: item.title,
438 subtitle: item.subtitle,
439 url: item.url,
440 repoName: item.repoName,
441 }));
442
443 const blockingCount = queue.filter((i) => i.priority === "blocking").length;
444 const importantCount = queue.filter((i) => i.priority === "important").length;
445 let headline = "All caught up — nothing needs your attention right now";
446 if (queue.length > 0) {
447 if (blockingCount > 0) {
448 headline = `${blockingCount} blocking item${blockingCount === 1 ? "" : "s"} need${blockingCount === 1 ? "s" : ""} your attention`;
449 } else if (importantCount > 0) {
450 headline = `${importantCount} item${importantCount === 1 ? "" : "s"} to action today`;
451 } else {
452 headline = `${queue.length} item${queue.length === 1 ? "" : "s"} in your queue`;
453 }
454 }
455
456 return { headline, queue };
457}
458
459// ---------------------------------------------------------------------------
460// Public API
461// ---------------------------------------------------------------------------
462
463/**
464 * Compose a smart digest for the given user. Never throws.
465 * Returns null if the user is not found or any fatal error occurs.
466 */
467export async function composeSmartDigest(
468 userId: string
469): Promise<SmartDigest | null> {
470 try {
471 const [user] = await db
472 .select()
473 .from(users)
474 .where(eq(users.id, userId))
475 .limit(1);
476 if (!user) return null;
477
478 const now = new Date();
479 const [rawItems, stats] = await Promise.all([
480 loadRawItems(userId, now),
481 loadStats(userId, now),
482 ]);
483
484 let headline = "All caught up — nothing needs your attention right now";
485 let queue: DigestItem[] = [];
486 let insight: string | undefined;
487
488 const claudeResult = await callClaude(rawItems, stats, user.username);
489 if (claudeResult) {
490 headline = claudeResult.headline || headline;
491 queue = (claudeResult.queue || []).slice(0, 8) as DigestItem[];
492 insight = claudeResult.insight || undefined;
493 } else {
494 const fallback = buildFallbackDigest(rawItems, stats, userId);
495 headline = fallback.headline;
496 queue = fallback.queue;
497 }
498
499 return {
500 userId,
501 generatedAt: now.toISOString(),
502 headline,
503 queue,
504 stats,
505 insight,
506 };
507 } catch (err) {
508 console.error("[smart-digest] composeSmartDigest error:", err);
509 return null;
510 }
511}
512
513/** Minimum hours between digests. */
514const SMART_DIGEST_COOLDOWN_HOURS = 20;
515
516/**
517 * Compose + store a single notification for the user.
518 * Updates `last_smart_digest_sent_at`. Respects the 20h cooldown.
519 * Never throws.
520 */
521export async function sendSmartDigest(userId: string): Promise<void> {
522 try {
523 const [user] = await db
524 .select()
525 .from(users)
526 .where(eq(users.id, userId))
527 .limit(1);
528 if (!user) return;
529
530 // Cooldown check — skip if sent recently
531 const lastSent = user.lastSmartDigestSentAt as Date | null;
532 if (lastSent) {
533 const hoursSince =
534 (Date.now() - new Date(lastSent).getTime()) / 3_600_000;
535 if (hoursSince < SMART_DIGEST_COOLDOWN_HOURS) {
536 return;
537 }
538 }
539
540 const digest = await composeSmartDigest(userId);
541 if (!digest) return;
542
543 // Insert a single 'digest' notification with full JSON in body
544 await db.insert(notifications).values({
545 userId,
546 kind: "digest",
547 title: digest.headline,
548 body: JSON.stringify(digest),
549 url: "/digest",
550 });
551
552 // Update timestamp
553 await db
554 .update(users)
555 .set({ lastSmartDigestSentAt: new Date() })
556 .where(eq(users.id, userId));
557 } catch (err) {
558 console.error("[smart-digest] sendSmartDigest error:", err);
559 }
560}
561
562/**
563 * Send smart digests to all opted-in users. Called from the autopilot loop.
564 * Never throws.
565 */
566export async function sendSmartDigestsToAll(): Promise<void> {
567 try {
568 const candidates = await db
569 .select({ id: users.id })
570 .from(users)
571 .where(
572 sql`(${users.notifyEmailDigestWeekly} = true OR ${users.notifySmartDigest} = true)`
573 )
574 .limit(200);
575
576 for (const candidate of candidates) {
577 try {
578 await sendSmartDigest(candidate.id);
579 } catch (err) {
580 console.error(
581 `[smart-digest] per-user error for user=${candidate.id}:`,
582 err
583 );
584 }
585 }
586 } catch (err) {
587 console.error("[smart-digest] sendSmartDigestsToAll error:", err);
588 }
589}
Addedsrc/routes/digest.tsx+508−0View fileUnifiedSplit
@@ -0,0 +1,508 @@
1/**
2 * /digest — Smart Morning Digest page.
3 *
4 * GET /digest — personal AI-curated digest (requireAuth)
5 * POST /digest/refresh — regenerate digest for current user, then redirect
6 *
7 * Shows the user's most recent digest notification (type='digest') or
8 * auto-generates one on first load. Displays:
9 * - AI-written headline
10 * - Priority-sorted queue (blocking=red, important=amber, fyi=grey)
11 * - Stats row (PRs reviewed / issues closed / commits)
12 * - Optional AI insight
13 * - "Regenerate" button
14 */
15
16import { Hono } from "hono";
17import { eq, and, desc } from "drizzle-orm";
18import { db } from "../db";
19import { notifications, users } from "../db/schema";
20import { Layout } from "../views/layout";
21import { requireAuth, softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { composeSmartDigest, sendSmartDigest, type SmartDigest, type DigestItem } from "../lib/smart-digest";
24import { formatRelative } from "../views/ui";
25
26const digest = new Hono<AuthEnv>();
27
28// ---------------------------------------------------------------------------
29// Styles
30// ---------------------------------------------------------------------------
31
32const DIGEST_STYLES = `
33 .digest-hero {
34 position: relative;
35 margin: 0 0 var(--space-5, 24px);
36 padding: 24px 28px 26px;
37 background: var(--bg-elevated, #f8f9fa);
38 border: 1px solid var(--border, #e1e4e8);
39 border-radius: 16px;
40 overflow: hidden;
41 }
42 .digest-hero::before {
43 content: '';
44 position: absolute; top: 0; left: 0; right: 0;
45 height: 2px;
46 background: linear-gradient(90deg, transparent 0%, #f7971e 30%, #ffd200 70%, transparent 100%);
47 opacity: 0.8;
48 pointer-events: none;
49 }
50 .digest-hero-icon {
51 font-size: 28px;
52 margin-bottom: 10px;
53 display: block;
54 }
55 .digest-headline {
56 font-size: clamp(20px, 2.8vw, 28px);
57 font-weight: 800;
58 letter-spacing: -0.02em;
59 color: var(--text-strong, #111);
60 margin: 0 0 8px;
61 line-height: 1.15;
62 }
63 .digest-meta {
64 font-size: 12.5px;
65 color: var(--text-muted, #777);
66 margin: 0;
67 }
68 .digest-actions {
69 display: flex;
70 gap: 8px;
71 align-items: center;
72 flex-wrap: wrap;
73 margin-top: 16px;
74 }
75 .digest-regenerate-btn {
76 display: inline-flex;
77 align-items: center;
78 gap: 6px;
79 padding: 8px 14px;
80 background: var(--bg, #fff);
81 border: 1px solid var(--border, #e1e4e8);
82 border-radius: 8px;
83 font-size: 13px;
84 font-weight: 500;
85 color: var(--text, #333);
86 cursor: pointer;
87 text-decoration: none;
88 }
89 .digest-regenerate-btn:hover {
90 background: var(--bg-elevated, #f8f9fa);
91 border-color: var(--accent, #0070f3);
92 }
93
94 /* Queue */
95 .digest-queue {
96 display: flex;
97 flex-direction: column;
98 gap: 8px;
99 margin-bottom: 24px;
100 }
101 .digest-item {
102 display: flex;
103 align-items: center;
104 gap: 12px;
105 padding: 12px 16px;
106 background: var(--bg-elevated, #f8f9fa);
107 border: 1px solid var(--border, #e1e4e8);
108 border-radius: 10px;
109 border-left: 3px solid transparent;
110 text-decoration: none;
111 color: inherit;
112 transition: box-shadow 0.15s;
113 }
114 .digest-item:hover {
115 box-shadow: 0 2px 8px rgba(0,0,0,0.08);
116 }
117 .digest-item.blocking {
118 border-left-color: #ef4444;
119 background: #fef2f2;
120 }
121 .digest-item.important {
122 border-left-color: #f59e0b;
123 background: #fffbeb;
124 }
125 .digest-item.fyi {
126 border-left-color: #94a3b8;
127 }
128 .digest-item-icon {
129 font-size: 18px;
130 flex-shrink: 0;
131 width: 28px;
132 text-align: center;
133 }
134 .digest-item-content {
135 flex: 1;
136 min-width: 0;
137 }
138 .digest-item-title {
139 font-size: 14px;
140 font-weight: 600;
141 color: var(--text-strong, #111);
142 margin: 0 0 2px;
143 white-space: nowrap;
144 overflow: hidden;
145 text-overflow: ellipsis;
146 }
147 .digest-item-subtitle {
148 font-size: 12px;
149 color: var(--text-muted, #777);
150 margin: 0;
151 }
152 .digest-item-priority {
153 font-size: 11px;
154 font-weight: 600;
155 padding: 2px 7px;
156 border-radius: 99px;
157 flex-shrink: 0;
158 }
159 .digest-item-priority.blocking {
160 background: #fee2e2;
161 color: #b91c1c;
162 }
163 .digest-item-priority.important {
164 background: #fef3c7;
165 color: #92400e;
166 }
167 .digest-item-priority.fyi {
168 background: var(--bg, #f1f5f9);
169 color: var(--text-muted, #64748b);
170 }
171 .digest-item-go {
172 font-size: 13px;
173 color: var(--accent, #0070f3);
174 flex-shrink: 0;
175 font-weight: 500;
176 }
177
178 /* Stats row */
179 .digest-stats {
180 display: flex;
181 gap: 16px;
182 flex-wrap: wrap;
183 margin-bottom: 24px;
184 }
185 .digest-stat-card {
186 flex: 1;
187 min-width: 120px;
188 padding: 14px 18px;
189 background: var(--bg-elevated, #f8f9fa);
190 border: 1px solid var(--border, #e1e4e8);
191 border-radius: 10px;
192 text-align: center;
193 }
194 .digest-stat-value {
195 font-size: 28px;
196 font-weight: 800;
197 color: var(--text-strong, #111);
198 letter-spacing: -0.03em;
199 line-height: 1;
200 margin-bottom: 4px;
201 }
202 .digest-stat-label {
203 font-size: 12px;
204 color: var(--text-muted, #777);
205 }
206
207 /* Insight */
208 .digest-insight {
209 padding: 16px 20px;
210 background: linear-gradient(135deg, #f0f4ff 0%, #fdf4ff 100%);
211 border: 1px solid #c7d2fe;
212 border-radius: 12px;
213 margin-bottom: 24px;
214 display: flex;
215 gap: 12px;
216 align-items: flex-start;
217 }
218 .digest-insight-icon {
219 font-size: 20px;
220 flex-shrink: 0;
221 margin-top: 2px;
222 }
223 .digest-insight-text {
224 font-size: 14px;
225 color: #3730a3;
226 margin: 0;
227 line-height: 1.5;
228 }
229
230 /* Empty state */
231 .digest-empty {
232 text-align: center;
233 padding: 48px 24px;
234 color: var(--text-muted, #777);
235 }
236 .digest-empty-icon { font-size: 40px; margin-bottom: 12px; }
237 .digest-empty-text { font-size: 16px; font-weight: 600; margin-bottom: 8px; color: var(--text-strong, #111); }
238 .digest-empty-sub { font-size: 14px; margin: 0; }
239
240 /* Spinner */
241 .digest-spinner-wrap {
242 text-align: center;
243 padding: 48px 24px;
244 }
245 .digest-spinner {
246 display: inline-block;
247 width: 32px;
248 height: 32px;
249 border: 3px solid var(--border, #e1e4e8);
250 border-top-color: var(--accent, #0070f3);
251 border-radius: 50%;
252 animation: digest-spin 0.8s linear infinite;
253 margin-bottom: 12px;
254 }
255 @keyframes digest-spin { to { transform: rotate(360deg); } }
256
257 /* Section header */
258 .digest-section-title {
259 font-size: 13px;
260 font-weight: 700;
261 letter-spacing: 0.06em;
262 text-transform: uppercase;
263 color: var(--text-muted, #777);
264 margin: 0 0 12px;
265 }
266`;
267
268// ---------------------------------------------------------------------------
269// Helpers
270// ---------------------------------------------------------------------------
271
272type Priority = DigestItem["priority"];
273type ItemType = DigestItem["type"];
274
275function priorityLabel(p: Priority): string {
276 if (p === "blocking") return "Blocking";
277 if (p === "important") return "Important";
278 return "FYI";
279}
280
281function itemIcon(t: ItemType): string {
282 if (t === "pr_review") return "\u{1F50D}";
283 if (t === "pr_comment") return "\u{1F4AC}";
284 if (t === "ci_failure") return "\u{274C}";
285 if (t === "mention") return "\u{1F514}";
286 if (t === "dep_update") return "\u{1F4E6}";
287 if (t === "new_issue") return "\u{1F41B}";
288 return "\u{2022}";
289}
290
291// ---------------------------------------------------------------------------
292// GET /digest
293// ---------------------------------------------------------------------------
294
295digest.get("/digest", softAuth, requireAuth, async (c) => {
296 const user = c.get("user")!;
297 const generating = c.req.query("generating") === "1";
298
299 // Look up the most recent digest notification
300 const [latestDigestNotif] = await db
301 .select()
302 .from(notifications)
303 .where(
304 and(
305 eq(notifications.userId, user.id),
306 eq(notifications.kind, "digest")
307 )
308 )
309 .orderBy(desc(notifications.createdAt))
310 .limit(1)
311 .catch(() => []);
312
313 let digestData: SmartDigest | null = null;
314 let isToday = false;
315
316 if (latestDigestNotif?.body) {
317 try {
318 digestData = JSON.parse(latestDigestNotif.body) as SmartDigest;
319 const digestDate = new Date(digestData.generatedAt);
320 const now = new Date();
321 isToday =
322 digestDate.getFullYear() === now.getFullYear() &&
323 digestDate.getMonth() === now.getMonth() &&
324 digestDate.getDate() === now.getDate();
325 } catch {
326 /* malformed JSON */
327 }
328 }
329
330 // Auto-generate if no digest today and not already generating
331 if (!isToday && !generating) {
332 // Fire-and-forget then redirect with ?generating=1 to show spinner
333 void (async () => {
334 try {
335 await sendSmartDigest(user.id);
336 } catch {
337 /* swallow */
338 }
339 })();
340 return c.redirect("/digest?generating=1");
341 }
342
343 // If generating, show spinner page that polls
344 if (generating && !isToday) {
345 return c.html(
346 <Layout title="Morning Digest" user={user}>
347 <style dangerouslySetInnerHTML={{ __html: DIGEST_STYLES }} />
348 <div style="max-width:720px;margin:0 auto;padding:24px 16px">
349 <h1 class="digest-headline" style="margin-bottom:24px">
350 Morning Digest
351 </h1>
352 <div class="digest-spinner-wrap">
353 <div class="digest-spinner" />
354 <p style="font-size:15px;color:var(--text-muted);margin:0">
355 Generating your digest...
356 </p>
357 </div>
358 <script
359 dangerouslySetInnerHTML={{
360 __html: `
361 setTimeout(function() {
362 window.location.href = '/digest';
363 }, 3000);
364 `,
365 }}
366 />
367 </div>
368 </Layout>
369 );
370 }
371
372 return c.html(
373 <Layout title="Morning Digest" user={user}>
374 <style dangerouslySetInnerHTML={{ __html: DIGEST_STYLES }} />
375 <div style="max-width:720px;margin:0 auto;padding:24px 16px">
376
377 {/* Hero */}
378 <div class="digest-hero">
379 <span class="digest-hero-icon" aria-hidden="true">{"☀"}</span>
380 <h1 class="digest-headline">
381 {digestData?.headline || "No digest available"}
382 </h1>
383 {digestData && (
384 <p class="digest-meta">
385 Generated {formatRelative(digestData.generatedAt)}
386 </p>
387 )}
388 <div class="digest-actions">
389 <form method="post" action="/digest/refresh" style="display:inline">
390 <button type="submit" class="digest-regenerate-btn">
391 {"↻"} Regenerate
392 </button>
393 </form>
394 <a href="/inbox" class="digest-regenerate-btn">
395 View all notifications
396 </a>
397 </div>
398 </div>
399
400 {/* AI Insight */}
401 {digestData?.insight && (
402 <div>
403 <p class="digest-section-title">AI Insight</p>
404 <div class="digest-insight">
405 <span class="digest-insight-icon" aria-hidden="true">{"✨"}</span>
406 <p class="digest-insight-text">{digestData.insight}</p>
407 </div>
408 </div>
409 )}
410
411 {/* Stats row */}
412 {digestData?.stats && (
413 <div>
414 <p class="digest-section-title">This week</p>
415 <div class="digest-stats" style="margin-bottom:24px">
416 <div class="digest-stat-card">
417 <div class="digest-stat-value">{digestData.stats.prsReviewed}</div>
418 <div class="digest-stat-label">PRs reviewed</div>
419 </div>
420 <div class="digest-stat-card">
421 <div class="digest-stat-value">{digestData.stats.issuesClosed}</div>
422 <div class="digest-stat-label">Issues closed</div>
423 </div>
424 <div class="digest-stat-card">
425 <div class="digest-stat-value">{digestData.stats.commitsThisWeek}</div>
426 <div class="digest-stat-label">Commits</div>
427 </div>
428 </div>
429 </div>
430 )}
431
432 {/* Queue */}
433 {digestData && digestData.queue.length > 0 ? (
434 <div>
435 <p class="digest-section-title">Action queue</p>
436 <div class="digest-queue">
437 {digestData.queue.map((item, idx) => (
438 <a
439 key={idx}
440 href={item.url}
441 class={`digest-item ${item.priority}`}
442 >
443 <span class="digest-item-icon" aria-hidden="true">
444 {itemIcon(item.type)}
445 </span>
446 <div class="digest-item-content">
447 <p class="digest-item-title">{item.title}</p>
448 <p class="digest-item-subtitle">{item.subtitle}</p>
449 </div>
450 <span class={`digest-item-priority ${item.priority}`}>
451 {priorityLabel(item.priority)}
452 </span>
453 <span class="digest-item-go" aria-hidden="true">{"→"}</span>
454 </a>
455 ))}
456 </div>
457 </div>
458 ) : digestData ? (
459 <div class="digest-empty">
460 <div class="digest-empty-icon" aria-hidden="true">{"🎉"}</div>
461 <p class="digest-empty-text">All caught up!</p>
462 <p class="digest-empty-sub">No action items for today.</p>
463 </div>
464 ) : (
465 <div class="digest-empty">
466 <div class="digest-empty-icon" aria-hidden="true">{"📋"}</div>
467 <p class="digest-empty-text">No digest yet</p>
468 <p class="digest-empty-sub">
469 Use the Regenerate button above to create your first digest.
470 </p>
471 </div>
472 )}
473
474 </div>
475 </Layout>
476 );
477});
478
479// ---------------------------------------------------------------------------
480// POST /digest/refresh
481// ---------------------------------------------------------------------------
482
483digest.post("/digest/refresh", softAuth, requireAuth, async (c) => {
484 const user = c.get("user")!;
485 // Force a fresh digest by clearing cooldown temporarily — just compose + insert
486 try {
487 const freshDigest = await composeSmartDigest(user.id);
488 if (freshDigest) {
489 await db.insert(notifications).values({
490 userId: user.id,
491 kind: "digest",
492 title: freshDigest.headline,
493 body: JSON.stringify(freshDigest),
494 url: "/digest",
495 });
496 // Update last sent timestamp
497 await db
498 .update(users)
499 .set({ lastSmartDigestSentAt: new Date() })
500 .where(eq(users.id, user.id));
501 }
502 } catch (err) {
503 console.error("[digest] refresh error:", err);
504 }
505 return c.redirect("/digest");
506});
507
508export default digest;
Modifiedsrc/routes/pulls.tsx+48−0View fileUnifiedSplit
@@ -66,6 +66,7 @@ import {
6666import { triggerPrTriage } from "../lib/pr-triage";
6767import { generatePrSummary } from "../lib/ai-generators";
6868import { isAiAvailable } from "../lib/ai-client";
69import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
6970import {
7071 computePrRiskForPullRequest,
7172 getCachedPrRisk,
@@ -3594,6 +3595,20 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
35943595 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
35953596 }
35963597
3598 // Review context restore — compute BEFORE recording the visit so the
3599 // previous timestamp is available for the delta calculation.
3600 let reviewCtx: ReviewContext | null = null;
3601 if (user) {
3602 reviewCtx = await getReviewContext(pr.id, user.id, {
3603 ownerName,
3604 repoName,
3605 baseBranch: pr.baseBranch,
3606 headBranch: pr.headBranch,
3607 });
3608 // Fire-and-forget: record the visit AFTER computing context
3609 void recordPrVisit(pr.id, user.id);
3610 }
3611
35973612 return c.html(
35983613 <Layout
35993614 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
@@ -3626,6 +3641,39 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
36263641 }}
36273642 />
36283643
3644 {/* Review context restore banner — shown when returning after changes */}
3645 {reviewCtx && (
3646 <div
3647 class="context-restore-banner"
3648 id="review-context"
3649 style="display:flex;align-items:flex-start;gap:12px;padding:12px 16px;margin:0 0 12px;background:var(--bg-elevated,#f8f9fa);border:1px solid var(--border,#e1e4e8);border-left:3px solid var(--accent,#0070f3);border-radius:10px"
3650 >
3651 <span class="context-icon" style="font-size:18px;flex-shrink:0;margin-top:2px" aria-hidden="true">{"↩"}</span>
3652 <div style="flex:1;min-width:0">
3653 <strong style="font-size:13.5px;color:var(--text-strong,#111)">Welcome back</strong>
3654 <p style="margin:4px 0 0;font-size:13px;color:var(--text,#333);line-height:1.5">{reviewCtx.summary}</p>
3655 <small style="font-size:11.5px;color:var(--text-muted,#777)">
3656 Last visited {formatRelative(new Date(reviewCtx.lastVisitedAt))}
3657 {reviewCtx.commitsSince > 0 && ` · ${reviewCtx.commitsSince} new commit${reviewCtx.commitsSince === 1 ? "" : "s"}`}
3658 {reviewCtx.newComments > 0 && ` · ${reviewCtx.newComments} new comment${reviewCtx.newComments === 1 ? "" : "s"}`}
3659 </small>
3660 {reviewCtx.suggestedStartLine && (
3661 <p style="margin:6px 0 0;font-size:12px;color:var(--accent,#0070f3)">
3662 Start at: <code style="font-size:11px">{reviewCtx.suggestedStartLine}</code>
3663 </p>
3664 )}
3665 </div>
3666 <button
3667 type="button"
3668 onclick="this.closest('.context-restore-banner').remove()"
3669 style="flex-shrink:0;background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-muted,#777);padding:0;line-height:1"
3670 aria-label="Dismiss"
3671 >
3672 {"×"}
3673 </button>
3674 </div>
3675 )}
3676
36293677 <div class="prs-detail-hero">
36303678 <div class="prs-edit-title-wrap">
36313679 <h1 class="prs-detail-title" id="pr-title-display">
Modifiedsrc/views/layout.tsx+4−0View fileUnifiedSplit
@@ -235,6 +235,10 @@ export const Layout: FC<
235235 </a>
236236 </div>
237237 </div>
238 {/* Smart morning digest link */}
239 <a href="/digest" class="nav-link" title="Morning Digest" aria-label="Morning Digest">
240 {"☀"}
241 </a>
238242 {/* Inbox bell with unread badge */}
239243 <a
240244 href="/inbox"
241245