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

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