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