Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

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