Commit2c61840unknown_key
feat: close 5 competitive gaps vs GitHub
feat: close 5 competitive gaps vs GitHub
1. AI-generated PR detection badge
· Runtime detection from PR body markers (🤖, Co-Authored-By: Claude,
Claude-Session, gluecron:ai-generated) and branch prefixes (claude/,
copilot/, ai/, bot/)
· ⚡ AI badge on both PR list rows and PR detail header
· No DB migration required — pattern-matched at render time
2. Unified CI/CD pipeline timeline (/:owner/:repo/pipeline)
· New route combining gate_runs + deployments chronologically (7-day window)
· Color-coded dots, AI-repair badges, duration, PR links, blocked reasons
· Auto-refreshes every 15s while events are in-flight
· Summary bar: gate failures, AI-repaired count, deploy issues
· Added "Pipeline" tab to repo nav
3. Sub-issues / epics hierarchy
· DB migration 0107: nullable self-referencing parentIssueId on issues
· Issue list: 📌 Epic badge with child count; ↳ #N child badge
· Issue detail: "Part of epic" breadcrumb + "Sub-issues" panel
· Sidebar form (owner-only): set/clear parent epic by issue number
· POST /:owner/:repo/issues/:number/set-parent endpoint
4. Mobile-responsive improvements
· Nav collapses at 640px: hides search, nav links, AI dropdown
· main element padding reduces to 20px/14px at 768px/480px
· box-sizing: border-box on main to prevent overflow
5. AI Workspace hub (/:owner/:repo/workspace)
· Single entry point for all AI coding modes: spec-to-PR, fix-an-issue,
web editor, generate-tests
· Open issues listed with "✨ Fix with AI" launch buttons for owners
· Usage tips panel
· Added "✨ Workspace" tab to repo nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFjwnPstUAEMzxmM4DrMiQ9 files changed+1176−02c61840b2727e6a306b63e42c272ed7e3f69ef83
9 changed files+1176−0
Addeddrizzle/0107_sub_issues.sql+11−0View fileUnifiedSplit
@@ -0,0 +1,11 @@
1-- Migration 0107: Sub-issues / epics hierarchy
2-- Adds a nullable self-referencing foreign key to `issues` so any issue
3-- can be a child of another issue (the parent becomes an "epic").
4
5ALTER TABLE issues
6 ADD COLUMN parent_issue_id uuid
7 REFERENCES issues(id)
8 ON DELETE SET NULL;
9
10CREATE INDEX issues_parent ON issues (parent_issue_id)
11 WHERE parent_issue_id IS NOT NULL;
Modifiedsrc/app.tsx+5−0View fileUnifiedSplit
@@ -96,6 +96,7 @@ import auditRoutes from "./routes/audit";
9696import reactionRoutes from "./routes/reactions";
9797import savedReplyRoutes from "./routes/saved-replies";
9898import deploymentRoutes from "./routes/deployments";
99import pipelineRoutes from "./routes/pipeline";
99100import orgRoutes from "./routes/orgs";
100101import notificationRoutes from "./routes/notifications";
101102import onboardingRoutes from "./routes/onboarding";
@@ -208,6 +209,7 @@ import developerProgramRoutes from "./routes/developer-program";
208209import shareRoutes from "./routes/share";
209210import incidentHookRoutes from "./routes/incident-hooks";
210211import workspaceRoutes from "./routes/ai-workspace";
212import workspaceHubRoutes from "./routes/workspace-hub";
211213import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
212214import { csrfToken, csrfProtect } from "./middleware/csrf";
213215import { noCache } from "./middleware/no-cache";
@@ -487,6 +489,8 @@ app.route("/", savedReplyRoutes);
487489
488490// Environments + deployment history UI
489491app.route("/", deploymentRoutes);
492// Unified CI/CD pipeline timeline
493app.route("/", pipelineRoutes);
490494
491495// Organizations + teams (Block B1)
492496app.route("/", orgRoutes);
@@ -535,6 +539,7 @@ app.route("/", shipAgentRoutes);
535539
536540// AI Copilot Workspace — issue-to-PR autonomous agent
537541app.route("/", workspaceRoutes);
542app.route("/", workspaceHubRoutes);
538543
539544// Comment moderation queue — owner-only `/:owner/:repo/comments/pending`
540545// + per-row approve/reject/spam actions. Mounted before `pullRoutes` so
Modifiedsrc/db/schema.ts+6−0View fileUnifiedSplit
@@ -658,6 +658,11 @@ export const issues = pgTable(
658658 milestoneId: uuid("milestone_id").references(() => milestones.id, {
659659 onDelete: "set null",
660660 }),
661 // Migration 0107 — sub-issues / epics. Nullable self-reference; when
662 // set, this issue is a child of the referenced epic issue.
663 parentIssueId: uuid("parent_issue_id").references((): any => issues.id, {
664 onDelete: "set null",
665 }),
661666 createdAt: timestamp("created_at").defaultNow().notNull(),
662667 updatedAt: timestamp("updated_at").defaultNow().notNull(),
663668 closedAt: timestamp("closed_at"),
@@ -666,6 +671,7 @@ export const issues = pgTable(
666671 index("issues_repo_state").on(table.repositoryId, table.state),
667672 index("issues_repo_number").on(table.repositoryId, table.number),
668673 index("issues_milestone").on(table.milestoneId),
674 index("issues_parent").on(table.parentIssueId),
669675 ]
670676);
671677
Modifiedsrc/routes/issues.tsx+257−0View fileUnifiedSplit
@@ -480,6 +480,74 @@ const issuesStyles = `
480480 border: 1px solid rgba(182,157,255,0.35);
481481 }
482482 .issues-detail-spacer { flex: 1; }
483
484 /* Sub-issues / epics */
485 .issues-epic-crumb {
486 display: inline-flex;
487 align-items: center;
488 gap: 6px;
489 font-size: 12px;
490 color: var(--text-muted);
491 margin-bottom: var(--space-2);
492 }
493 .issues-epic-crumb a { color: var(--accent); text-decoration: none; }
494 .issues-epic-crumb a:hover { text-decoration: underline; }
495 .issues-sub-panel {
496 background: var(--bg-elevated);
497 border: 1px solid var(--border);
498 border-radius: 12px;
499 margin-bottom: var(--space-4);
500 overflow: hidden;
501 }
502 .issues-sub-panel-head {
503 display: flex;
504 align-items: center;
505 justify-content: space-between;
506 padding: var(--space-3) var(--space-4);
507 border-bottom: 1px solid var(--border);
508 font-size: 12px;
509 font-weight: 700;
510 text-transform: uppercase;
511 letter-spacing: 0.07em;
512 color: var(--text-muted);
513 }
514 .issues-sub-row {
515 display: flex;
516 align-items: center;
517 gap: var(--space-3);
518 padding: var(--space-2) var(--space-4);
519 font-size: 13px;
520 transition: background 120ms;
521 }
522 .issues-sub-row:hover { background: var(--bg-hover); }
523 .issues-sub-row-icon { font-size: 12px; flex-shrink: 0; }
524 .issues-sub-row-icon.is-open { color: #3fb950; }
525 .issues-sub-row-icon.is-closed { color: #5b6ee8; }
526 .issues-sub-row-title { flex: 1; min-width: 0; }
527 .issues-sub-row-title a { color: var(--text); text-decoration: none; font-weight: 500; }
528 .issues-sub-row-title a:hover { color: var(--accent); text-decoration: underline; }
529 .issues-sub-row-num { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
530 .issues-sub-row-author { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
531 /* Epic pill on the issue list */
532 .issues-tag-epic {
533 display: inline-flex; align-items: center; gap: 4px;
534 padding: 2px 7px;
535 font-size: 11px; font-weight: 600;
536 border-radius: 9999px;
537 background: rgba(210,153,34,0.12);
538 color: #d29922;
539 border: 1px solid rgba(210,153,34,0.35);
540 }
541 .issues-tag-child {
542 display: inline-flex; align-items: center; gap: 4px;
543 padding: 2px 7px;
544 font-size: 11px; font-weight: 600;
545 border-radius: 9999px;
546 background: rgba(88,166,255,0.10);
547 color: #58a6ff;
548 border: 1px solid rgba(88,166,255,0.25);
549 }
550
483551 .issues-detail-labels {
484552 margin-top: 12px;
485553 display: flex;
@@ -1024,6 +1092,49 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), asy
10241092 .limit(perPage)
10251093 .offset(offset);
10261094
1095 // Batch-load sub-issue counts for epics in this list page
1096 const issueIds = issueList.map((r) => r.issue.id);
1097 const subCountMap = new Map<string, number>();
1098 const parentNumberMap = new Map<string, number>(); // childIssueId → parentIssueNumber
1099 if (issueIds.length > 0) {
1100 const [subRows, parentRows] = await Promise.all([
1101 // Count children per parent (to show "N sub-issues" on epics)
1102 db
1103 .select({
1104 parentId: issues.parentIssueId,
1105 cnt: sql<number>`count(*)::int`,
1106 })
1107 .from(issues)
1108 .where(
1109 and(
1110 inArray(issues.parentIssueId, issueIds),
1111 eq(issues.repositoryId, repo.id)
1112 )
1113 )
1114 .groupBy(issues.parentIssueId),
1115 // Resolve parent numbers for children (to show "Part of #N")
1116 issueList
1117 .filter((r) => r.issue.parentIssueId !== null)
1118 .length > 0
1119 ? db
1120 .select({ id: issues.id, number: issues.number })
1121 .from(issues)
1122 .where(
1123 inArray(
1124 issues.id,
1125 issueList
1126 .filter((r) => r.issue.parentIssueId !== null)
1127 .map((r) => r.issue.parentIssueId as string)
1128 )
1129 )
1130 : Promise.resolve([]),
1131 ]);
1132 subRows.forEach((r) => {
1133 if (r.parentId) subCountMap.set(r.parentId, Number(r.cnt));
1134 });
1135 parentRows.forEach((p) => parentNumberMap.set(p.id, p.number));
1136 }
1137
10271138 // Count open/closed
10281139 const [counts] = await db
10291140 .select({
@@ -1228,6 +1339,16 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), asy
12281339 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
12291340 {issue.title}
12301341 </a>
1342 {subCountMap.has(issue.id) && (
1343 <span class="issues-tag-epic" title={`Epic with ${subCountMap.get(issue.id)} sub-issue${subCountMap.get(issue.id) === 1 ? "" : "s"}`}>
1344 📌 Epic · {subCountMap.get(issue.id)}
1345 </span>
1346 )}
1347 {issue.parentIssueId && parentNumberMap.has(issue.parentIssueId) && (
1348 <span class="issues-tag-child" title={`Sub-issue of #${parentNumberMap.get(issue.parentIssueId)}`}>
1349 ↳ #{parentNumberMap.get(issue.parentIssueId)}
1350 </span>
1351 )}
12311352 </h3>
12321353 <div class="issues-row-meta">
12331354 #{issue.number} opened by{" "}
@@ -1517,6 +1638,31 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
15171638 ? await countPendingForRepo(resolved.repo.id)
15181639 : 0;
15191640
1641 // Sub-issues / epics — load parent issue (if this is a child) and
1642 // any child issues (if this is an epic), in parallel with reactions.
1643 const [parentIssueRow, subIssueRows] = await Promise.all([
1644 issue.parentIssueId
1645 ? db
1646 .select({ id: issues.id, number: issues.number, title: issues.title, state: issues.state })
1647 .from(issues)
1648 .where(eq(issues.id, issue.parentIssueId))
1649 .limit(1)
1650 .then((r) => r[0] ?? null)
1651 : Promise.resolve(null),
1652 db
1653 .select({
1654 id: issues.id,
1655 number: issues.number,
1656 title: issues.title,
1657 state: issues.state,
1658 authorUsername: users.username,
1659 })
1660 .from(issues)
1661 .innerJoin(users, eq(issues.authorId, users.id))
1662 .where(and(eq(issues.parentIssueId, issue.id), eq(issues.repositoryId, resolved.repo.id)))
1663 .orderBy(asc(issues.number)),
1664 ]);
1665
15201666 // Load reactions for the issue + each comment in parallel.
15211667 const [issueReactions, ...commentReactions] = await Promise.all([
15221668 summariseReactions("issue", issue.id, user?.id),
@@ -1611,6 +1757,14 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
16111757 )}
16121758
16131759 <section class="issues-detail-hero">
1760 {parentIssueRow && (
1761 <div class="issues-epic-crumb">
1762 <span>📌 Part of epic</span>
1763 <a href={`/${ownerName}/${repoName}/issues/${parentIssueRow.number}`}>
1764 #{parentIssueRow.number} — {parentIssueRow.title}
1765 </a>
1766 </div>
1767 )}
16141768 <h1 class="issues-detail-title">
16151769 {issue.title}
16161770 <span class="issues-detail-number">#{issue.number}</span>
@@ -1690,6 +1844,30 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
16901844
16911845 <div class="issue-detail-layout">
16921846 <div class="issue-detail-main">
1847
1848 {subIssueRows.length > 0 && (
1849 <div class="issues-sub-panel">
1850 <div class="issues-sub-panel-head">
1851 <span>Sub-issues</span>
1852 <span style="font-size:11px;background:var(--bg-inset);border:1px solid var(--border);border-radius:10px;padding:1px 8px;font-weight:500;letter-spacing:0">
1853 {subIssueRows.filter((s) => s.state === "open").length}/{subIssueRows.length} open
1854 </span>
1855 </div>
1856 {subIssueRows.map((sub) => (
1857 <div class="issues-sub-row" key={sub.id}>
1858 <span class={`issues-sub-row-icon ${sub.state === "open" ? "is-open" : "is-closed"}`}>
1859 {sub.state === "open" ? "○" : "✓"}
1860 </span>
1861 <span class="issues-sub-row-title">
1862 <a href={`/${ownerName}/${repoName}/issues/${sub.number}`}>{sub.title}</a>
1863 </span>
1864 <span class="issues-sub-row-author">{sub.authorUsername}</span>
1865 <span class="issues-sub-row-num">#{sub.number}</span>
1866 </div>
1867 ))}
1868 </div>
1869 )}
1870
16931871 <div class="issues-thread">
16941872 {issue.body && (
16951873 <article class="issues-comment">
@@ -1871,6 +2049,37 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
18712049 <span class="issue-meta-empty">No milestone</span>
18722050 )}
18732051 </div>
2052 {/* Epic / parent issue */}
2053 {viewerIsOwner && (
2054 <div class="issue-meta-section">
2055 <div class="issue-meta-label">Epic (parent issue)</div>
2056 {parentIssueRow ? (
2057 <div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px">
2058 <a href={`/${ownerName}/${repoName}/issues/${parentIssueRow.number}`} style="color:var(--accent);text-decoration:none">
2059 #{parentIssueRow.number} {parentIssueRow.title.slice(0, 40)}{parentIssueRow.title.length > 40 ? "…" : ""}
2060 </a>
2061 </div>
2062 ) : (
2063 <span class="issue-meta-empty">None</span>
2064 )}
2065 <form
2066 method="post"
2067 action={`/${ownerName}/${repoName}/issues/${issue.number}/set-parent`}
2068 style="display:flex;gap:6px;margin-top:6px"
2069 >
2070 <input
2071 type="number"
2072 name="parent_number"
2073 placeholder={parentIssueRow ? "Clear (enter 0)" : "Issue #"}
2074 style="width:100%;padding:4px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-inset);color:var(--text)"
2075 min="0"
2076 />
2077 <button type="submit" class="btn" style="font-size:12px;padding:4px 10px;white-space:nowrap">
2078 Set
2079 </button>
2080 </form>
2081 </div>
2082 )}
18742083 </aside>
18752084 </div>{/* /issue-detail-layout */}
18762085 </div>
@@ -2290,5 +2499,53 @@ issueRoutes.post(
22902499 }
22912500);
22922501
2502// POST /:owner/:repo/issues/:number/set-parent
2503// Sets or clears the parent (epic) for an issue.
2504issueRoutes.post(
2505 "/:owner/:repo/issues/:number/set-parent",
2506 softAuth,
2507 requireAuth,
2508 requireRepoAccess("write"),
2509 async (c) => {
2510 const { owner: ownerName, repo: repoName } = c.req.param();
2511 const issueNum = parseInt(c.req.param("number"), 10);
2512 const form = await c.req.formData();
2513 const parentNum = form.get("parent_number")?.toString().trim() ?? "";
2514
2515 const resolved = await resolveRepo(ownerName, repoName);
2516 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/issues`);
2517
2518 const [issue] = await db
2519 .select({ id: issues.id })
2520 .from(issues)
2521 .where(and(eq(issues.repositoryId, resolved.repo.id), eq(issues.number, issueNum)))
2522 .limit(1);
2523 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
2524
2525 // Clear parent
2526 if (!parentNum || parentNum === "0") {
2527 await db.update(issues).set({ parentIssueId: null }).where(eq(issues.id, issue.id));
2528 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Removed from epic.")}`);
2529 }
2530
2531 const pNum = parseInt(parentNum, 10);
2532 if (isNaN(pNum) || pNum === issueNum) {
2533 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent("Invalid parent issue number.")}`);
2534 }
2535
2536 const [parent] = await db
2537 .select({ id: issues.id })
2538 .from(issues)
2539 .where(and(eq(issues.repositoryId, resolved.repo.id), eq(issues.number, pNum)))
2540 .limit(1);
2541 if (!parent) {
2542 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Issue #${pNum} not found.`)}`);
2543 }
2544
2545 await db.update(issues).set({ parentIssueId: parent.id }).where(eq(issues.id, issue.id));
2546 return c.redirect(`/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Added to epic #${pNum}.`)}`);
2547 }
2548);
2549
22932550export default issueRoutes;
22942551export { IssueNav };
Addedsrc/routes/pipeline.tsx+496−0View fileUnifiedSplit
@@ -0,0 +1,496 @@
1/**
2 * Unified CI/CD Pipeline Timeline — /:owner/:repo/pipeline
3 *
4 * Single scrollable view combining gate runs and deployments for a repo,
5 * ordered chronologically. Operators can see the full story of a push —
6 * from gate check through to production deploy — without clicking between
7 * separate pages.
8 *
9 * Events shown:
10 * - Gate runs (status: passed/failed/running/skipped/repaired)
11 * - Cloud deployments (status: success/failed/blocked/running/pending)
12 *
13 * Refreshes every 15s while any event is in-flight.
14 */
15
16import { Hono } from "hono";
17import { and, desc, eq, gte, or } from "drizzle-orm";
18import { db } from "../db";
19import { deployments, gateRuns, pullRequests, repositories, users } from "../db/schema";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24
25const app = new Hono<AuthEnv>();
26app.use("/:owner/:repo/pipeline", softAuth);
27
28// ---------------------------------------------------------------------------
29// Helpers
30// ---------------------------------------------------------------------------
31
32function relTime(d: Date): string {
33 const sec = Math.floor((Date.now() - d.getTime()) / 1000);
34 if (sec < 60) return `${sec}s ago`;
35 if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
36 if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
37 return `${Math.floor(sec / 86400)}d ago`;
38}
39
40function durationStr(startMs: number, endMs?: number | null): string {
41 const ms = (endMs ?? Date.now()) - startMs;
42 if (ms < 1000) return `${ms}ms`;
43 if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
44 return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
45}
46
47type GateEvent = {
48 kind: "gate";
49 id: string;
50 createdAt: Date;
51 completedAt: Date | null;
52 status: string;
53 gateName: string;
54 commitSha: string;
55 ref: string;
56 prNumber: number | null;
57 repairAttempted: boolean | null;
58 repairSucceeded: boolean | null;
59 durationMs: number | null;
60};
61
62type DeployEvent = {
63 kind: "deploy";
64 id: string;
65 createdAt: Date;
66 completedAt: Date | null;
67 status: string;
68 environment: string;
69 ref: string;
70 commitSha: string;
71 blockedReason: string | null;
72};
73
74type TimelineEvent = GateEvent | DeployEvent;
75
76// ---------------------------------------------------------------------------
77// GET /:owner/:repo/pipeline
78// ---------------------------------------------------------------------------
79
80app.get("/:owner/:repo/pipeline", async (c) => {
81 const { owner, repo } = c.req.param();
82
83 const [repoRow] = await db
84 .select({ repo: repositories, owner: users })
85 .from(repositories)
86 .innerJoin(users, eq(repositories.ownerId, users.id))
87 .where(and(eq(users.username, owner), eq(repositories.name, repo)));
88
89 if (!repoRow) return c.notFound();
90
91 const authUser = c.get("user");
92 const isOwner = authUser?.id === repoRow.owner.id;
93 if (repoRow.repo.isPrivate && !isOwner) return c.notFound();
94
95 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // last 7 days
96
97 const [gateRows, deployRows] = await Promise.all([
98 db
99 .select({
100 id: gateRuns.id,
101 createdAt: gateRuns.createdAt,
102 completedAt: gateRuns.completedAt,
103 status: gateRuns.status,
104 gateName: gateRuns.gateName,
105 commitSha: gateRuns.commitSha,
106 ref: gateRuns.ref,
107 pullRequestId: gateRuns.pullRequestId,
108 repairAttempted: gateRuns.repairAttempted,
109 repairSucceeded: gateRuns.repairSucceeded,
110 durationMs: gateRuns.durationMs,
111 })
112 .from(gateRuns)
113 .where(and(eq(gateRuns.repositoryId, repoRow.repo.id), gte(gateRuns.createdAt, since)))
114 .orderBy(desc(gateRuns.createdAt))
115 .limit(100),
116 db
117 .select({
118 id: deployments.id,
119 createdAt: deployments.createdAt,
120 completedAt: deployments.completedAt,
121 status: deployments.status,
122 environment: deployments.environment,
123 ref: deployments.ref,
124 commitSha: deployments.commitSha,
125 blockedReason: deployments.blockedReason,
126 })
127 .from(deployments)
128 .where(and(eq(deployments.repositoryId, repoRow.repo.id), gte(deployments.createdAt, since)))
129 .orderBy(desc(deployments.createdAt))
130 .limit(50),
131 ]);
132
133 // Resolve PR numbers for gate rows that have a pullRequestId
134 const prIdSet = new Set(gateRows.map((g) => g.pullRequestId).filter(Boolean) as string[]);
135 const prNumberMap = new Map<string, number>();
136 if (prIdSet.size > 0) {
137 const prRows = await db
138 .select({ id: pullRequests.id, number: pullRequests.number })
139 .from(pullRequests)
140 .where(
141 or(...Array.from(prIdSet).map((id) => eq(pullRequests.id, id)))
142 );
143 prRows.forEach((p) => prNumberMap.set(p.id, p.number));
144 }
145
146 const events: TimelineEvent[] = [
147 ...gateRows.map((g): GateEvent => ({
148 kind: "gate",
149 id: g.id,
150 createdAt: g.createdAt,
151 completedAt: g.completedAt,
152 status: g.status,
153 gateName: g.gateName,
154 commitSha: g.commitSha,
155 ref: g.ref,
156 prNumber: g.pullRequestId ? (prNumberMap.get(g.pullRequestId) ?? null) : null,
157 repairAttempted: g.repairAttempted,
158 repairSucceeded: g.repairSucceeded,
159 durationMs: g.durationMs,
160 })),
161 ...deployRows.map((d): DeployEvent => ({
162 kind: "deploy",
163 id: d.id,
164 createdAt: d.createdAt,
165 completedAt: d.completedAt,
166 status: d.status,
167 environment: d.environment,
168 ref: d.ref,
169 commitSha: d.commitSha,
170 blockedReason: d.blockedReason,
171 })),
172 ].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
173
174 const hasInFlight = events.some(
175 (e) => e.status === "running" || e.status === "pending" || e.status === "waiting_timer"
176 );
177
178 // Summary counts
179 const gateTotal = gateRows.length;
180 const gateFailed = gateRows.filter((g) => g.status === "failed").length;
181 const gateRepaired = gateRows.filter((g) => g.repairSucceeded).length;
182 const deployTotal = deployRows.length;
183 const deployFailed = deployRows.filter((d) => d.status === "failed" || d.status === "blocked").length;
184
185 const styles = `
186 .pl-wrap { max-width: 920px; margin: 0 auto; padding: 0 var(--space-4); }
187
188 .pl-summary {
189 display: flex;
190 gap: var(--space-3);
191 flex-wrap: wrap;
192 margin-bottom: var(--space-5);
193 padding: var(--space-4);
194 background: var(--bg-elevated);
195 border: 1px solid var(--border);
196 border-radius: 12px;
197 }
198 .pl-summary-stat {
199 display: flex;
200 flex-direction: column;
201 align-items: center;
202 gap: 2px;
203 min-width: 80px;
204 padding: var(--space-2) var(--space-3);
205 background: var(--bg-inset);
206 border: 1px solid var(--border);
207 border-radius: 10px;
208 }
209 .pl-summary-val {
210 font-size: 22px;
211 font-weight: 700;
212 line-height: 1;
213 }
214 .pl-summary-val.ok { color: #3fb950; }
215 .pl-summary-val.err { color: #f85149; }
216 .pl-summary-val.warn { color: #d29922; }
217 .pl-summary-val.neutral { color: var(--text); }
218 .pl-summary-label { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
219
220 .pl-filter {
221 display: flex;
222 gap: var(--space-2);
223 flex-wrap: wrap;
224 margin-bottom: var(--space-4);
225 }
226 .pl-filter-btn {
227 padding: 5px 14px;
228 border: 1px solid var(--border);
229 border-radius: 20px;
230 background: var(--bg-inset);
231 color: var(--text-secondary);
232 font-size: 12px;
233 font-weight: 600;
234 cursor: pointer;
235 text-decoration: none;
236 transition: border-color 120ms, color 120ms;
237 }
238 .pl-filter-btn:hover, .pl-filter-btn.active {
239 border-color: var(--accent);
240 color: var(--accent);
241 }
242
243 .pl-timeline { position: relative; }
244 .pl-timeline::before {
245 content: '';
246 position: absolute;
247 left: 19px;
248 top: 0;
249 bottom: 0;
250 width: 2px;
251 background: var(--border);
252 }
253
254 .pl-event {
255 position: relative;
256 display: flex;
257 gap: var(--space-3);
258 padding: var(--space-3) 0;
259 }
260 .pl-event-dot {
261 position: relative;
262 z-index: 1;
263 width: 20px;
264 height: 20px;
265 flex-shrink: 0;
266 margin-top: 2px;
267 border-radius: 50%;
268 border: 2px solid var(--bg);
269 display: flex;
270 align-items: center;
271 justify-content: center;
272 font-size: 10px;
273 }
274 .pl-event-dot.gate-passed { background: #3fb950; }
275 .pl-event-dot.gate-failed { background: #f85149; }
276 .pl-event-dot.gate-running { background: #58a6ff; }
277 .pl-event-dot.gate-pending { background: var(--text-muted); }
278 .pl-event-dot.gate-skipped { background: var(--border-strong); }
279 .pl-event-dot.gate-repaired { background: #d29922; }
280 .pl-event-dot.deploy-success { background: #3fb950; }
281 .pl-event-dot.deploy-failed { background: #f85149; }
282 .pl-event-dot.deploy-blocked { background: #d29922; }
283 .pl-event-dot.deploy-running { background: #58a6ff; }
284 .pl-event-dot.deploy-pending { background: var(--text-muted); }
285
286 .pl-event-body {
287 flex: 1;
288 min-width: 0;
289 background: var(--bg-elevated);
290 border: 1px solid var(--border);
291 border-radius: 10px;
292 padding: var(--space-3) var(--space-4);
293 transition: border-color 120ms;
294 }
295 .pl-event-body:hover { border-color: var(--border-strong); }
296
297 .pl-event-top {
298 display: flex;
299 align-items: center;
300 gap: var(--space-2);
301 flex-wrap: wrap;
302 }
303 .pl-event-kind {
304 font-size: 10px;
305 font-weight: 700;
306 text-transform: uppercase;
307 letter-spacing: 0.08em;
308 padding: 1px 7px;
309 border-radius: 8px;
310 }
311 .pl-event-kind.gate { background: rgba(88,166,255,0.12); color: #58a6ff; }
312 .pl-event-kind.deploy { background: rgba(91,110,232,0.12); color: #8b9cf4; }
313 .pl-event-name { font-size: 14px; font-weight: 600; color: var(--text); flex: 1; }
314 .pl-event-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
315 .pl-event-status {
316 font-size: 11px;
317 font-weight: 600;
318 padding: 2px 8px;
319 border-radius: 8px;
320 }
321 .pl-status-passed { background: rgba(63,185,80,0.12); color: #3fb950; }
322 .pl-status-failed { background: rgba(248,81,73,0.12); color: #f85149; }
323 .pl-status-running { background: rgba(88,166,255,0.12); color: #58a6ff; }
324 .pl-status-pending { background: rgba(130,80,223,0.10); color: #9a7fde; }
325 .pl-status-skipped { background: var(--bg-inset); color: var(--text-muted); }
326 .pl-status-repaired { background: rgba(210,153,34,0.12); color: #d29922; }
327 .pl-status-success { background: rgba(63,185,80,0.12); color: #3fb950; }
328 .pl-status-blocked { background: rgba(210,153,34,0.12); color: #d29922; }
329
330 .pl-event-meta {
331 margin-top: var(--space-1);
332 font-size: 12px;
333 color: var(--text-muted);
334 display: flex;
335 gap: var(--space-3);
336 flex-wrap: wrap;
337 align-items: center;
338 }
339 .pl-event-meta a { color: var(--accent); text-decoration: none; }
340 .pl-event-meta a:hover { text-decoration: underline; }
341 .pl-sha {
342 font-family: var(--font-mono);
343 font-size: 11px;
344 background: var(--bg-inset);
345 padding: 1px 6px;
346 border-radius: 5px;
347 }
348 .pl-repair-badge {
349 font-size: 11px;
350 font-weight: 600;
351 padding: 1px 7px;
352 border-radius: 8px;
353 background: rgba(210,153,34,0.12);
354 color: #d29922;
355 }
356
357 .pl-empty {
358 text-align: center;
359 padding: var(--space-8) var(--space-4);
360 color: var(--text-muted);
361 font-size: 14px;
362 }
363
364 @media (max-width: 600px) {
365 .pl-wrap { padding: 0 var(--space-2); }
366 .pl-summary { gap: var(--space-2); }
367 .pl-event-body { padding: var(--space-2) var(--space-3); }
368 }
369 `;
370
371 const gateStatusCls = (s: string) => `pl-status-${s}`;
372 const deployStatusCls = (s: string) => {
373 if (s === "success" || s === "succeeded") return "pl-status-success";
374 return `pl-status-${s}`;
375 };
376 const dotCls = (e: TimelineEvent) => {
377 if (e.kind === "gate") {
378 const s = e.repairSucceeded ? "repaired" : e.status;
379 return `gate-${s}`;
380 }
381 const s = e.status === "succeeded" ? "success" : e.status;
382 return `deploy-${s}`;
383 };
384
385 return c.html(
386 <Layout title={`Pipeline — ${owner}/${repo}`} user={authUser ?? undefined}>
387 <style>{styles}</style>
388 {hasInFlight && <meta http-equiv="refresh" content="15" />}
389
390 <div class="pl-wrap">
391 <RepoHeader
392 owner={owner}
393 repo={repo}
394 description={repoRow.repo.description ?? ""}
395 isPrivate={repoRow.repo.isPrivate}
396 isFork={!!repoRow.repo.forkedFromId}
397 stars={repoRow.repo.starsCount ?? 0}
398 forks={repoRow.repo.forksCount ?? 0}
399 />
400 <RepoNav owner={owner} repo={repo} active="pipeline" isOwner={isOwner} />
401
402 <div style="margin: var(--space-5) 0 var(--space-3); display:flex; align-items:baseline; gap:var(--space-3)">
403 <h2 style="font-size:18px;font-weight:700;color:var(--text);margin:0">CI/CD Pipeline</h2>
404 <span style="font-size:12px;color:var(--text-muted)">last 7 days</span>
405 {hasInFlight && (
406 <span style="font-size:11px;color:#58a6ff;margin-left:auto">⟳ auto-refresh every 15s</span>
407 )}
408 </div>
409
410 {/* Summary bar */}
411 <div class="pl-summary">
412 <div class="pl-summary-stat">
413 <span class={`pl-summary-val ${gateFailed === 0 ? "ok" : "err"}`}>{gateFailed}</span>
414 <span class="pl-summary-label">Gate failures</span>
415 </div>
416 <div class="pl-summary-stat">
417 <span class={`pl-summary-val neutral`}>{gateTotal}</span>
418 <span class="pl-summary-label">Gate runs</span>
419 </div>
420 <div class="pl-summary-stat">
421 <span class={`pl-summary-val ${gateRepaired > 0 ? "warn" : "neutral"}`}>{gateRepaired}</span>
422 <span class="pl-summary-label">AI-repaired</span>
423 </div>
424 <div class="pl-summary-stat">
425 <span class={`pl-summary-val ${deployFailed === 0 ? "ok" : "err"}`}>{deployFailed}</span>
426 <span class="pl-summary-label">Deploy issues</span>
427 </div>
428 <div class="pl-summary-stat">
429 <span class={`pl-summary-val neutral`}>{deployTotal}</span>
430 <span class="pl-summary-label">Deployments</span>
431 </div>
432 </div>
433
434 {events.length === 0 ? (
435 <div class="pl-empty">
436 <div style="font-size:32px;margin-bottom:var(--space-3)">🚀</div>
437 <div style="font-weight:600;color:var(--text);margin-bottom:var(--space-2)">No pipeline events yet</div>
438 <div>Gate runs and deployments will appear here after your first push.</div>
439 </div>
440 ) : (
441 <div class="pl-timeline">
442 {events.map((e) => (
443 <div class="pl-event" key={e.id}>
444 <div class={`pl-event-dot ${dotCls(e)}`} />
445 <div class="pl-event-body">
446 <div class="pl-event-top">
447 <span class={`pl-event-kind ${e.kind}`}>
448 {e.kind === "gate" ? "Gate" : "Deploy"}
449 </span>
450 <span class="pl-event-name">
451 {e.kind === "gate" ? e.gateName : `${e.environment} deployment`}
452 </span>
453 {e.kind === "gate" && e.repairSucceeded && (
454 <span class="pl-repair-badge">⚡ AI-repaired</span>
455 )}
456 <span class={`pl-event-status ${e.kind === "gate" ? gateStatusCls(e.repairSucceeded ? "repaired" : e.status) : deployStatusCls(e.status)}`}>
457 {e.kind === "gate"
458 ? (e.repairSucceeded ? "repaired" : e.status)
459 : (e.status === "succeeded" ? "success" : e.status)}
460 </span>
461 <span class="pl-event-time">{relTime(e.createdAt)}</span>
462 </div>
463 <div class="pl-event-meta">
464 <span class="pl-sha">{e.commitSha.slice(0, 8)}</span>
465 <span>{e.ref.replace("refs/heads/", "")}</span>
466 {e.kind === "gate" && e.prNumber && (
467 <a href={`/${owner}/${repo}/pulls/${e.prNumber}`}>
468 PR #{e.prNumber}
469 </a>
470 )}
471 {e.kind === "gate" && e.durationMs && (
472 <span>{durationStr(0, e.durationMs)}</span>
473 )}
474 {e.kind === "gate" && !e.durationMs && e.completedAt && (
475 <span>{durationStr(e.createdAt.getTime(), e.completedAt.getTime())}</span>
476 )}
477 {e.kind === "deploy" && e.blockedReason && (
478 <span style="color:var(--warning)">{e.blockedReason.slice(0, 80)}</span>
479 )}
480 {e.kind === "deploy" && (
481 <a href={`/${owner}/${repo}/cloud-deployments`}>
482 Details →
483 </a>
484 )}
485 </div>
486 </div>
487 </div>
488 ))}
489 </div>
490 )}
491 </div>
492 </Layout>
493 );
494});
495
496export default app;
Modifiedsrc/routes/pulls.tsx+31−0View fileUnifiedSplit
@@ -368,6 +368,11 @@ const PRS_LIST_STYLES = `
368368 border-color: rgba(248,113,113,0.40);
369369 background: rgba(248,113,113,0.08);
370370 }
371 .prs-tag.is-ai {
372 color: #a78bfa;
373 border-color: rgba(167,139,250,0.40);
374 background: rgba(167,139,250,0.08);
375 }
371376
372377 .prs-empty {
373378 position: relative;
@@ -2646,6 +2651,26 @@ function TrioVerdictPills({
26462651 );
26472652}
26482653
2654// Detect AI-generated PRs from body markers and branch naming conventions.
2655// No DB column required — pattern-matched at render time.
2656function isAiGeneratedPr(body: string | null, headBranch: string): boolean {
2657 const AI_BRANCH_PREFIXES = ["claude/", "copilot/", "ai/", "bot/", "gluecron-ai/", "devin/"];
2658 if (AI_BRANCH_PREFIXES.some((p) => headBranch.startsWith(p))) return true;
2659 if (!body) return false;
2660 const AI_BODY_MARKERS = [
2661 "🤖 Generated with",
2662 "Co-Authored-By: Claude",
2663 "Claude-Session:",
2664 "gluecron:ai-generated",
2665 "AI-generated",
2666 "generated by claude",
2667 "generated with claude code",
2668 "copilot workspace",
2669 ];
2670 const lower = body.toLowerCase();
2671 return AI_BODY_MARKERS.some((m) => lower.includes(m.toLowerCase()));
2672}
2673
26492674// List PRs
26502675pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
26512676 const { owner: ownerName, repo: repoName } = c.req.param();
@@ -2980,6 +3005,9 @@ pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c)
29803005 </span>
29813006 <span class="prs-row-tags">
29823007 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
3008 {isAiGeneratedPr(pr.body, pr.headBranch) && (
3009 <span class="prs-tag is-ai" title="Opened by an AI agent">⚡ AI</span>
3010 )}
29833011 {pr.state === "merged" && (
29843012 <span class="prs-tag is-merged">Merged</span>
29853013 )}
@@ -4511,6 +4539,9 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
45114539 <span aria-hidden="true">{stateIcon}</span>
45124540 <span>{stateLabel}</span>
45134541 </span>
4542 {isAiGeneratedPr(pr.body, pr.headBranch) && (
4543 <span class="prs-tag is-ai" title="This pull request was opened by an AI agent">⚡ AI-generated</span>
4544 )}
45144545 {prSizeInfo && (
45154546 <span
45164547 class="prs-size-badge"
Addedsrc/routes/workspace-hub.tsx+341−0View fileUnifiedSplit
@@ -0,0 +1,341 @@
1/**
2 * AI Workspace Hub — /:owner/:repo/workspace
3 *
4 * The single entry point for all AI-assisted coding on a repo.
5 * Surfaces the three existing modes in one clean page so developers
6 * don't have to hunt through menus to find them:
7 *
8 * Mode A — Spec-to-PR: describe a feature, AI writes the code + opens a PR
9 * Mode B — Fix an issue: pick an open issue, AI implements it autonomously
10 * Mode C — Quick edit: describe a small change, editor opens on a new branch
11 *
12 * Also shows the 5 most recent workspace/spec jobs for this repo so the
13 * owner can track in-progress AI tasks at a glance.
14 */
15
16import { Hono } from "hono";
17import { and, desc, eq, gte } from "drizzle-orm";
18import { db } from "../db";
19import { issues, repositories, users } from "../db/schema";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24
25const app = new Hono<AuthEnv>();
26app.use("/:owner/:repo/workspace", softAuth);
27
28// ---------------------------------------------------------------------------
29// GET /:owner/:repo/workspace
30// ---------------------------------------------------------------------------
31
32app.get("/:owner/:repo/workspace", async (c) => {
33 const { owner, repo } = c.req.param();
34 const authUser = c.get("user");
35
36 const [repoRow] = await db
37 .select({ repo: repositories, owner: users })
38 .from(repositories)
39 .innerJoin(users, eq(repositories.ownerId, users.id))
40 .where(and(eq(users.username, owner), eq(repositories.name, repo)));
41
42 if (!repoRow) return c.notFound();
43 if (repoRow.repo.isPrivate && authUser?.id !== repoRow.owner.id) return c.notFound();
44
45 const isOwner = authUser?.id === repoRow.owner.id;
46
47 // Load a handful of open issues to populate the "Fix an issue" mode
48 const openIssues = await db
49 .select({
50 number: issues.number,
51 title: issues.title,
52 authorUsername: users.username,
53 })
54 .from(issues)
55 .innerJoin(users, eq(issues.authorId, users.id))
56 .where(and(eq(issues.repositoryId, repoRow.repo.id), eq(issues.state, "open")))
57 .orderBy(desc(issues.createdAt))
58 .limit(8);
59
60 const styles = `
61 .ws-wrap { max-width: 920px; margin: 0 auto; }
62 .ws-intro {
63 margin-bottom: var(--space-6);
64 padding: var(--space-5) var(--space-6);
65 background: var(--bg-elevated);
66 border: 1px solid var(--border);
67 border-radius: 16px;
68 position: relative;
69 overflow: hidden;
70 }
71 .ws-intro::before {
72 content: '';
73 position: absolute;
74 top: 0; left: 0; right: 0;
75 height: 3px;
76 background: var(--accent-gradient);
77 }
78 .ws-intro-title {
79 font-size: 22px;
80 font-weight: 700;
81 color: var(--text);
82 margin: 0 0 var(--space-2);
83 }
84 .ws-intro-sub {
85 font-size: 14px;
86 color: var(--text-secondary);
87 max-width: 600px;
88 }
89
90 .ws-modes {
91 display: grid;
92 grid-template-columns: 1fr 1fr;
93 gap: var(--space-4);
94 margin-bottom: var(--space-6);
95 }
96 @media (max-width: 680px) { .ws-modes { grid-template-columns: 1fr; } }
97 .ws-mode {
98 background: var(--bg-elevated);
99 border: 1px solid var(--border);
100 border-radius: 14px;
101 padding: var(--space-5);
102 display: flex;
103 flex-direction: column;
104 gap: var(--space-3);
105 transition: border-color 160ms, box-shadow 160ms;
106 }
107 .ws-mode:hover {
108 border-color: var(--border-strong);
109 box-shadow: 0 4px 20px rgba(0,0,0,0.18);
110 }
111 .ws-mode-icon {
112 font-size: 28px;
113 line-height: 1;
114 }
115 .ws-mode-title {
116 font-size: 16px;
117 font-weight: 700;
118 color: var(--text);
119 }
120 .ws-mode-desc {
121 font-size: 13px;
122 color: var(--text-secondary);
123 flex: 1;
124 line-height: 1.6;
125 }
126 .ws-mode-cta {
127 display: inline-flex;
128 align-items: center;
129 gap: 6px;
130 padding: 8px 16px;
131 background: var(--accent-gradient);
132 color: #fff;
133 border: none;
134 border-radius: 8px;
135 font-size: 13px;
136 font-weight: 600;
137 text-decoration: none;
138 cursor: pointer;
139 transition: opacity 150ms;
140 width: fit-content;
141 }
142 .ws-mode-cta:hover { opacity: 0.88; color: #fff; text-decoration: none; }
143 .ws-mode-cta.secondary {
144 background: var(--bg-inset);
145 color: var(--text-secondary);
146 border: 1px solid var(--border);
147 }
148 .ws-mode-cta.secondary:hover { border-color: var(--accent); color: var(--accent); opacity: 1; }
149
150 .ws-issues-section {
151 background: var(--bg-elevated);
152 border: 1px solid var(--border);
153 border-radius: 14px;
154 overflow: hidden;
155 margin-bottom: var(--space-6);
156 }
157 .ws-issues-head {
158 display: flex;
159 align-items: center;
160 justify-content: space-between;
161 padding: var(--space-3) var(--space-5);
162 border-bottom: 1px solid var(--border);
163 font-size: 12px;
164 font-weight: 700;
165 text-transform: uppercase;
166 letter-spacing: 0.07em;
167 color: var(--text-muted);
168 }
169 .ws-issue-row {
170 display: flex;
171 align-items: center;
172 gap: var(--space-3);
173 padding: var(--space-3) var(--space-5);
174 transition: background 120ms;
175 }
176 .ws-issue-row:hover { background: var(--bg-hover); }
177 .ws-issue-title {
178 flex: 1;
179 font-size: 13px;
180 color: var(--text);
181 font-weight: 500;
182 white-space: nowrap;
183 overflow: hidden;
184 text-overflow: ellipsis;
185 }
186 .ws-issue-num { font-size: 11px; color: var(--text-muted); }
187 .ws-issue-btn {
188 display: inline-flex;
189 align-items: center;
190 gap: 5px;
191 padding: 4px 10px;
192 font-size: 11px;
193 font-weight: 600;
194 border: 1px solid var(--border);
195 border-radius: 7px;
196 background: var(--bg-inset);
197 color: var(--text-secondary);
198 text-decoration: none;
199 white-space: nowrap;
200 flex-shrink: 0;
201 transition: border-color 120ms, color 120ms;
202 }
203 .ws-issue-btn:hover { border-color: var(--accent); color: var(--accent); }
204
205 .ws-tips {
206 display: grid;
207 grid-template-columns: repeat(3, 1fr);
208 gap: var(--space-3);
209 }
210 @media (max-width: 600px) { .ws-tips { grid-template-columns: 1fr; } }
211 .ws-tip {
212 padding: var(--space-4);
213 background: var(--bg-elevated);
214 border: 1px solid var(--border);
215 border-radius: 10px;
216 font-size: 12px;
217 color: var(--text-secondary);
218 line-height: 1.6;
219 }
220 .ws-tip-head { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
221 `;
222
223 return c.html(
224 <Layout title={`AI Workspace — ${owner}/${repo}`} user={authUser ?? undefined}>
225 <style>{styles}</style>
226 <div class="ws-wrap">
227 <RepoHeader
228 owner={owner}
229 repo={repo}
230 description={repoRow.repo.description ?? ""}
231 isPrivate={repoRow.repo.isPrivate}
232 isFork={!!repoRow.repo.forkedFromId}
233 stars={repoRow.repo.starsCount ?? 0}
234 forks={repoRow.repo.forksCount ?? 0}
235 />
236 <RepoNav owner={owner} repo={repo} active="workspace" isOwner={isOwner} />
237
238 {/* Hero intro */}
239 <div class="ws-intro">
240 <div class="ws-intro-title">✨ AI Workspace</div>
241 <div class="ws-intro-sub">
242 Claude-powered coding modes for <strong>{owner}/{repo}</strong>. Describe what you want built — the AI writes the code, opens a branch, and creates a draft PR for review.
243 </div>
244 </div>
245
246 {/* Three modes */}
247 <div class="ws-modes">
248 <div class="ws-mode">
249 <div class="ws-mode-icon">📋</div>
250 <div class="ws-mode-title">Spec-to-PR</div>
251 <div class="ws-mode-desc">
252 Paste a feature specification in plain English. Claude analyses the codebase, writes the implementation, and opens a draft pull request ready for your review.
253 </div>
254 <a href={`/${owner}/${repo}/spec`} class="ws-mode-cta">
255 Write a spec →
256 </a>
257 </div>
258
259 <div class="ws-mode">
260 <div class="ws-mode-icon">🔧</div>
261 <div class="ws-mode-title">Fix an issue</div>
262 <div class="ws-mode-desc">
263 Pick any open issue below. Claude reads the issue, explores the relevant code, writes a fix, and opens a PR — you just review and merge.
264 </div>
265 <a href={`/${owner}/${repo}/issues`} class="ws-mode-cta secondary">
266 Browse open issues ↓
267 </a>
268 </div>
269
270 <div class="ws-mode">
271 <div class="ws-mode-icon">✏️</div>
272 <div class="ws-mode-title">Web editor</div>
273 <div class="ws-mode-desc">
274 Edit files directly in the browser. Great for quick documentation updates, config tweaks, or small fixes without cloning the repo.
275 </div>
276 <a href={`/${owner}/${repo}/new/main`} class="ws-mode-cta secondary">
277 Open editor →
278 </a>
279 </div>
280
281 <div class="ws-mode">
282 <div class="ws-mode-icon">🧪</div>
283 <div class="ws-mode-title">Generate tests</div>
284 <div class="ws-mode-desc">
285 Point Claude at a file or function. It generates a test suite, creates a branch, and opens a PR so you can review coverage improvements instantly.
286 </div>
287 <a href={`/${owner}/${repo}/generate-tests`} class="ws-mode-cta secondary">
288 Generate tests →
289 </a>
290 </div>
291 </div>
292
293 {/* Open issues ready to be fixed by AI */}
294 {openIssues.length > 0 && (
295 <div class="ws-issues-section">
296 <div class="ws-issues-head">
297 <span>Open issues — launch AI workspace</span>
298 <a href={`/${owner}/${repo}/issues`} style="font-size:11px;color:var(--accent);text-transform:none;letter-spacing:0;text-decoration:none">
299 All issues →
300 </a>
301 </div>
302 {openIssues.map((issue) => (
303 <div class="ws-issue-row" key={issue.number}>
304 <span style="color:#3fb950;font-size:12px;flex-shrink:0">○</span>
305 <span class="ws-issue-title">{issue.title}</span>
306 <span class="ws-issue-num">#{issue.number}</span>
307 {isOwner && (
308 <a
309 href={`/${owner}/${repo}/issues/${issue.number}/workspace`}
310 class="ws-issue-btn"
311 title="Let AI implement this issue"
312 >
313 ✨ Fix with AI
314 </a>
315 )}
316 </div>
317 ))}
318 </div>
319 )}
320
321 {/* Usage tips */}
322 <div class="ws-tips">
323 <div class="ws-tip">
324 <div class="ws-tip-head">Best specs get best results</div>
325 Include acceptance criteria, the affected files if you know them, and any constraints (no new dependencies, must stay backwards-compatible, etc.).
326 </div>
327 <div class="ws-tip">
328 <div class="ws-tip-head">Always review the diff</div>
329 AI PRs are drafts. Claude marks them clearly with an ⚡ AI badge. Check the diff before merging — gates still run so broken builds are caught automatically.
330 </div>
331 <div class="ws-tip">
332 <div class="ws-tip-head">Issues → specs → PRs</div>
333 The fastest flow: file a clear issue, use "Fix with AI" to start a workspace, review the resulting PR. The gate CI runs automatically on every push.
334 </div>
335 </div>
336 </div>
337 </Layout>
338 );
339});
340
341export default app;
Modifiedsrc/views/components.tsx+13−0View fileUnifiedSplit
@@ -271,6 +271,12 @@ export const RepoNav: FC<{
271271 >
272272 Deployments
273273 </a>
274 <a
275 href={`/${owner}/${repo}/pipeline`}
276 class={active === "pipeline" ? "active" : ""}
277 >
278 Pipeline
279 </a>
274280 <a
275281 href={`/${owner}/${repo}/insights`}
276282 class={active === "insights" ? "active" : ""}
@@ -293,6 +299,13 @@ export const RepoNav: FC<{
293299 <a href={`/${owner}/${repo}/ask`} class="repo-nav-ai">
294300 {"\u2728"} Ask AI
295301 </a>
302 <a
303 href={`/${owner}/${repo}/workspace`}
304 class={`repo-nav-ai${active === "workspace" ? " active" : ""}`}
305 title="AI Workspace — spec-to-PR, fix issues with AI, web editor"
306 >
307 {"✨"} Workspace
308 </a>
296309 <a
297310 href={`/${owner}/${repo}/spec`}
298311 class="repo-nav-ai"
Modifiedsrc/views/layout.tsx+16−0View fileUnifiedSplit
@@ -1712,6 +1712,17 @@ const css = `
17121712 }
17131713 (max-width: 780px) { .nav-migrate { display: none; } }
17141714
1715 /* ── Mobile nav ──────────────────────────��──────────────────── */
1716 (max-width: 640px) {
1717 .site-header nav { gap: 10px; padding: 0 var(--space-3); }
1718 .nav-search { display: none; }
1719 .nav-right { gap: 4px; }
1720 .nav-link { display: none; }
1721 .nav-ai-dropdown { display: none; }
1722 .nav-deploy-pill .deploy-pill-text { display: none; }
1723 .nav-inbox-btn .nav-inbox-badge { font-size: 9px; min-width: 14px; height: 14px; }
1724 }
1725
17151726 .nav-user {
17161727 color: var(--text-strong);
17171728 font-weight: 600;
@@ -1842,6 +1853,7 @@ const css = `
18421853 padding: 36px 24px 80px;
18431854 flex: 1;
18441855 width: 100%;
1856 box-sizing: border-box;
18451857 /* Subtle entrance fade on page load — kept short (180ms) so it
18461858 reads as responsiveness, not theatre. Honors reduced-motion. */
18471859 animation: gxMainEnter 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
@@ -1947,10 +1959,14 @@ const css = `
19471959 opacity: 0.9;
19481960 }
19491961 (max-width: 768px) {
1962 main { padding: 20px 14px 60px; }
19501963 footer .footer-inner { grid-template-columns: 1fr; gap: 32px; }
19511964 footer .footer-links { grid-template-columns: repeat(2, 1fr); gap: 24px 32px; }
19521965 footer .footer-bottom { flex-direction: column; gap: 8px; text-align: center; }
19531966 }
1967 (max-width: 480px) {
1968 main { padding: 14px 10px 48px; }
1969 }
19541970
19551971 /* ============================================================ */
19561972 /* Buttons */
19571973