Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit3c03977unknown_key

feat(pr-live): live co-editing on PRs — presence + cursors + content sync via SSE

Claude committed on May 25, 2026Parent: 38d31d3
8 files changed+1287153c03977916e84d8042ca494d04a3e800c1c2af66
8 changed files+1287−15
Addeddrizzle/0061_pr_live_sessions.sql+52−0View fileUnifiedSplit
1-- Live co-editing on PRs — presence + cursor + content sync.
2--
3-- A `pr_live_sessions` row represents a single browser tab (or agent
4-- runtime) actively editing a PR's description, comments, or inline
5-- diff annotations. The SSE endpoint at /api/v2/pulls/:prId/live
6-- fans presence + cursor + content patches out to every subscriber
7-- on the same PR.
8--
9-- Either `user_id` OR `agent_session_id` is non-null (XOR-ish at the
10-- app layer; the DB just keeps both nullable so the same table can
11-- represent humans and AI agents in one stream). `color` is a stable
12-- per-user hue picked deterministically by the lib so concurrent tabs
13-- of the same user share a colour and the cursor ribbon stays steady.
14--
15-- `cursor_position` is JSON shaped like:
16-- { "field": "description" | "comment_<uuid>" | "line_<path>:<n>",
17-- "range": { "start": number, "end": number } }
18-- We keep this opaque at the DB layer so future field types (e.g. a
19-- new "review_summary" textarea) don't require a migration.
20--
21-- Lifecycle:
22-- joined_at — when the session was first registered.
23-- last_seen_at — touched by every heartbeat (15s cadence from the
24-- client) and every cursor/edit broadcast.
25-- status — 'active' | 'idle' (>60s no heartbeat) | 'left'
26-- (>5m no heartbeat or explicit leave).
27-- The autopilot pr-live-cleanup task transitions stale rows; the SSE
28-- subscriber also lazily updates status when it notices a stale peer.
29
30CREATE TABLE IF NOT EXISTS pr_live_sessions (
31 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
32 pr_id uuid NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
33 user_id uuid REFERENCES users(id) ON DELETE CASCADE,
34 agent_session_id uuid REFERENCES agent_sessions(id) ON DELETE CASCADE,
35 cursor_position jsonb,
36 color text NOT NULL,
37 joined_at timestamptz NOT NULL DEFAULT now(),
38 last_seen_at timestamptz NOT NULL DEFAULT now(),
39 status text NOT NULL DEFAULT 'active'
40);
41
42-- Active-presence lookup: "who is here on PR X right now?" — answered
43-- in O(1) by this partial index. Status transitions to 'left' drop the
44-- row from the index automatically.
45CREATE INDEX IF NOT EXISTS pr_live_sessions_active_pr
46 ON pr_live_sessions (pr_id, last_seen_at)
47 WHERE status <> 'left';
48
49-- Stale-sweep lookup: the autopilot cleanup task scans rows whose
50-- last_seen_at is older than the idle/left thresholds.
51CREATE INDEX IF NOT EXISTS pr_live_sessions_status_seen
52 ON pr_live_sessions (status, last_seen_at);
Addedsrc/__tests__/pr-live.test.ts+352−0View fileUnifiedSplit
1/**
2 * PR live co-editing — session lifecycle, cursor fan-out, stale sweep.
3 *
4 * Two layers:
5 * - Pure helpers (colorFor determinism, topic naming) run
6 * unconditionally.
7 * - DB-backed flows (join/leave, listLive, sweepStale) and the
8 * in-process broadcast fan-out are gated behind HAS_DB so the
9 * suite stays green on machines without Postgres.
10 */
11
12import { describe, it, expect, beforeAll } from "bun:test";
13import { randomBytes } from "crypto";
14import {
15 colorFor,
16 prLiveTopic,
17 type CursorPosition,
18} from "../lib/pr-live";
19import { subscribe, type SSEEvent } from "../lib/sse";
20
21const HAS_DB = Boolean(process.env.DATABASE_URL);
22
23beforeAll(() => {
24 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
25});
26
27// ---------------------------------------------------------------------------
28// Pure helpers (no DB)
29// ---------------------------------------------------------------------------
30
31describe("pr-live — colour helper", () => {
32 it("returns a stable colour for the same principal", () => {
33 const id = "user-abc-123";
34 expect(colorFor(id)).toBe(colorFor(id));
35 });
36
37 it("returns a hex colour from the curated palette", () => {
38 const c = colorFor("user-x");
39 expect(/^#[0-9a-f]{6}$/i.test(c)).toBe(true);
40 });
41
42 it("disjoint principals usually map to different colours", () => {
43 // Hash-mod over a 10-entry palette: two random ids land on
44 // different bins ~90% of the time. We probe 20 pairs and only
45 // assert that *most* differ — the property we care about is
46 // distribution, not strict pairwise inequality.
47 let differ = 0;
48 for (let i = 0; i < 20; i++) {
49 const a = colorFor(`p-${i}-a`);
50 const b = colorFor(`p-${i}-b`);
51 if (a !== b) differ++;
52 }
53 expect(differ).toBeGreaterThanOrEqual(14);
54 });
55});
56
57describe("pr-live — topic naming", () => {
58 it("prefixes pr-live: + prId", () => {
59 expect(prLiveTopic("abc-123")).toBe("pr-live:abc-123");
60 });
61});
62
63// ---------------------------------------------------------------------------
64// Broadcast fan-out — pure pub/sub layer, no DB needed.
65// ---------------------------------------------------------------------------
66
67describe("pr-live — broadcast fan-out (no DB)", () => {
68 it("publishes a cursor event to subscribers on the same topic", async () => {
69 const { updateCursor } = await import("../lib/pr-live");
70 const prId = `tmp-pr-${randomBytes(4).toString("hex")}`;
71 const events: SSEEvent[] = [];
72 const unsub = subscribe(prLiveTopic(prId), (e) => events.push(e));
73
74 const position: CursorPosition = {
75 field: "description",
76 range: { start: 4, end: 4 },
77 };
78 // sessionId can be a synthetic value — the in-process broadcaster
79 // does not require the row to exist in the DB.
80 await updateCursor(`tmp-${randomBytes(4).toString("hex")}`, prId, position);
81
82 expect(events.length).toBeGreaterThan(0);
83 const cursorEvt = events.find((e) => e.event === "cursor");
84 expect(cursorEvt).toBeDefined();
85 const data = cursorEvt!.data as { position: CursorPosition };
86 expect(data.position.field).toBe("description");
87 expect(data.position.range.start).toBe(4);
88
89 unsub();
90 });
91
92 it("publishes presence-leave on leaveSession", async () => {
93 const { leaveSession } = await import("../lib/pr-live");
94 const prId = `tmp-pr-${randomBytes(4).toString("hex")}`;
95 const events: SSEEvent[] = [];
96 const unsub = subscribe(prLiveTopic(prId), (e) => events.push(e));
97
98 await leaveSession(`tmp-${randomBytes(4).toString("hex")}`, prId);
99 expect(events.some((e) => e.event === "presence-leave")).toBe(true);
100 unsub();
101 });
102
103 it("publishes an edit event on broadcastEdit", async () => {
104 const { broadcastEdit } = await import("../lib/pr-live");
105 const prId = `tmp-pr-${randomBytes(4).toString("hex")}`;
106 const events: SSEEvent[] = [];
107 const unsub = subscribe(prLiveTopic(prId), (e) => events.push(e));
108
109 await broadcastEdit(`tmp-${randomBytes(4).toString("hex")}`, prId, {
110 field: "comment_new",
111 op: "replace",
112 at: 0,
113 value: "hi",
114 });
115
116 const editEvt = events.find((e) => e.event === "edit");
117 expect(editEvt).toBeDefined();
118 const data = editEvt!.data as { patch: { value: string } };
119 expect(data.patch.value).toBe("hi");
120 unsub();
121 });
122});
123
124// ---------------------------------------------------------------------------
125// DB-backed lifecycle + stale sweep.
126// ---------------------------------------------------------------------------
127
128describe.skipIf(!HAS_DB)("pr-live — DB lifecycle", () => {
129 /**
130 * Seed a real user + repository + PR so foreign keys are satisfied.
131 * Returns the PR id (which is what the live-session table joins on).
132 */
133 async function seedPr(): Promise<string | null> {
134 const { db } = await import("../db");
135 const { users, repositories, pullRequests } = await import("../db/schema");
136 const stamp = randomBytes(4).toString("hex");
137 const [u] = await db
138 .insert(users)
139 .values({
140 username: `prlive-${stamp}`,
141 email: `prlive-${stamp}@test.local`,
142 passwordHash: "x",
143 })
144 .returning();
145 if (!u) return null;
146
147 const [r] = await db
148 .insert(repositories)
149 .values({
150 name: `prlive-repo-${stamp}`,
151 ownerId: u.id,
152 diskPath: `/tmp/prlive-${stamp}`,
153 defaultBranch: "main",
154 })
155 .returning();
156 if (!r) return null;
157
158 const [pr] = await db
159 .insert(pullRequests)
160 .values({
161 repositoryId: r.id,
162 authorId: u.id,
163 title: "live edit test",
164 baseBranch: "main",
165 headBranch: `feat-${stamp}`,
166 })
167 .returning();
168 return pr?.id ?? null;
169 }
170
171 it("joinSession inserts a row and returns sessionId + colour", async () => {
172 const { joinSession, getSession } = await import("../lib/pr-live");
173 const { db } = await import("../db");
174 const { users } = await import("../db/schema");
175
176 const prId = await seedPr();
177 expect(prId).not.toBeNull();
178 if (!prId) return;
179
180 const stamp = randomBytes(4).toString("hex");
181 const [u] = await db
182 .insert(users)
183 .values({
184 username: `joiner-${stamp}`,
185 email: `joiner-${stamp}@test.local`,
186 passwordHash: "x",
187 })
188 .returning();
189 if (!u) return;
190
191 const joined = await joinSession({ prId, userId: u.id });
192 expect(joined).not.toBeNull();
193 if (!joined) return;
194 expect(joined.color).toMatch(/^#[0-9a-f]{6}$/i);
195 expect(joined.sessionId.length).toBeGreaterThan(0);
196
197 const row = await getSession(joined.sessionId);
198 expect(row).not.toBeNull();
199 expect(row?.status).toBe("active");
200 expect(row?.prId).toBe(prId);
201 });
202
203 it("leaveSession transitions status to 'left'", async () => {
204 const { joinSession, leaveSession, getSession } = await import(
205 "../lib/pr-live"
206 );
207 const { db } = await import("../db");
208 const { users } = await import("../db/schema");
209
210 const prId = await seedPr();
211 if (!prId) return;
212 const stamp = randomBytes(4).toString("hex");
213 const [u] = await db
214 .insert(users)
215 .values({
216 username: `leaver-${stamp}`,
217 email: `leaver-${stamp}@test.local`,
218 passwordHash: "x",
219 })
220 .returning();
221 if (!u) return;
222
223 const joined = await joinSession({ prId, userId: u.id });
224 if (!joined) return;
225 await leaveSession(joined.sessionId, prId);
226 const row = await getSession(joined.sessionId);
227 expect(row?.status).toBe("left");
228 });
229
230 it("listLive omits left sessions but keeps active + idle", async () => {
231 const { joinSession, leaveSession, listLive } = await import(
232 "../lib/pr-live"
233 );
234 const { db } = await import("../db");
235 const { users } = await import("../db/schema");
236
237 const prId = await seedPr();
238 if (!prId) return;
239 const mk = async () => {
240 const stamp = randomBytes(4).toString("hex");
241 const [u] = await db
242 .insert(users)
243 .values({
244 username: `m-${stamp}`,
245 email: `m-${stamp}@test.local`,
246 passwordHash: "x",
247 })
248 .returning();
249 return u?.id ?? null;
250 };
251 const u1 = await mk();
252 const u2 = await mk();
253 if (!u1 || !u2) return;
254
255 const j1 = await joinSession({ prId, userId: u1 });
256 const j2 = await joinSession({ prId, userId: u2 });
257 expect(j1).not.toBeNull();
258 expect(j2).not.toBeNull();
259
260 let list = await listLive(prId);
261 expect(list.length).toBeGreaterThanOrEqual(2);
262
263 if (j2) await leaveSession(j2.sessionId, prId);
264 list = await listLive(prId);
265 const ids = list.map((s) => s.id);
266 if (j2) expect(ids.includes(j2.sessionId)).toBe(false);
267 if (j1) expect(ids.includes(j1.sessionId)).toBe(true);
268 });
269
270 it("sweepStale marks idle (>60s) and left (>5m) rows", async () => {
271 const { joinSession, sweepStale, getSession } = await import(
272 "../lib/pr-live"
273 );
274 const { db } = await import("../db");
275 const { users, prLiveSessions } = await import("../db/schema");
276 const { eq } = await import("drizzle-orm");
277
278 const prId = await seedPr();
279 if (!prId) return;
280 const stamp = randomBytes(4).toString("hex");
281 const [u] = await db
282 .insert(users)
283 .values({
284 username: `stale-${stamp}`,
285 email: `stale-${stamp}@test.local`,
286 passwordHash: "x",
287 })
288 .returning();
289 if (!u) return;
290
291 const joined = await joinSession({ prId, userId: u.id });
292 if (!joined) return;
293 // Back-date last_seen_at by 70s so the next sweep transitions to
294 // 'idle'.
295 const idleStamp = new Date(Date.now() - 70_000);
296 await db
297 .update(prLiveSessions)
298 .set({ lastSeenAt: idleStamp })
299 .where(eq(prLiveSessions.id, joined.sessionId));
300
301 const r1 = await sweepStale();
302 expect(r1.idled).toBeGreaterThanOrEqual(1);
303 const idleRow = await getSession(joined.sessionId);
304 expect(idleRow?.status).toBe("idle");
305
306 // Push past 5 minutes; next sweep should mark 'left'.
307 const leftStamp = new Date(Date.now() - 6 * 60_000);
308 await db
309 .update(prLiveSessions)
310 .set({ lastSeenAt: leftStamp })
311 .where(eq(prLiveSessions.id, joined.sessionId));
312
313 const r2 = await sweepStale();
314 expect(r2.left).toBeGreaterThanOrEqual(1);
315 const leftRow = await getSession(joined.sessionId);
316 expect(leftRow?.status).toBe("left");
317 });
318
319 it("updateCursor persists position + touches last_seen_at", async () => {
320 const { joinSession, updateCursor, getSession } = await import(
321 "../lib/pr-live"
322 );
323 const { db } = await import("../db");
324 const { users } = await import("../db/schema");
325
326 const prId = await seedPr();
327 if (!prId) return;
328 const stamp = randomBytes(4).toString("hex");
329 const [u] = await db
330 .insert(users)
331 .values({
332 username: `cursor-${stamp}`,
333 email: `cursor-${stamp}@test.local`,
334 passwordHash: "x",
335 })
336 .returning();
337 if (!u) return;
338 const joined = await joinSession({ prId, userId: u.id });
339 if (!joined) return;
340
341 await updateCursor(joined.sessionId, prId, {
342 field: "description",
343 range: { start: 12, end: 18 },
344 });
345
346 const row = await getSession(joined.sessionId);
347 expect(row?.cursor?.field).toBe("description");
348 expect(row?.cursor?.range.start).toBe(12);
349 expect(row?.cursor?.range.end).toBe(18);
350 expect(row?.status).toBe("active");
351 });
352});
Modifiedsrc/app.tsx+7−1View fileUnifiedSplit
3131import teamCollaboratorRoutes from "./routes/team-collaborators";
3232import invitesRoutes from "./routes/invites";
3333import liveEventsRoutes from "./routes/live-events";
34import prLiveRoutes from "./routes/pr-live";
3435import compareRoutes from "./routes/compare";
3536import pullRoutes from "./routes/pulls";
3637import editorRoutes from "./routes/editor";
161162 p.includes(".git/") ||
162163 p.startsWith("/live-events") ||
163164 p.startsWith("/api/events/deploy") ||
164 p.startsWith("/admin/status")
165 p.startsWith("/admin/status") ||
166 // PR live co-editing SSE stream — never etag streaming responses.
167 /^\/api\/v2\/pulls\/[^/]+\/live(\/|$)/.test(p)
165168 ) {
166169 return next();
167170 }
419422// Real-time SSE endpoint (topic-based live updates)
420423app.route("/", liveEventsRoutes);
421424
425// PR live co-editing — presence + cursors + content sync via SSE.
426app.route("/", prLiveRoutes);
427
422428// Webhooks management
423429app.route("/", webhookRoutes);
424430
Modifiedsrc/db/schema.ts+37−7View fileUnifiedSplit
30653065export type NewAgentLease = typeof agentLeases.$inferInsert;
30663066
30673067// ---------------------------------------------------------------------------
3068// 0060 — AI repo rubber-duck chat.
3069// One row per chat thread + one row per message. Distinct from the older
3070// `ai_chats` JSON-blob design so streaming partials, per-message citations,
3071// and token-cost accounting all stay first-class. See src/lib/repo-chat.ts
3072// for the helpers + src/routes/repo-chat.tsx for the UI.
3068// 0060 — AI repo rubber-duck chat. See src/lib/repo-chat.ts.
30733069// ---------------------------------------------------------------------------
30743070export const repoChats = pgTable(
30753071 "repo_chats",
31023098 chatId: uuid("chat_id")
31033099 .notNull()
31043100 .references(() => repoChats.id, { onDelete: "cascade" }),
3105 // 'user' | 'assistant' | 'system'
31063101 role: text("role").notNull(),
31073102 content: text("content").notNull(),
3108 // Array of { file_path, blob_sha }. jsonb so we can index later if needed.
31093103 citations: jsonb("citations").$type<Array<{ file_path: string; blob_sha: string }>>().notNull().default([]),
31103104 tokenCost: integer("token_cost").notNull().default(0),
31113105 createdAt: timestamp("created_at", { withTimezone: true })
31223116export type RepoChatMessage = typeof repoChatMessages.$inferSelect;
31233117export type NewRepoChatMessage = typeof repoChatMessages.$inferInsert;
31243118
3119// ---------------------------------------------------------------------------
3120// 0061 — Live co-editing on PRs. See src/lib/pr-live.ts.
3121// ---------------------------------------------------------------------------
3122export const prLiveSessions = pgTable(
3123 "pr_live_sessions",
3124 {
3125 id: uuid("id").primaryKey().defaultRandom(),
3126 prId: uuid("pr_id")
3127 .notNull()
3128 .references(() => pullRequests.id, { onDelete: "cascade" }),
3129 userId: uuid("user_id").references(() => users.id, {
3130 onDelete: "cascade",
3131 }),
3132 agentSessionId: uuid("agent_session_id").references(
3133 () => agentSessions.id,
3134 { onDelete: "cascade" }
3135 ),
3136 cursorPosition: jsonb("cursor_position"),
3137 color: text("color").notNull(),
3138 joinedAt: timestamp("joined_at", { withTimezone: true })
3139 .defaultNow()
3140 .notNull(),
3141 lastSeenAt: timestamp("last_seen_at", { withTimezone: true })
3142 .defaultNow()
3143 .notNull(),
3144 status: text("status").default("active").notNull(),
3145 },
3146 (table) => [
3147 index("pr_live_sessions_active_pr").on(table.prId, table.lastSeenAt),
3148 index("pr_live_sessions_status_seen").on(table.status, table.lastSeenAt),
3149 ]
3150);
3151
3152export type PrLiveSession = typeof prLiveSessions.$inferSelect;
3153export type NewPrLiveSession = typeof prLiveSessions.$inferInsert;
3154
Modifiedsrc/lib/autopilot.ts+20−0View fileUnifiedSplit
6262} from "./ai-standup";
6363import { runSpecToPrTaskOnce } from "./autopilot-spec-to-pr";
6464import { runMigrationWatcherTaskOnce } from "./migration-assistant";
65import { sweepStale as sweepStalePrLive } from "./pr-live";
6566
6667export interface AutopilotTaskResult {
6768 name: string;
384385 }
385386 },
386387 },
388 {
389 // PR live co-editing — transition stale `pr_live_sessions` rows
390 // to 'idle' (>60s) and 'left' (>5m) so the presence pill on the
391 // PR detail page never claims a ghost user is still editing.
392 // Cheap pure-SQL UPDATE; runs every tick.
393 name: "pr-live-cleanup",
394 run: async () => {
395 try {
396 const summary = await sweepStalePrLive();
397 if (summary.idled > 0 || summary.left > 0) {
398 console.log(
399 `[autopilot] pr-live-cleanup: idled=${summary.idled} left=${summary.left}`
400 );
401 }
402 } catch (err) {
403 console.error("[autopilot] pr-live-cleanup: threw:", err);
404 }
405 },
406 },
387407 {
388408 // AI Standup — weekly Claude-generated team brief. Mondays only.
389409 name: "weekly-standup",
Addedsrc/lib/pr-live.ts+364−0View fileUnifiedSplit
1/**
2 * PR live co-editing — presence, cursors, and content patches.
3 *
4 * One row per active browser tab (human) or agent runtime in
5 * `pr_live_sessions`. The HTTP layer (src/routes/pr-live.ts) opens a
6 * topic-based SSE stream `pr-live:<prId>`; this lib drives the writes:
7 *
8 * joinSession — register a session, return id + colour
9 * updateCursor — debounce-friendly cursor broadcast
10 * broadcastEdit — content patch fan-out (last-write-wins for v1)
11 * leaveSession — explicit "this tab is closing" marker
12 * sweepStale — autopilot task: idle/left state machine
13 *
14 * Everything that talks to subscribers funnels through `publish()` on
15 * topic `pr-live:<prId>` so the route file stays a thin transport. DB
16 * writes are best-effort — if the database is offline the broadcast
17 * still reaches the in-process subscribers, which is the happy path
18 * for production where ephemeral presence rarely needs durability.
19 */
20
21import { eq, and, lt, sql, ne } from "drizzle-orm";
22import { createHash } from "crypto";
23import { db } from "../db";
24import { prLiveSessions } from "../db/schema";
25import { publish } from "./sse";
26
27/** Field identifiers the client may attach a cursor / patch to. */
28export type LiveField =
29 | "description"
30 | `comment_${string}`
31 | `line_${string}:${number}`;
32
33/** Cursor range — character offsets within the named field's content. */
34export interface CursorPosition {
35 field: LiveField | string;
36 range: { start: number; end: number };
37}
38
39/** Patch payload — opaque to the lib; the client picks the dialect. */
40export interface EditPatch {
41 field: LiveField | string;
42 /** Op shape — replace, insert, delete. */
43 op: "replace" | "insert" | "delete";
44 /** Position (char offset) the op applies at. */
45 at: number;
46 /** Inserted/replaced text (omit for delete). */
47 value?: string;
48 /** Length to remove (replace/delete only). */
49 length?: number;
50}
51
52/** Public session view — the shape SSE consumers see. */
53export interface PublicLiveSession {
54 id: string;
55 prId: string;
56 userId: string | null;
57 agentSessionId: string | null;
58 color: string;
59 status: "active" | "idle" | "left";
60 cursor: CursorPosition | null;
61 joinedAt: string;
62 lastSeenAt: string;
63}
64
65/** Tuned per the spec: idle after 60s, dropped after 5m of silence. */
66export const IDLE_AFTER_MS = 60_000;
67export const LEFT_AFTER_MS = 5 * 60_000;
68/** Heartbeat the client is expected to send. */
69export const HEARTBEAT_MS = 15_000;
70
71/** Deterministic colour palette — picked by hashing the principal id. */
72const COLORS = [
73 "#f87171", "#fb923c", "#fbbf24", "#a3e635",
74 "#34d399", "#22d3ee", "#60a5fa", "#a78bfa",
75 "#f472b6", "#fb7185",
76] as const;
77
78/**
79 * Deterministic colour for a principal (user id or agent session id).
80 * Hash → modulo palette so concurrent tabs of the same user share the
81 * same hue and the UI stays steady.
82 */
83export function colorFor(principalId: string): string {
84 const h = createHash("sha256").update(principalId).digest();
85 const idx = h[0] % COLORS.length;
86 return COLORS[idx];
87}
88
89/** SSE topic name for one PR. */
90export function prLiveTopic(prId: string): string {
91 return `pr-live:${prId}`;
92}
93
94/**
95 * Best-effort DB write. If the DB is unreachable we still want presence
96 * to work in-process — production already runs autopilot cleanup that
97 * heals from drift. Returns the value or `null` on error.
98 */
99async function tryDb<T>(fn: () => Promise<T>): Promise<T | null> {
100 try {
101 return await fn();
102 } catch {
103 return null;
104 }
105}
106
107/**
108 * Begin a session. Exactly one of `userId` / `agentSessionId` should be
109 * non-null; the DB stores both as nullable so a single table represents
110 * both principals.
111 */
112export async function joinSession(args: {
113 prId: string;
114 userId?: string | null;
115 agentSessionId?: string | null;
116}): Promise<{ sessionId: string; color: string } | null> {
117 const principalId =
118 args.userId ?? args.agentSessionId ?? null;
119 if (!principalId) return null;
120 const color = colorFor(principalId);
121
122 const row = await tryDb(async () => {
123 const [inserted] = await db
124 .insert(prLiveSessions)
125 .values({
126 prId: args.prId,
127 userId: args.userId ?? null,
128 agentSessionId: args.agentSessionId ?? null,
129 color,
130 status: "active",
131 })
132 .returning();
133 return inserted ?? null;
134 });
135
136 // Even if the DB write failed, mint a synthetic id so the in-process
137 // broadcaster has something to key on for this tab.
138 const sessionId = row?.id ?? `tmp-${principalId}-${Date.now()}`;
139
140 publish(prLiveTopic(args.prId), {
141 event: "presence-join",
142 data: {
143 sessionId,
144 prId: args.prId,
145 userId: args.userId ?? null,
146 agentSessionId: args.agentSessionId ?? null,
147 color,
148 status: "active",
149 joinedAt: row?.joinedAt?.toISOString() ?? new Date().toISOString(),
150 },
151 });
152
153 return { sessionId, color };
154}
155
156/**
157 * Update cursor position + touch heartbeat. Caller is expected to
158 * debounce (~100ms) — this function does not internally throttle.
159 */
160export async function updateCursor(
161 sessionId: string,
162 prId: string,
163 position: CursorPosition
164): Promise<void> {
165 await tryDb(async () => {
166 await db
167 .update(prLiveSessions)
168 .set({
169 cursorPosition: position as unknown as Record<string, unknown>,
170 lastSeenAt: new Date(),
171 status: "active",
172 })
173 .where(eq(prLiveSessions.id, sessionId));
174 });
175
176 publish(prLiveTopic(prId), {
177 event: "cursor",
178 data: { sessionId, position },
179 });
180}
181
182/**
183 * Touch a session's heartbeat without changing the cursor. The client
184 * pings every 15s; missing pings transition the row to idle/left.
185 */
186export async function heartbeat(
187 sessionId: string,
188 prId: string
189): Promise<void> {
190 await tryDb(async () => {
191 await db
192 .update(prLiveSessions)
193 .set({ lastSeenAt: new Date(), status: "active" })
194 .where(eq(prLiveSessions.id, sessionId));
195 });
196
197 publish(prLiveTopic(prId), {
198 event: "heartbeat",
199 data: { sessionId },
200 });
201}
202
203/**
204 * Broadcast a content patch. v1 is last-write-wins — the client applies
205 * received patches directly to its textarea. A proper OT engine is the
206 * v2 follow-up, which is why the patch shape is intentionally open.
207 */
208export async function broadcastEdit(
209 sessionId: string,
210 prId: string,
211 patch: EditPatch
212): Promise<void> {
213 // Touch last_seen so an editing tab doesn't go stale.
214 await tryDb(async () => {
215 await db
216 .update(prLiveSessions)
217 .set({ lastSeenAt: new Date(), status: "active" })
218 .where(eq(prLiveSessions.id, sessionId));
219 });
220
221 publish(prLiveTopic(prId), {
222 event: "edit",
223 data: { sessionId, patch },
224 });
225}
226
227/** Mark a session as explicitly left (tab close, navigation, etc.). */
228export async function leaveSession(
229 sessionId: string,
230 prId: string
231): Promise<void> {
232 await tryDb(async () => {
233 await db
234 .update(prLiveSessions)
235 .set({ status: "left", lastSeenAt: new Date() })
236 .where(eq(prLiveSessions.id, sessionId));
237 });
238
239 publish(prLiveTopic(prId), {
240 event: "presence-leave",
241 data: { sessionId },
242 });
243}
244
245/** List the live presence on a PR (active + idle, drops 'left'). */
246export async function listLive(prId: string): Promise<PublicLiveSession[]> {
247 const rows = await tryDb(() =>
248 db
249 .select()
250 .from(prLiveSessions)
251 .where(
252 and(
253 eq(prLiveSessions.prId, prId),
254 ne(prLiveSessions.status, "left")
255 )
256 )
257 );
258 if (!rows) return [];
259 return rows.map((r) => ({
260 id: r.id,
261 prId: r.prId,
262 userId: r.userId,
263 agentSessionId: r.agentSessionId,
264 color: r.color,
265 status: (r.status as PublicLiveSession["status"]) ?? "active",
266 cursor: (r.cursorPosition as CursorPosition | null) ?? null,
267 joinedAt: r.joinedAt.toISOString(),
268 lastSeenAt: r.lastSeenAt.toISOString(),
269 }));
270}
271
272/**
273 * Autopilot sweep — transition stale rows to idle / left based on the
274 * configured thresholds. Returns { idled, left } counts so the task
275 * row can log them. Safe to call on an empty DB.
276 */
277export async function sweepStale(
278 now: Date = new Date()
279): Promise<{ idled: number; left: number }> {
280 const idleCutoff = new Date(now.getTime() - IDLE_AFTER_MS);
281 const leftCutoff = new Date(now.getTime() - LEFT_AFTER_MS);
282
283 const idledRes = await tryDb(async () => {
284 const updated = await db
285 .update(prLiveSessions)
286 .set({ status: "idle" })
287 .where(
288 and(
289 eq(prLiveSessions.status, "active"),
290 lt(prLiveSessions.lastSeenAt, idleCutoff)
291 )
292 )
293 .returning({ id: prLiveSessions.id, prId: prLiveSessions.prId });
294 return updated;
295 });
296 const idled = idledRes ?? [];
297
298 const leftRes = await tryDb(async () => {
299 const updated = await db
300 .update(prLiveSessions)
301 .set({ status: "left" })
302 .where(
303 and(
304 // Sweeping anything not already 'left' covers both active and
305 // idle rows that have crossed the 5-minute threshold.
306 ne(prLiveSessions.status, "left"),
307 lt(prLiveSessions.lastSeenAt, leftCutoff)
308 )
309 )
310 .returning({ id: prLiveSessions.id, prId: prLiveSessions.prId });
311 return updated;
312 });
313 const left = leftRes ?? [];
314
315 // Fire presence-update / leave events for any peers still listening.
316 for (const row of idled) {
317 publish(prLiveTopic(row.prId), {
318 event: "presence-update",
319 data: { sessionId: row.id, status: "idle" },
320 });
321 }
322 for (const row of left) {
323 publish(prLiveTopic(row.prId), {
324 event: "presence-leave",
325 data: { sessionId: row.id },
326 });
327 }
328
329 return { idled: idled.length, left: left.length };
330}
331
332/**
333 * Lookup a single session row by id — used by the SSE endpoint to
334 * verify a heartbeat/leave request matches a real row before
335 * broadcasting.
336 */
337export async function getSession(
338 sessionId: string
339): Promise<PublicLiveSession | null> {
340 const row = await tryDb(async () => {
341 const [r] = await db
342 .select()
343 .from(prLiveSessions)
344 .where(eq(prLiveSessions.id, sessionId))
345 .limit(1);
346 return r ?? null;
347 });
348 if (!row) return null;
349 return {
350 id: row.id,
351 prId: row.prId,
352 userId: row.userId,
353 agentSessionId: row.agentSessionId,
354 color: row.color,
355 status: (row.status as PublicLiveSession["status"]) ?? "active",
356 cursor: (row.cursorPosition as CursorPosition | null) ?? null,
357 joinedAt: row.joinedAt.toISOString(),
358 lastSeenAt: row.lastSeenAt.toISOString(),
359 };
360}
361
362// Re-export so callers don't need to also import drizzle helpers. Kept
363// at the bottom so the public surface is obvious when reading the file.
364export { sql };
Addedsrc/routes/pr-live.ts+239−0View fileUnifiedSplit
1/**
2 * Live co-editing transport — SSE stream + REST control plane.
3 *
4 * GET /api/v2/pulls/:prId/live
5 * SSE feed of presence + cursor + edit events for one PR. Auto-
6 * joins on connect (if authed) and auto-leaves on stream close.
7 *
8 * POST /api/v2/pulls/:prId/live/cursor
9 * { sessionId, position } — broadcast a cursor move.
10 *
11 * POST /api/v2/pulls/:prId/live/edit
12 * { sessionId, patch } — broadcast a content patch.
13 *
14 * POST /api/v2/pulls/:prId/live/heartbeat
15 * { sessionId } — keep-alive ping.
16 *
17 * POST /api/v2/pulls/:prId/live/leave
18 * { sessionId } — explicit leave (the browser also fires this on
19 * `beforeunload` via sendBeacon).
20 *
21 * Unauthed connections still receive the SSE feed (so anonymous repo
22 * viewers see presence), but the POST control plane requires auth.
23 * Agent tokens (`agt_*`) are accepted via the optional `?agent=1`
24 * query string + Authorization header path — left to the writeup;
25 * v1 wires humans only and falls back gracefully on missing principal.
26 */
27
28import { Hono } from "hono";
29import { softAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
31import { subscribe, type SSEEvent } from "../lib/sse";
32import {
33 joinSession,
34 leaveSession,
35 updateCursor,
36 heartbeat,
37 broadcastEdit,
38 listLive,
39 prLiveTopic,
40 type CursorPosition,
41 type EditPatch,
42} from "../lib/pr-live";
43
44const app = new Hono<AuthEnv>();
45
46// SSE heartbeat from the server side (separate from client-side
47// heartbeat broadcasts). Keeps intermediaries from idle-timing the
48// connection.
49const SSE_PING_MS = 25_000;
50
51/** Strict UUID-ish guard (we don't lock to a specific RFC variant). */
52const ID_RE = /^[a-zA-Z0-9\-]{1,64}$/;
53
54app.get("/api/v2/pulls/:prId/live", softAuth, async (c) => {
55 const prId = c.req.param("prId");
56 if (!prId || !ID_RE.test(prId)) {
57 return c.json({ error: "Invalid pr id" }, 400);
58 }
59
60 const user = c.get("user") ?? null;
61 const topic = prLiveTopic(prId);
62
63 // Auto-join — only humans for v1; agents will get an explicit
64 // POST /join endpoint when the harness wants them on the stream.
65 let sessionId: string | null = null;
66 let sessionColor: string | null = null;
67 if (user) {
68 const joined = await joinSession({ prId, userId: user.id });
69 if (joined) {
70 sessionId = joined.sessionId;
71 sessionColor = joined.color;
72 }
73 }
74
75 // Snapshot of current presence — sent as the first event so the
76 // client can render avatars without an extra round-trip.
77 const presence = await listLive(prId);
78
79 const encoder = new TextEncoder();
80
81 const stream = new ReadableStream<Uint8Array>({
82 start(controller) {
83 let closed = false;
84 const safeEnqueue = (chunk: string) => {
85 if (closed) return;
86 try {
87 controller.enqueue(encoder.encode(chunk));
88 } catch {
89 closed = true;
90 }
91 };
92
93 const sendEvent = (event: SSEEvent) => {
94 let payload = "";
95 if (event.id !== undefined) payload += `id: ${event.id}\n`;
96 if (event.event !== undefined) payload += `event: ${event.event}\n`;
97 const data =
98 typeof event.data === "string"
99 ? event.data
100 : JSON.stringify(event.data);
101 for (const line of data.split("\n")) {
102 payload += `data: ${line}\n`;
103 }
104 payload += "\n";
105 safeEnqueue(payload);
106 };
107
108 // Flush headers on proxies that buffer.
109 safeEnqueue(": open\n\n");
110
111 // Initial "hello" with the snapshot + the joined session id (so
112 // the client knows which row is theirs and can suppress echo
113 // events from itself).
114 sendEvent({
115 event: "hello",
116 data: {
117 sessionId,
118 color: sessionColor,
119 presence,
120 },
121 });
122
123 const unsubscribe = subscribe(topic, sendEvent);
124
125 const ping = setInterval(() => {
126 safeEnqueue(": ping\n\n");
127 }, SSE_PING_MS);
128
129 const cleanup = async () => {
130 if (closed) return;
131 closed = true;
132 clearInterval(ping);
133 unsubscribe();
134 if (sessionId) {
135 // Fire-and-forget. The autopilot sweep is the durable
136 // fallback if this never runs (process crash).
137 try {
138 await leaveSession(sessionId, prId);
139 } catch {
140 /* best-effort */
141 }
142 }
143 try {
144 controller.close();
145 } catch {
146 /* already closed */
147 }
148 };
149
150 const signal = c.req.raw.signal;
151 if (signal) {
152 if (signal.aborted) {
153 void cleanup();
154 } else {
155 signal.addEventListener("abort", () => void cleanup(), { once: true });
156 }
157 }
158 },
159 });
160
161 return new Response(stream, {
162 status: 200,
163 headers: {
164 "Content-Type": "text/event-stream; charset=utf-8",
165 "Cache-Control": "no-cache, no-transform",
166 Connection: "keep-alive",
167 "X-Accel-Buffering": "no",
168 },
169 });
170});
171
172// ---------- Control plane ----------
173
174async function parseJsonBody(c: import("hono").Context): Promise<any> {
175 try {
176 return await c.req.json();
177 } catch {
178 return null;
179 }
180}
181
182app.post("/api/v2/pulls/:prId/live/cursor", softAuth, async (c) => {
183 const prId = c.req.param("prId");
184 if (!prId || !ID_RE.test(prId)) return c.json({ error: "Invalid pr id" }, 400);
185 const body = await parseJsonBody(c);
186 const sessionId = String(body?.sessionId || "");
187 const position = body?.position as CursorPosition | undefined;
188 if (!sessionId || !position || typeof position.field !== "string") {
189 return c.json({ error: "Invalid body" }, 400);
190 }
191 await updateCursor(sessionId, prId, position);
192 return c.json({ ok: true });
193});
194
195app.post("/api/v2/pulls/:prId/live/edit", softAuth, async (c) => {
196 const prId = c.req.param("prId");
197 if (!prId || !ID_RE.test(prId)) return c.json({ error: "Invalid pr id" }, 400);
198 const body = await parseJsonBody(c);
199 const sessionId = String(body?.sessionId || "");
200 const patch = body?.patch as EditPatch | undefined;
201 if (!sessionId || !patch || typeof patch.field !== "string") {
202 return c.json({ error: "Invalid body" }, 400);
203 }
204 await broadcastEdit(sessionId, prId, patch);
205 return c.json({ ok: true });
206});
207
208app.post("/api/v2/pulls/:prId/live/heartbeat", softAuth, async (c) => {
209 const prId = c.req.param("prId");
210 if (!prId || !ID_RE.test(prId)) return c.json({ error: "Invalid pr id" }, 400);
211 const body = await parseJsonBody(c);
212 const sessionId = String(body?.sessionId || "");
213 if (!sessionId) return c.json({ error: "Invalid body" }, 400);
214 await heartbeat(sessionId, prId);
215 return c.json({ ok: true });
216});
217
218app.post("/api/v2/pulls/:prId/live/leave", softAuth, async (c) => {
219 const prId = c.req.param("prId");
220 if (!prId || !ID_RE.test(prId)) return c.json({ error: "Invalid pr id" }, 400);
221 // Accept JSON body OR a sendBeacon Blob — sendBeacon sets
222 // content-type to text/plain;charset=UTF-8 by default. Try JSON
223 // first, fall back to reading raw text and parsing it.
224 let body = await parseJsonBody(c);
225 if (!body) {
226 try {
227 const raw = await c.req.text();
228 body = raw ? JSON.parse(raw) : null;
229 } catch {
230 body = null;
231 }
232 }
233 const sessionId = String(body?.sessionId || "");
234 if (!sessionId) return c.json({ error: "Invalid body" }, 400);
235 await leaveSession(sessionId, prId);
236 return c.json({ ok: true });
237});
238
239export default app;
Modifiedsrc/routes/pulls.tsx+216−7View fileUnifiedSplit
748748 .prs-comment-head { padding: 10px 12px; }
749749 .prs-files-card { padding: 12px 14px; }
750750 }
751
752 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
753 .live-pill {
754 display: inline-flex;
755 align-items: center;
756 gap: 8px;
757 padding: 4px 10px 4px 8px;
758 margin-left: 6px;
759 background: var(--bg-elevated);
760 border: 1px solid var(--border);
761 border-radius: 9999px;
762 font-size: 12px;
763 color: var(--text-muted);
764 line-height: 1;
765 vertical-align: middle;
766 }
767 .live-pill.is-busy { color: var(--text); }
768 .live-pill-dot {
769 width: 8px; height: 8px;
770 border-radius: 9999px;
771 background: #34d399;
772 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
773 animation: live-pulse 1.6s ease-in-out infinite;
774 }
775 @keyframes live-pulse {
776 0%, 100% { opacity: 1; }
777 50% { opacity: 0.55; }
778 }
779 .live-avatars {
780 display: inline-flex;
781 margin-left: 2px;
782 }
783 .live-avatar {
784 display: inline-flex;
785 align-items: center;
786 justify-content: center;
787 width: 22px; height: 22px;
788 border-radius: 9999px;
789 font-size: 10px;
790 font-weight: 700;
791 color: #0b1020;
792 margin-left: -6px;
793 border: 2px solid var(--bg-elevated);
794 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
795 }
796 .live-avatar:first-child { margin-left: 0; }
797 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
798 .live-cursor-host {
799 position: relative;
800 }
801 .live-cursor-overlay {
802 position: absolute;
803 inset: 0;
804 pointer-events: none;
805 overflow: hidden;
806 border-radius: inherit;
807 }
808 .live-cursor {
809 position: absolute;
810 width: 2px;
811 height: 18px;
812 border-radius: 2px;
813 transform: translate(-1px, 0);
814 transition: transform 80ms linear, opacity 200ms ease;
815 }
816 .live-cursor::after {
817 content: attr(data-label);
818 position: absolute;
819 top: -16px;
820 left: -2px;
821 font-size: 10px;
822 line-height: 1;
823 color: #0b1020;
824 background: inherit;
825 padding: 2px 5px;
826 border-radius: 4px 4px 4px 0;
827 white-space: nowrap;
828 font-weight: 600;
829 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
830 }
831 .live-cursor.is-idle { opacity: 0.4; }
832 .live-edit-tag {
833 display: inline-block;
834 margin-left: 6px;
835 padding: 1px 6px;
836 font-size: 10px;
837 font-weight: 600;
838 letter-spacing: 0.02em;
839 color: #0b1020;
840 border-radius: 9999px;
841 }
751842`;
752843
753844/**
791882 );
792883}
793884
885/**
886 * Live co-editing client. Connects to the per-PR SSE feed and:
887 * - Maintains a "Live: N editing" pill in the PR header (avatars +
888 * status colour per user).
889 * - Renders tinted cursor caret overlays inside #pr-body and every
890 * `[data-live-field]` element.
891 * - Broadcasts the local user's cursor position (selectionStart /
892 * selectionEnd) debounced at 100ms.
893 * - Broadcasts content patches (`replace` of the whole textarea —
894 * last-write-wins v1) debounced at 250ms.
895 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
896 * to the matching local field if untouched.
897 *
898 * All endpoint URLs are JSON-escaped via safe replacements so they
899 * can't break out of the <script> tag.
900 */
901function LIVE_COEDIT_SCRIPT(prId: string): string {
902 const idJson = JSON.stringify(prId)
903 .split("<").join("\\u003C")
904 .split(">").join("\\u003E")
905 .split("&").join("\\u0026");
906 return (
907 "(function(){try{" +
908 "if(typeof EventSource==='undefined')return;" +
909 "var prId=" + idJson + ";" +
910 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
911 "var pill=document.getElementById('live-pill');" +
912 "var avEl=document.getElementById('live-avatars');" +
913 "var countEl=document.getElementById('live-count');" +
914 "var sessionId=null;var myColor=null;" +
915 "var presence={};" + // sessionId -> {color,status,userId,initials}
916 "var lastApplied={};" + // field -> last server value (for echo suppression)
917 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
918 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
919 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
920 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
921 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
922 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
923 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
924 "avEl.innerHTML=html;}}" +
925 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
926 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
927 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
928 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
929 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
930 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
931 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
932 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
933 "if(!c){c=document.createElement('div');c.className='live-cursor';c.setAttribute('data-sid',sid);c.style.background=p.color;c.setAttribute('data-label',p.label||'editor');ov.appendChild(c);}" +
934 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
935 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
936 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
937 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
938 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
939 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
940 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
941 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
942 "}catch(e){}" +
943 "c.style.transform='translate('+x+'px,'+y+'px)';" +
944 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
945 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
946 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
947 "var es;var delay=1000;" +
948 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
949 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
950 "(d.presence||[]).forEach(function(s){presence[s.id]={color:s.color,status:s.status,userId:s.userId,initials:initials(s.userId||s.agentSessionId),label:s.userId?'user':'agent'};});renderPresence();}catch(e){}});" +
951 "es.addEventListener('presence-join',function(m){try{var d=JSON.parse(m.data);presence[d.sessionId]={color:d.color,status:d.status,userId:d.userId,initials:initials(d.userId||d.agentSessionId),label:d.userId?'user':'agent'};renderPresence();}catch(e){}});" +
952 "es.addEventListener('presence-update',function(m){try{var d=JSON.parse(m.data);if(presence[d.sessionId]){presence[d.sessionId].status=d.status;renderPresence();}}catch(e){}});" +
953 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
954 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
955 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
956 "var patch=d.patch;if(!patch||!patch.field)return;" +
957 "var ta=fieldEl(patch.field);if(!ta)return;" +
958 "if(document.activeElement===ta)return;" + // don't trample local typing
959 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
960 "}catch(e){}});" +
961 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
962 "}connect();" +
963 "function post(suffix,body){try{return fetch(base+suffix,{method:'POST',headers:{'content-type':'application/json'},credentials:'same-origin',body:JSON.stringify(body)}).catch(function(){});}catch(e){}}" +
964 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
965 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
966 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
967 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
968 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
969 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
970 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
971 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
972 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
973 "}" +
974 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
975 "var live=document.querySelectorAll('[data-live-field]');" +
976 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
977 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
978 "window.addEventListener('beforeunload',function(){if(!sessionId)return;try{var blob=new Blob([JSON.stringify({sessionId:sessionId})],{type:'application/json'});if(navigator.sendBeacon)navigator.sendBeacon(base+'/leave',blob);else post('/leave',{sessionId:sessionId});}catch(e){}});" +
979 "}catch(e){}})();"
980 );
981}
982
794983async function resolveRepo(ownerName: string, repoName: string) {
795984 const [owner] = await db
796985 .select()
16541843 <span class="prs-branch-pill">{pr.baseBranch}</span>
16551844 </span>
16561845 <span>opened {formatRelative(pr.createdAt)}</span>
1846 <span
1847 id="live-pill"
1848 class="live-pill"
1849 title="People editing this PR right now"
1850 >
1851 <span class="live-pill-dot" aria-hidden="true"></span>
1852 <span>
1853 Live: <strong id="live-count">0</strong> editing
1854 </span>
1855 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
1856 </span>
16571857 {canManage && pr.state === "open" && pr.isDraft && (
16581858 <form
16591859 method="post"
16671867 )}
16681868 </div>
16691869 </div>
1870 <script
1871 dangerouslySetInnerHTML={{
1872 __html: LIVE_COEDIT_SCRIPT(pr.id),
1873 }}
1874 />
16701875
16711876 <nav class="prs-detail-tabs" aria-label="Pull request sections">
16721877 <a
18682073 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
18692074 >
18702075 <FormGroup>
1871 <TextArea
1872 name="body"
1873 rows={5}
1874 required
1875 placeholder="Leave a comment... (Markdown supported)"
1876 mono
1877 />
2076 <div class="live-cursor-host" style="position:relative">
2077 <textarea
2078 name="body"
2079 id="pr-comment-body"
2080 data-live-field="comment_new"
2081 rows={5}
2082 required
2083 placeholder="Leave a comment... (Markdown supported)"
2084 style="font-family:var(--font-mono);font-size:13px;width:100%"
2085 ></textarea>
2086 </div>
18782087 </FormGroup>
18792088 <div class="prs-merge-actions">
18802089 <Button type="submit" variant="primary">
18812090