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

ai-workspace.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.

ai-workspace.tsxBlame545 lines · 2 contributors
77cf834Claude1/**
2 * AI Copilot Workspace routes.
3 *
4 * GET /:owner/:repo/issues/:number/workspace — status page
5 * POST /:owner/:repo/issues/:number/workspace/start — start job
6 */
7
8import { Hono } from "hono";
fc623bfccantynz-alt9import { parseIdNumber } from "../lib/route-params";
77cf834Claude10import { eq, and } from "drizzle-orm";
11import { db } from "../db";
12import { issues, repositories, users } from "../db/schema";
13import { Layout } from "../views/layout";
14import { softAuth, requireAuth } from "../middleware/auth";
15import { requireRepoAccess } from "../middleware/repo-access";
16import type { AuthEnv } from "../middleware/auth";
17import {
18 startWorkspace,
19 getWorkspaceJobForIssue,
20 type WorkspaceJob,
21 type WorkspaceStatus,
22} from "../lib/ai-workspace";
23import { isAiAvailable } from "../lib/ai-client";
24
25export const workspaceRoutes = new Hono<AuthEnv>();
26
27// ---------------------------------------------------------------------------
28// Styles
29// ---------------------------------------------------------------------------
30
31const wsStyles = `
32 .ws-hero {
33 position: relative;
34 margin: 4px 0 24px;
35 padding: 28px 32px;
36 background: var(--bg-elevated);
37 border: 1px solid var(--border);
38 border-radius: 16px;
39 overflow: hidden;
40 }
41 .ws-hero::before {
42 content: '';
43 position: absolute;
44 top: 0; left: 0; right: 0;
45 height: 2px;
6fd5915Claude46 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
77cf834Claude47 opacity: 0.7;
48 pointer-events: none;
49 }
50 .ws-title {
51 font-family: var(--font-display);
52 font-size: clamp(22px, 3vw, 30px);
53 font-weight: 700;
54 letter-spacing: -0.022em;
55 color: var(--text-strong);
56 margin: 0 0 8px;
57 }
58 .ws-subtitle {
59 font-size: 15px;
60 color: var(--text-muted);
61 margin: 0;
62 line-height: 1.5;
63 }
64 .ws-stepper {
65 display: flex;
66 flex-direction: column;
67 gap: 0;
68 margin: 28px 0;
69 }
70 .ws-step {
71 display: flex;
72 align-items: flex-start;
73 gap: 14px;
74 padding: 14px 0;
75 border-left: 2px solid var(--border);
76 padding-left: 20px;
77 position: relative;
78 }
79 .ws-step:last-child {
80 border-left: 2px solid transparent;
81 }
82 .ws-step-dot {
83 position: absolute;
84 left: -7px;
85 top: 18px;
86 width: 12px;
87 height: 12px;
88 border-radius: 50%;
89 background: var(--border);
90 border: 2px solid var(--bg);
91 flex-shrink: 0;
92 transition: background 200ms;
93 }
94 .ws-step.is-done .ws-step-dot {
e589f77ccantynz-alt95 background: var(--green);
77cf834Claude96 }
97 .ws-step.is-active .ws-step-dot {
e589f77ccantynz-alt98 background: var(--accent);
6fd5915Claude99 box-shadow: 0 0 0 4px rgba(91,110,232,0.22);
77cf834Claude100 animation: ws-pulse 1.4s ease-in-out infinite;
101 }
102 .ws-step.is-failed .ws-step-dot {
e589f77ccantynz-alt103 background: var(--red);
77cf834Claude104 }
105 @keyframes ws-pulse {
6fd5915Claude106 0%, 100% { box-shadow: 0 0 0 4px rgba(91,110,232,0.22); }
107 50% { box-shadow: 0 0 0 7px rgba(91,110,232,0.10); }
77cf834Claude108 }
109 .ws-step-label {
110 font-size: 14px;
111 font-weight: 600;
112 color: var(--text-muted);
113 line-height: 1.4;
114 padding-top: 1px;
115 }
e589f77ccantynz-alt116 .ws-step.is-done .ws-step-label { color: var(--green); }
77cf834Claude117 .ws-step.is-active .ws-step-label { color: var(--text-strong); }
e589f77ccantynz-alt118 .ws-step.is-failed .ws-step-label { color: var(--red); }
77cf834Claude119 .ws-step-desc {
120 font-size: 12.5px;
121 color: var(--text-muted);
122 margin-top: 2px;
123 }
124 .ws-result {
125 margin-top: 18px;
126 padding: 16px 20px;
127 border-radius: 12px;
128 font-size: 14px;
129 line-height: 1.55;
130 }
131 .ws-result.is-done {
132 background: rgba(52,211,153,0.08);
133 border: 1px solid rgba(52,211,153,0.3);
134 color: var(--text);
135 }
136 .ws-result.is-failed {
137 background: rgba(248,113,113,0.08);
138 border: 1px solid rgba(248,113,113,0.3);
139 color: var(--text);
140 }
141 .ws-pr-link {
142 display: inline-flex;
143 align-items: center;
144 gap: 6px;
145 margin-top: 12px;
146 padding: 9px 18px;
147 border-radius: 8px;
6fd5915Claude148 background: rgba(91,110,232,0.14);
149 border: 1px solid rgba(91,110,232,0.35);
77cf834Claude150 color: var(--accent);
151 font-weight: 600;
152 text-decoration: none;
153 font-size: 13.5px;
154 transition: background 120ms;
155 }
6fd5915Claude156 .ws-pr-link:hover { background: rgba(91,110,232,0.22); text-decoration: none; }
77cf834Claude157 .ws-start-btn {
158 display: inline-flex;
159 align-items: center;
160 gap: 8px;
161 padding: 10px 22px;
162 border-radius: 8px;
6fd5915Claude163 background: rgba(91,110,232,0.14);
164 border: 1px solid rgba(91,110,232,0.35);
77cf834Claude165 color: var(--accent);
166 font-weight: 700;
167 font-size: 14px;
168 cursor: pointer;
169 transition: background 120ms;
170 }
6fd5915Claude171 .ws-start-btn:hover { background: rgba(91,110,232,0.22); }
77cf834Claude172 .ws-explain {
173 margin-top: 18px;
174 padding: 14px 18px;
175 border-radius: 10px;
176 background: var(--bg-secondary);
177 border: 1px solid var(--border);
178 font-size: 13.5px;
179 color: var(--text-muted);
180 line-height: 1.6;
181 }
182 .ws-explain ul {
183 margin: 8px 0 0 18px;
184 padding: 0;
185 }
186 .ws-explain li { margin: 4px 0; }
187 .ws-back-link {
188 display: inline-flex;
189 align-items: center;
190 gap: 6px;
191 font-size: 13px;
192 color: var(--text-muted);
193 text-decoration: none;
194 margin-bottom: 18px;
195 }
196 .ws-back-link:hover { color: var(--text); text-decoration: none; }
197 .ws-badge {
198 display: inline-block;
199 padding: 2px 9px;
200 border-radius: 9999px;
201 font-size: 11px;
202 font-weight: 700;
203 letter-spacing: 0.03em;
204 text-transform: uppercase;
205 }
206 .ws-badge-active {
6fd5915Claude207 background: rgba(91,110,232,0.15);
77cf834Claude208 color: #a78bfa;
6fd5915Claude209 border: 1px solid rgba(91,110,232,0.3);
77cf834Claude210 }
211 .ws-badge-done {
212 background: rgba(52,211,153,0.12);
e589f77ccantynz-alt213 color: var(--green);
77cf834Claude214 border: 1px solid rgba(52,211,153,0.3);
215 }
216 .ws-badge-failed {
217 background: rgba(248,113,113,0.12);
e589f77ccantynz-alt218 color: var(--red);
77cf834Claude219 border: 1px solid rgba(248,113,113,0.3);
220 }
221`;
222
223// ---------------------------------------------------------------------------
224// Step definitions
225// ---------------------------------------------------------------------------
226
227const STEPS: Array<{ key: WorkspaceStatus; label: string; desc: string }> = [
228 { key: "planning", label: "Planning", desc: "Reading the issue, exploring the codebase, generating a plan" },
229 { key: "implementing", label: "Implementing", desc: "Creating a branch and applying file changes" },
230 { key: "opening_pr", label: "Opening PR", desc: "Pushing the branch and opening a draft pull request" },
231 { key: "done", label: "Done", desc: "PR is open and ready for review" },
232];
233
234const STATUS_ORDER: Record<WorkspaceStatus, number> = {
235 pending: -1,
236 planning: 0,
237 implementing: 1,
238 opening_pr: 2,
239 done: 3,
240 failed: -2,
241};
242
243function stepClass(stepStatus: WorkspaceStatus, currentStatus: WorkspaceStatus): string {
244 if (currentStatus === "failed") {
245 // All steps pending or last attempted step shows as failed — just show neutral
246 return "";
247 }
248 const stepOrder = STATUS_ORDER[stepStatus];
249 const curOrder = STATUS_ORDER[currentStatus];
250 if (stepOrder < curOrder) return "is-done";
251 if (stepOrder === curOrder) return "is-active";
252 return "";
253}
254
255// ---------------------------------------------------------------------------
256// Helpers
257// ---------------------------------------------------------------------------
258
259async function resolveIssueAndRepo(
260 ownerName: string,
261 repoName: string,
262 issueNum: number
263) {
264 const [ownerRow] = await db
265 .select({ id: users.id })
266 .from(users)
267 .where(eq(users.username, ownerName))
268 .limit(1);
269 if (!ownerRow) return null;
270
271 const [repoRow] = await db
272 .select({ id: repositories.id, name: repositories.name, isPrivate: repositories.isPrivate })
273 .from(repositories)
274 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repoName)))
275 .limit(1);
276 if (!repoRow) return null;
277
278 const [issueRow] = await db
279 .select({ id: issues.id, number: issues.number, title: issues.title, state: issues.state })
280 .from(issues)
281 .where(and(eq(issues.repositoryId, repoRow.id), eq(issues.number, issueNum)))
282 .limit(1);
283 if (!issueRow) return null;
284
285 return { owner: ownerRow, repo: repoRow, issue: issueRow };
286}
287
288// ---------------------------------------------------------------------------
289// WorkspaceStatusPage JSX component
290// ---------------------------------------------------------------------------
291
292function WorkspaceStatusPage({
293 owner,
294 repoName,
295 issue,
296 job,
297 user,
298}: {
299 owner: string;
300 repoName: string;
301 issue: { number: number; title: string };
302 job: WorkspaceJob | undefined;
303 user: { username: string } | null | undefined;
304}) {
305 const isActive =
306 job &&
307 ["pending", "planning", "implementing", "opening_pr"].includes(job.status);
308 const isDone = job?.status === "done";
309 const isFailed = job?.status === "failed";
310 const hasJob = !!job;
311
312 const issueUrl = `/${owner}/${repoName}/issues/${issue.number}`;
313 const prUrl = isDone && job.prNumber
314 ? `/${owner}/${repoName}/pulls/${job.prNumber}`
315 : null;
316 const branchUrl = job?.branchName
317 ? `/${owner}/${repoName}/tree/${job.branchName}`
318 : null;
319
320 return (
321 <Layout title={`AI Workspace — #${issue.number}`} user={user as any}>
322 {isActive && (
323 <meta http-equiv="refresh" content="3" />
324 )}
325 <style dangerouslySetInnerHTML={{ __html: wsStyles }} />
326 <div style="max-width:800px;margin:0 auto;padding:24px 16px">
327 <a href={issueUrl} class="ws-back-link">
328 &larr; #{issue.number}: {issue.title}
329 </a>
330
331 <div class="ws-hero">
332 <h1 class="ws-title">
333 AI Copilot Workspace
334 {isActive && (
335 <span class="ws-badge ws-badge-active" style="margin-left:12px;vertical-align:middle">
336 Running
337 </span>
338 )}
339 {isDone && (
340 <span class="ws-badge ws-badge-done" style="margin-left:12px;vertical-align:middle">
341 Done
342 </span>
343 )}
344 {isFailed && (
345 <span class="ws-badge ws-badge-failed" style="margin-left:12px;vertical-align:middle">
346 Failed
347 </span>
348 )}
349 </h1>
350 <p class="ws-subtitle">
351 Autonomous issue-to-PR agent — reads the issue, explores the codebase,
352 proposes a plan, then opens a draft pull request.
353 </p>
354 </div>
355
356 {/* If no job, show Start button */}
357 {!hasJob && (
358 <div>
359 <form method="post" action={`/${owner}/${repoName}/issues/${issue.number}/workspace/start`}>
360 <button type="submit" class="ws-start-btn">
361 Start Workspace
362 </button>
363 </form>
364 <div class="ws-explain">
365 <strong>What Gluecron will do:</strong>
366 <ul>
367 <li>Read issue #{issue.number} and recent comments to understand the task</li>
368 <li>Explore the codebase — file tree + most relevant files (Claude-selected)</li>
369 <li>Generate an implementation plan and post it as an issue comment</li>
370 <li>Create a branch, implement the changes file-by-file</li>
371 <li>Open a draft PR linked to this issue</li>
372 </ul>
373 <p style="margin:12px 0 0">
374 This usually takes 60–180 seconds depending on codebase size.
375 The page auto-refreshes while running.
376 </p>
377 </div>
378 </div>
379 )}
380
381 {/* Stepper — shown when a job exists */}
382 {hasJob && (
383 <div>
384 <div class="ws-stepper">
385 {STEPS.map((step) => {
386 const cls = isFailed
387 ? ""
388 : stepClass(step.key, job!.status);
389 return (
390 <div class={`ws-step ${cls}`}>
391 <div class="ws-step-dot" />
392 <div>
393 <div class="ws-step-label">{step.label}</div>
394 <div class="ws-step-desc">{step.desc}</div>
395 </div>
396 </div>
397 );
398 })}
399 </div>
400
401 {/* Done state */}
402 {isDone && prUrl && (
403 <div class="ws-result is-done">
404 <strong>Workspace complete!</strong> A draft PR has been opened.
405 <br />
406 {job.planComment && (
407 <p style="margin-top:8px;font-size:13px;color:var(--text-muted)">
408 An implementation plan was posted as a comment on the issue.
409 </p>
410 )}
411 <a href={prUrl} class="ws-pr-link">
412 View Draft PR #{job.prNumber}
413 </a>
414 {branchUrl && (
415 <>
416 {" "}
417 <a href={branchUrl} class="ws-pr-link" style="margin-left:8px">
418 Browse Branch
419 </a>
420 </>
421 )}
422 </div>
423 )}
424
425 {/* Failed state */}
426 {isFailed && (
427 <div class="ws-result is-failed">
428 <strong>Workspace failed.</strong>
429 {job.errorMessage && (
430 <p style="margin:8px 0 0;font-family:var(--font-mono);font-size:12px;word-break:break-all">
431 {job.errorMessage}
432 </p>
433 )}
434 <form
435 method="post"
436 action={`/${owner}/${repoName}/issues/${issue.number}/workspace/start`}
437 style="margin-top:14px"
438 >
439 <button type="submit" class="ws-start-btn">
440 Retry Workspace
441 </button>
442 </form>
443 </div>
444 )}
445
446 {/* Active state hint */}
447 {isActive && (
448 <p style="font-size:13px;color:var(--text-muted);margin-top:12px">
449 This page refreshes every 3 seconds. You can leave and come back.
450 </p>
451 )}
452 </div>
453 )}
454 </div>
455 </Layout>
456 );
457}
458
459// ---------------------------------------------------------------------------
460// GET /:owner/:repo/issues/:number/workspace
461// ---------------------------------------------------------------------------
462
463workspaceRoutes.get(
464 "/:owner/:repo/issues/:number/workspace",
465 softAuth,
466 requireAuth,
467 requireRepoAccess("read"),
468 async (c) => {
469 const { owner, repo } = c.req.param();
fc623bfccantynz-alt470 const issueNum = (parseIdNumber(c.req.param("number")) ?? -1);
77cf834Claude471 const user = c.get("user");
472
473 const resolved = await resolveIssueAndRepo(owner, repo, issueNum);
474 if (!resolved) {
475 return c.html(
476 <Layout title="Not Found" user={user}>
477 <div style="padding:40px;text-align:center;color:var(--text-muted)">Issue not found.</div>
478 </Layout>,
479 404
480 );
481 }
482
483 const job = getWorkspaceJobForIssue(resolved.issue.id);
484
485 return c.html(
486 <WorkspaceStatusPage
487 owner={owner}
488 repoName={repo}
489 issue={{ number: resolved.issue.number, title: resolved.issue.title }}
490 job={job}
491 user={user}
492 />
493 );
494 }
495);
496
497// ---------------------------------------------------------------------------
498// POST /:owner/:repo/issues/:number/workspace/start
499// ---------------------------------------------------------------------------
500
501workspaceRoutes.post(
502 "/:owner/:repo/issues/:number/workspace/start",
503 softAuth,
504 requireAuth,
505 requireRepoAccess("write"),
506 async (c) => {
507 const { owner, repo } = c.req.param();
fc623bfccantynz-alt508 const issueNum = (parseIdNumber(c.req.param("number")) ?? -1);
77cf834Claude509 const user = c.get("user")!;
510
511 if (!isAiAvailable()) {
512 return c.html(
513 <Layout title="AI unavailable" user={user}>
514 <div style="padding:40px;text-align:center;color:var(--text-muted)">
515 ANTHROPIC_API_KEY is not configured. AI Workspace requires the AI features to be enabled.
516 </div>
517 </Layout>,
518 503
519 );
520 }
521
522 const resolved = await resolveIssueAndRepo(owner, repo, issueNum);
523 if (!resolved) {
524 return c.html(
525 <Layout title="Not Found" user={user}>
526 <div style="padding:40px;text-align:center;color:var(--text-muted)">Issue not found.</div>
527 </Layout>,
528 404
529 );
530 }
531
532 await startWorkspace(
533 resolved.issue.id,
534 resolved.issue.number,
535 resolved.repo.id,
536 owner,
537 repo,
538 user.id
539 );
540
541 return c.redirect(`/${owner}/${repo}/issues/${issueNum}/workspace`);
542 }
543);
544
545export default workspaceRoutes;