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