Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

claude-web.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

claude-web.tsxBlame430 lines · 2 contributors
90c7531Claude1/**
2 * Block CW — Claude on the web.
3 *
4 * GET /:owner/:repo/claude — session list + new-session form
5 * POST /:owner/:repo/claude — create session, redirect to detail
6 * GET /:owner/:repo/claude/:sessionId — chat UI (messages + composer)
7 * GET /:owner/:repo/claude/:sessionId/stream — SSE stream of a single turn
8 * POST /:owner/:repo/claude/:sessionId/delete — delete session
9 *
4d9e72bClaude10 * Open to all authenticated users with at least READ access to the repo
11 * (owner, accepted collaborator, or any user for public repos). The page
12 * renders server-side with a small inline EventSource client that POSTs
13 * nothing — the SSE GET is parameterised by `?prompt=...` so iPad
14 * keyboards (no JS fetch issues) just need to follow a link the form
15 * submits to. The endpoint persists the user message before opening the
16 * stream, so a flaky network mid-stream still leaves the question in the
17 * transcript.
90c7531Claude18 */
19
20import { Hono } from "hono";
fc623bfccantynz-alt21import { parseIdUuid } from "../lib/route-params";
90c7531Claude22import { and, eq } from "drizzle-orm";
23import { db } from "../db";
24import { repositories, users } from "../db/schema";
25import { Layout } from "../views/layout";
26import { softAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
4d9e72bClaude28import { resolveRepoAccess } from "../middleware/repo-access";
90c7531Claude29import {
30 appendMessage,
31 createSession,
32 deleteSession,
33 ensureWorkdir,
34 getSession,
35 listMessages,
f1dc38bClaude36 listSessionsForUser,
90c7531Claude37 runTurn,
38 touchSession,
39} from "../lib/claude-web-session";
40
41const claudeWeb = new Hono<AuthEnv>();
42claudeWeb.use("*", softAuth);
43
44async function gate(
45 c: any
46): Promise<{ userId: string; repoId: string; ownerName: string; repoName: string } | Response> {
47 const user = c.get("user");
48 if (!user) {
49 const target = encodeURIComponent(c.req.url);
50 return c.redirect(`/login?next=${target}`);
51 }
52 const ownerName = c.req.param("owner");
53 const repoName = c.req.param("repo");
54 const [row] = await db
4d9e72bClaude55 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
90c7531Claude56 .from(repositories)
57 .innerJoin(users, eq(repositories.ownerId, users.id))
58 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
59 .limit(1);
60 if (!row) return c.notFound();
4d9e72bClaude61 const access = await resolveRepoAccess({
62 repoId: row.id,
63 userId: user.id,
64 isPublic: !row.isPrivate,
65 });
66 if (access === "none") return c.notFound();
90c7531Claude67 return { userId: user.id, repoId: row.id, ownerName, repoName };
68}
69
70const wrap =
71 "max-width:980px;margin:24px auto;padding:0 16px;color:#e5e7eb;font-family:system-ui,sans-serif";
72const card =
73 "background:#0e1117;border:1px solid #1f2937;border-radius:10px;padding:16px 18px;margin-bottom:14px";
74const inputStyle =
75 "width:100%;background:#0b0e13;border:1px solid #1f2937;color:#e5e7eb;padding:10px 12px;border-radius:6px;font-size:16px";
76const btn =
e589f77ccantynz-alt77 "background:var(--accent);border:0;color:#fff;padding:10px 16px;border-radius:6px;cursor:pointer;font-size:15px";
90c7531Claude78
79// ─── GET /:owner/:repo/claude ───────────────────────────────────────────────
80
81claudeWeb.get("/:owner/:repo/claude", async (c) => {
82 const g = await gate(c);
83 if (g instanceof Response) return g;
84 const user = c.get("user")!;
f1dc38bClaude85 // Show only the current user's sessions for this repo (privacy isolation).
86 const sessions = await listSessionsForUser(g.repoId, g.userId);
87
88 const statusBadge = (status: string) => {
89 const colors: Record<string, string> = {
90 cold: "#374151",
91 running: "#1e40af",
92 ready: "#14532d",
93 failed: "#7f1d1d",
94 };
95 const bg = colors[status] ?? "#374151";
96 return (
97 <span
98 style={`background:${bg};color:#e5e7eb;font-size:11px;padding:2px 7px;border-radius:10px;font-family:ui-monospace,monospace;vertical-align:middle`}
99 >
100 {status}
101 </span>
102 );
103 };
90c7531Claude104
105 return c.html(
106 <Layout title={`Claude — ${g.ownerName}/${g.repoName}`} user={user}>
107 <main style={wrap}>
108 <p style="margin:0 0 6px">
109 <a href={`/${g.ownerName}/${g.repoName}`} style="color:#9ca3af;text-decoration:none">
110 ← {g.ownerName}/{g.repoName}
111 </a>
112 </p>
f1dc38bClaude113 <h1 style="margin:0 0 4px;font-size:22px">✨ Claude Code Sessions</h1>
114 <p style="margin:0 0 20px;color:#9ca3af;font-size:14px">
115 Browser-based Claude Code sessions on a live clone of this repo. Each session
116 persists its transcript so you can resume from any device.
90c7531Claude117 </p>
118
119 <form method="post" action={`/${g.ownerName}/${g.repoName}/claude`} style={card}>
f1dc38bClaude120 <label style="display:block;font-size:13px;color:#9ca3af;margin-bottom:8px;font-weight:600">
121 Start a new session
90c7531Claude122 </label>
f1dc38bClaude123 <div style="display:flex;gap:8px;flex-wrap:wrap">
124 <input
125 name="title"
126 placeholder="What do you want to work on?"
127 style={inputStyle + ";flex:1;min-width:180px"}
128 />
129 <input
130 name="branch"
131 placeholder="branch (default: main)"
132 style={inputStyle + ";max-width:200px"}
133 />
134 <button type="submit" style={btn}>
135 Start session →
136 </button>
90c7531Claude137 </div>
138 </form>
139
140 <div style={card}>
f1dc38bClaude141 <h2 style="margin:0 0 12px;font-size:15px;color:#cbd5e1">
142 Your sessions{sessions.length > 0 ? ` (${sessions.length})` : ""}
143 </h2>
90c7531Claude144 {sessions.length === 0 ? (
f1dc38bClaude145 <p style="color:#6b7280;margin:0;font-size:14px">
146 No sessions yet. Start one above — Claude will clone the repo and
147 open a persistent conversation.
148 </p>
90c7531Claude149 ) : (
150 <ul style="list-style:none;padding:0;margin:0">
f1dc38bClaude151 {[...sessions].reverse().map((s) => (
152 <li style="padding:12px 0;border-top:1px solid #1f2937;display:flex;justify-content:space-between;align-items:center;gap:12px">
90c7531Claude153 <a
154 href={`/${g.ownerName}/${g.repoName}/claude/${s.id}`}
f1dc38bClaude155 style="color:#7aa2f7;text-decoration:none;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
90c7531Claude156 >
157 {s.title}
158 </a>
f1dc38bClaude159 <span style="display:flex;align-items:center;gap:8px;flex-shrink:0;color:#6b7280;font-size:12px;font-family:ui-monospace,monospace">
160 {s.branch}
161 {statusBadge(s.status)}
90c7531Claude162 </span>
163 </li>
164 ))}
165 </ul>
166 )}
167 </div>
168 </main>
169 </Layout>
170 );
171});
172
173// ─── POST /:owner/:repo/claude ──────────────────────────────────────────────
174
175claudeWeb.post("/:owner/:repo/claude", async (c) => {
176 const g = await gate(c);
177 if (g instanceof Response) return g;
178 const form = await c.req.parseBody();
179 const title = String(form.title || "").trim() || "New session";
180 const branch = String(form.branch || "").trim() || "main";
181 const session = await createSession({
182 repositoryId: g.repoId,
183 ownerUserId: g.userId,
184 title,
185 branch,
186 });
187 return c.redirect(`/${g.ownerName}/${g.repoName}/claude/${session.id}`);
188});
189
190// ─── GET /:owner/:repo/claude/:sessionId ────────────────────────────────────
191
192claudeWeb.get("/:owner/:repo/claude/:sessionId", async (c) => {
193 const g = await gate(c);
194 if (g instanceof Response) return g;
195 const user = c.get("user")!;
fc623bfccantynz-alt196 const sessionId = parseIdUuid(c.req.param("sessionId"));
197 if (!sessionId) return c.notFound();
90c7531Claude198 const session = await getSession(sessionId, g.userId);
199 if (!session || session.repositoryId !== g.repoId) return c.notFound();
200
201 const messages = await listMessages(sessionId);
202
203 // Inline SSE client. ~30 lines of vanilla JS — keeps the bundle empty.
204 const clientJs = `
205 (function() {
206 var f = document.getElementById('cw-composer');
207 if (!f) return;
208 f.addEventListener('submit', function(ev) {
209 ev.preventDefault();
210 var input = document.getElementById('cw-prompt');
211 var prompt = (input.value || '').trim();
212 if (!prompt) return;
213 input.value = '';
214 var list = document.getElementById('cw-messages');
215 var u = document.createElement('div');
216 u.className = 'cw-msg cw-msg-user';
217 u.textContent = prompt;
218 list.appendChild(u);
219 var a = document.createElement('pre');
220 a.className = 'cw-msg cw-msg-assistant';
221 a.textContent = '';
222 list.appendChild(a);
223 var url = ${JSON.stringify(`/${g.ownerName}/${g.repoName}/claude/${sessionId}/stream`)} + '?prompt=' + encodeURIComponent(prompt);
224 var es = new EventSource(url);
225 es.addEventListener('chunk', function(e) {
226 a.textContent += e.data;
227 window.scrollTo(0, document.body.scrollHeight);
228 });
229 es.addEventListener('done', function() {
230 es.close();
231 });
232 es.addEventListener('error', function() {
233 a.textContent += '\\n[stream error — reload to retry]';
234 es.close();
235 });
236 });
237 })();
238 `;
239
240 const styleCss = `
241 .cw-msg { padding:10px 12px;border-radius:8px;margin:8px 0;white-space:pre-wrap;word-wrap:break-word;font-family:ui-monospace,monospace;font-size:14px;line-height:1.5; }
242 .cw-msg-user { background:#172033;color:#e5e7eb;border:1px solid #1f6feb55; }
243 .cw-msg-assistant { background:#0e1117;color:#cbd5e1;border:1px solid #1f2937; }
244 .cw-msg-system { background:#2a1f10;color:#e1c47f;border:1px solid #5a4a1f;font-size:13px; }
245 `;
246
247 return c.html(
248 <Layout title={`${session.title} — Claude`} user={user}>
249 <main style={wrap}>
250 <p style="margin:0 0 6px">
251 <a href={`/${g.ownerName}/${g.repoName}/claude`} style="color:#9ca3af;text-decoration:none">
252 ← Claude sessions
253 </a>
254 </p>
255 <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
256 <h1 style="margin:0;font-size:20px">{session.title}</h1>
257 <span style="color:#6b7280;font-size:12px;font-family:ui-monospace,monospace">
258 {session.branch} · {session.status}
259 </span>
260 </div>
261 <style dangerouslySetInnerHTML={{ __html: styleCss }} />
262
263 <div id="cw-messages" style="margin-bottom:14px">
264 {messages.length === 0 ? (
265 <p style="color:#6b7280;font-size:14px">No turns yet. Ask Claude something below.</p>
266 ) : (
267 messages.map((m) => (
268 <div
269 class={"cw-msg cw-msg-" + m.role}
270 data-role={m.role}
271 >
272 {m.body}
273 </div>
274 ))
275 )}
276 </div>
277
278 <form id="cw-composer" style={card}>
279 <textarea
280 id="cw-prompt"
281 name="prompt"
282 placeholder="Ask Claude..."
283 rows={3}
284 style={inputStyle}
285 />
286 <div style="margin-top:8px;display:flex;justify-content:space-between;align-items:center">
287 <span style="color:#6b7280;font-size:12px">
288 Streams over SSE. Long turns cap at 5 minutes.
289 </span>
290 <button type="submit" style={btn}>Send</button>
291 </div>
292 </form>
293
294 <form
295 method="post"
296 action={`/${g.ownerName}/${g.repoName}/claude/${session.id}/delete`}
297 style="text-align:right;margin-top:12px"
298 onsubmit="return confirm('Delete this session and its transcript?')"
299 >
300 <button type="submit" style="background:#a02020;border:0;color:#fff;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px">
301 Delete session
302 </button>
303 </form>
304
305 <script dangerouslySetInnerHTML={{ __html: clientJs }} />
306 </main>
307 </Layout>
308 );
309});
310
311// ─── GET /:owner/:repo/claude/:sessionId/stream ─────────────────────────────
312
313claudeWeb.get("/:owner/:repo/claude/:sessionId/stream", async (c) => {
314 const g = await gate(c);
315 if (g instanceof Response) return g;
fc623bfccantynz-alt316 const sessionId = parseIdUuid(c.req.param("sessionId"));
317 if (!sessionId) return c.notFound();
90c7531Claude318 const session = await getSession(sessionId, g.userId);
319 if (!session || session.repositoryId !== g.repoId) return c.notFound();
320 const prompt = (c.req.query("prompt") || "").slice(0, 16_000);
321 if (!prompt.trim()) {
322 return c.text("missing prompt", 400);
323 }
324
325 // Persist the user turn *before* we start the stream so a network blip
326 // doesn't lose the question.
327 await appendMessage({ sessionId, role: "user", body: prompt });
328 await touchSession({ sessionId, status: "running" });
329
330 // Lazily clone the workdir on first turn.
331 const workdir = await ensureWorkdir(session, g.ownerName, g.repoName);
332 if (!workdir.ok) {
333 await appendMessage({
334 sessionId,
335 role: "system",
336 body: `workdir error: ${workdir.error}`,
337 });
338 await touchSession({ sessionId, status: "failed" });
339 return c.text(`workdir: ${workdir.error}`, 500);
340 }
341
342 const stream = new ReadableStream<Uint8Array>({
343 async start(controller) {
344 const enc = new TextEncoder();
345 let assistantBody = "";
346 let finalExit = 0;
347 let finalDuration = 0;
348 let finalClaudeId: string | undefined;
349
350 function send(event: string, data: string) {
351 controller.enqueue(enc.encode(`event: ${event}\n`));
352 // Split data on newlines so we never break the SSE wire format.
353 for (const line of data.split("\n")) {
354 controller.enqueue(enc.encode(`data: ${line}\n`));
355 }
356 controller.enqueue(enc.encode(`\n`));
357 }
358
359 try {
360 for await (const ev of runTurn({
361 session,
362 ownerName: g.ownerName,
363 repoName: g.repoName,
364 prompt,
365 })) {
366 if (ev.chunk) {
367 assistantBody += ev.chunk;
368 send("chunk", ev.chunk);
369 }
370 if (ev.done) {
371 finalExit = ev.done.exitCode;
372 finalDuration = ev.done.durationMs;
373 finalClaudeId = ev.done.claudeSessionId;
374 if (ev.done.stderr && ev.done.exitCode !== 0) {
375 assistantBody += `\n[stderr] ${ev.done.stderr}`;
376 send("chunk", `\n[stderr] ${ev.done.stderr}`);
377 }
378 send("done", String(ev.done.exitCode));
379 }
380 }
381 } catch (err) {
382 const msg = err instanceof Error ? err.message : String(err);
383 assistantBody += `\n[exception] ${msg}`;
384 send("error", msg);
385 } finally {
386 try {
387 await appendMessage({
388 sessionId,
389 role: "assistant",
390 body: assistantBody,
391 exitCode: finalExit,
392 durationMs: finalDuration,
393 });
394 await touchSession({
395 sessionId,
396 claudeSessionId: finalClaudeId,
397 status: finalExit === 0 ? "ready" : "failed",
398 });
399 } catch {
400 /* persistence errors don't break the response */
401 }
402 controller.close();
403 }
404 },
405 });
406
407 return new Response(stream, {
408 headers: {
409 "Content-Type": "text/event-stream; charset=utf-8",
410 "Cache-Control": "no-cache, no-transform",
411 "Connection": "keep-alive",
412 "X-Accel-Buffering": "no",
413 },
414 });
415});
416
417// ─── POST /:owner/:repo/claude/:sessionId/delete ────────────────────────────
418
419claudeWeb.post("/:owner/:repo/claude/:sessionId/delete", async (c) => {
420 const g = await gate(c);
421 if (g instanceof Response) return g;
fc623bfccantynz-alt422 const sessionId = parseIdUuid(c.req.param("sessionId"));
423 if (!sessionId) return c.notFound();
90c7531Claude424 const session = await getSession(sessionId, g.userId);
425 if (!session || session.repositoryId !== g.repoId) return c.notFound();
426 await deleteSession(sessionId);
427 return c.redirect(`/${g.ownerName}/${g.repoName}/claude`);
428});
429
430export default claudeWeb;