Commit641aa42unknown_key
feat: smart empty states + keyboard-first enhancements — AI repo onboarding and power-user shortcuts
feat: smart empty states + keyboard-first enhancements — AI repo onboarding and power-user shortcuts https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
9 files changed+1241−6641aa421fd33fb057299d4ea1fb650b7cab5f5e6
9 changed files+1241−6
Addeddrizzle/0100_repo_onboarding.sql+17−0View fileUnifiedSplit
@@ -0,0 +1,17 @@
1-- Migration 0088: Smart empty states — repo onboarding data + flag
2-- Adds onboarding_shown column to repositories and a new repo_onboarding_data
3-- table. Generated on first push to a repo; displayed as a dismissible card
4-- on the repo home page.
5
6ALTER TABLE repositories ADD COLUMN IF NOT EXISTS onboarding_shown BOOLEAN NOT NULL DEFAULT false;
7
8CREATE TABLE IF NOT EXISTS repo_onboarding_data (
9 repository_id UUID PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
10 detected_language TEXT,
11 detected_framework TEXT,
12 suggested_readme TEXT,
13 suggested_labels JSONB NOT NULL DEFAULT '[]',
14 suggested_gates_config TEXT,
15 first_commit_suggestions JSONB NOT NULL DEFAULT '[]',
16 generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
17);
Modifiedsrc/db/schema.ts+30−0View fileUnifiedSplit
@@ -249,6 +249,9 @@ export const repositories = pgTable(
249249 // and auto-merges (pass) or opens a PR with an AI migration guide
250250 // (fail). Default false — off by default because it touches branches.
251251 depUpdaterEnabled: boolean("dep_updater_enabled").default(false).notNull(),
252 // Migration 0088 — smart empty states. Set to true once the onboarding
253 // card has been dismissed by the repo owner. Generated on first push.
254 onboardingShown: boolean("onboarding_shown").default(false).notNull(),
252255 },
253256 (table) => [
254257 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
@@ -4547,3 +4550,30 @@ export const prVisits = pgTable(
45474550);
45484551
45494552export type PrVisit = typeof prVisits.$inferSelect;
4553// ---------------------------------------------------------------------------
4554// Migration 0088 — Smart empty states: repo onboarding data
4555// Generated by generateRepoOnboarding() on first push to a repo.
4556// ---------------------------------------------------------------------------
4557export const repoOnboardingData = pgTable("repo_onboarding_data", {
4558 repositoryId: uuid("repository_id")
4559 .primaryKey()
4560 .references(() => repositories.id, { onDelete: "cascade" }),
4561 detectedLanguage: text("detected_language"),
4562 detectedFramework: text("detected_framework"),
4563 suggestedReadme: text("suggested_readme"),
4564 suggestedLabels: jsonb("suggested_labels")
4565 .notNull()
4566 .default([])
4567 .$type<Array<{ name: string; color: string; description: string }>>(),
4568 suggestedGatesConfig: text("suggested_gates_config"),
4569 firstCommitSuggestions: jsonb("first_commit_suggestions")
4570 .notNull()
4571 .default([])
4572 .$type<string[]>(),
4573 generatedAt: timestamp("generated_at", { withTimezone: true })
4574 .notNull()
4575 .defaultNow(),
4576});
4577
4578export type RepoOnboardingData = typeof repoOnboardingData.$inferSelect;
4579export type NewRepoOnboardingData = typeof repoOnboardingData.$inferInsert;
Modifiedsrc/hooks/post-receive.ts+46−0View fileUnifiedSplit
@@ -37,6 +37,7 @@ import {
3737} from "../lib/server-target-store";
3838import { deployToTarget } from "../lib/server-targets";
3939import { fireCloudDeploys } from "../lib/cloud-deploy";
40import { ensureRepoOnboarding } from "../lib/repo-onboarding";
4041
4142interface PushRef {
4243 oldSha: string;
@@ -161,6 +162,15 @@ export async function onPostReceive(
161162 console.warn("[ai-doc-updater] dispatch error:", err)
162163 );
163164
165 // 4g. Smart empty states — repo onboarding. On the very first push to a
166 // repo's default branch (oldSha all-zeros), generate a README draft,
167 // suggested labels, and gates.yml starter using Claude Sonnet.
168 // Idempotent: ensureRepoOnboarding skips if a row already exists.
169 // Fire-and-forget; never blocks the push path.
170 void fireRepoOnboarding(owner, repo, refs).catch((err) =>
171 console.warn("[repo-onboarding] dispatch error:", err)
172 );
173
164174 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
165175 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
166176 // default branch. The branch case (`Main` vs `main`) is determined by
@@ -790,6 +800,41 @@ async function fireDependencyScan(
790800 }
791801}
792802
803/**
804 * Migration 0088 — trigger repo onboarding on first push to the default branch.
805 * Detects "first push" by checking whether oldSha is all-zeros on a push to
806 * the repo's default branch (or any branch for a repo with no prior history).
807 * Resolves the repo DB id, then calls ensureRepoOnboarding which is idempotent.
808 * Never throws.
809 */
810async function fireRepoOnboarding(
811 owner: string,
812 repo: string,
813 refs: PushRef[]
814): Promise<void> {
815 // Only fire on first push — oldSha all-zeros means the branch is brand-new.
816 const firstPushRefs = refs.filter(
817 (r) => r.refName.startsWith("refs/heads/") && /^0+$/.test(r.oldSha) && !r.newSha.startsWith("0000")
818 );
819 if (firstPushRefs.length === 0) return;
820
821 let repositoryId = "";
822 try {
823 const [row] = await db
824 .select({ id: repositories.id })
825 .from(repositories)
826 .innerJoin(users, eq(repositories.ownerId, users.id))
827 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
828 .limit(1);
829 repositoryId = row?.id || "";
830 } catch {
831 return;
832 }
833 if (!repositoryId) return;
834
835 await ensureRepoOnboarding(repositoryId, owner, repo);
836}
837
793838/** Test-only access to internal helpers. */
794839export const __test = {
795840 triggerCrontechDeploy,
@@ -803,4 +848,5 @@ export const __test = {
803848 fireServerTargetDeploys,
804849 fireDependencyScan,
805850 fireCloudDeploys,
851 fireRepoOnboarding,
806852};
Addedsrc/lib/repo-onboarding.ts+492−0View fileUnifiedSplit
@@ -0,0 +1,492 @@
1/**
2 * Smart empty states — repo onboarding generation.
3 *
4 * generateRepoOnboarding() — analyse a repo's file tree + detect language/
5 * framework, then call Claude Sonnet to produce a
6 * README draft, suggested labels, a gates.yml
7 * starter, and first-commit suggestions.
8 *
9 * ensureRepoOnboarding() — idempotent wrapper called on first push;
10 * skips if onboarding_shown is already set OR if
11 * the repo_onboarding_data row already exists.
12 *
13 * All external calls are swallowed — a missing AI key or DB error must never
14 * break the push path.
15 */
16
17import { eq } from "drizzle-orm";
18import { db } from "../db";
19import { repositories, repoOnboardingData } from "../db/schema";
20import { getAnthropic, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
21import { getRepoPath } from "../git/repository";
22
23// ---------------------------------------------------------------------------
24// Types
25// ---------------------------------------------------------------------------
26
27export interface RepoOnboarding {
28 detectedLanguage: string;
29 detectedFramework?: string;
30 suggestedReadme: string;
31 suggestedLabels: Array<{ name: string; color: string; description: string }>;
32 suggestedGatesConfig: string;
33 firstCommitSuggestions: string[];
34}
35
36// ---------------------------------------------------------------------------
37// Language + framework detection helpers
38// ---------------------------------------------------------------------------
39
40function detectLanguage(files: string[]): string {
41 const counts: Record<string, number> = {};
42 for (const f of files) {
43 const ext = f.split(".").pop()?.toLowerCase() ?? "";
44 if (ext === "ts" || ext === "tsx") counts.TypeScript = (counts.TypeScript ?? 0) + 1;
45 else if (ext === "js" || ext === "jsx" || ext === "mjs" || ext === "cjs") counts.JavaScript = (counts.JavaScript ?? 0) + 1;
46 else if (ext === "py") counts.Python = (counts.Python ?? 0) + 1;
47 else if (ext === "go") counts.Go = (counts.Go ?? 0) + 1;
48 else if (ext === "rs") counts.Rust = (counts.Rust ?? 0) + 1;
49 else if (ext === "java") counts.Java = (counts.Java ?? 0) + 1;
50 else if (ext === "rb") counts.Ruby = (counts.Ruby ?? 0) + 1;
51 else if (ext === "php") counts.PHP = (counts.PHP ?? 0) + 1;
52 else if (ext === "cs") counts["C#"] = (counts["C#"] ?? 0) + 1;
53 else if (ext === "cpp" || ext === "cc" || ext === "cxx") counts["C++"] = (counts["C++"] ?? 0) + 1;
54 else if (ext === "c" || ext === "h") counts.C = (counts.C ?? 0) + 1;
55 else if (ext === "swift") counts.Swift = (counts.Swift ?? 0) + 1;
56 else if (ext === "kt") counts.Kotlin = (counts.Kotlin ?? 0) + 1;
57 }
58 const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
59 return sorted[0]?.[0] ?? "Unknown";
60}
61
62async function detectFramework(
63 ownerName: string,
64 repoName: string,
65 files: string[]
66): Promise<string | undefined> {
67 const fileSet = new Set(files.map((f) => f.toLowerCase()));
68
69 // Check next.config.* — Next.js
70 if (files.some((f) => /next\.config\.(js|ts|mjs|cjs)/.test(f))) return "Next.js";
71
72 // Check Cargo.toml for Actix / Axum / Warp
73 if (fileSet.has("cargo.toml")) {
74 const content = await readFileFromGit(ownerName, repoName, "Cargo.toml");
75 if (content) {
76 if (/actix-web/.test(content)) return "Actix";
77 if (/axum/.test(content)) return "Axum";
78 if (/warp/.test(content)) return "Warp";
79 }
80 }
81
82 // Check requirements.txt for FastAPI / Django / Flask
83 if (fileSet.has("requirements.txt")) {
84 const content = await readFileFromGit(ownerName, repoName, "requirements.txt");
85 if (content) {
86 if (/fastapi/i.test(content)) return "FastAPI";
87 if (/django/i.test(content)) return "Django";
88 if (/flask/i.test(content)) return "Flask";
89 }
90 }
91
92 // Check package.json for various JS frameworks
93 if (fileSet.has("package.json")) {
94 const content = await readFileFromGit(ownerName, repoName, "package.json");
95 if (content) {
96 try {
97 const pkg = JSON.parse(content) as Record<string, unknown>;
98 const deps = {
99 ...((pkg.dependencies as Record<string, string>) ?? {}),
100 ...((pkg.devDependencies as Record<string, string>) ?? {}),
101 };
102 if ("hono" in deps) return "Hono";
103 if ("next" in deps) return "Next.js";
104 if ("express" in deps) return "Express";
105 if ("fastify" in deps) return "Fastify";
106 if ("@nestjs/core" in deps) return "NestJS";
107 if ("nuxt" in deps) return "Nuxt";
108 if ("svelte" in deps) return "SvelteKit";
109 if ("remix" in deps || "@remix-run/node" in deps) return "Remix";
110 if ("react" in deps) return "React";
111 if ("vue" in deps) return "Vue";
112 } catch {
113 /* ignore parse error */
114 }
115 }
116 }
117
118 // go.mod check
119 if (fileSet.has("go.mod")) {
120 const content = await readFileFromGit(ownerName, repoName, "go.mod");
121 if (content) {
122 if (/gin-gonic\/gin/.test(content)) return "Gin";
123 if (/labstack\/echo/.test(content)) return "Echo";
124 if (/gofiber\/fiber/.test(content)) return "Fiber";
125 }
126 }
127
128 return undefined;
129}
130
131// ---------------------------------------------------------------------------
132// Git helpers
133// ---------------------------------------------------------------------------
134
135async function listAllFiles(ownerName: string, repoName: string): Promise<string[]> {
136 const cwd = getRepoPath(ownerName, repoName);
137 try {
138 const proc = Bun.spawn(
139 ["git", "ls-tree", "-r", "--name-only", "HEAD"],
140 { cwd, stdout: "pipe", stderr: "pipe" }
141 );
142 const text = await new Response(proc.stdout).text();
143 await proc.exited;
144 return text.split("\n").map((s) => s.trim()).filter(Boolean);
145 } catch {
146 return [];
147 }
148}
149
150async function readFileFromGit(
151 ownerName: string,
152 repoName: string,
153 path: string
154): Promise<string | null> {
155 const cwd = getRepoPath(ownerName, repoName);
156 try {
157 const proc = Bun.spawn(
158 ["git", "show", `HEAD:${path}`],
159 { cwd, stdout: "pipe", stderr: "pipe" }
160 );
161 const text = await new Response(proc.stdout).text();
162 const exit = await proc.exited;
163 return exit === 0 ? text : null;
164 } catch {
165 return null;
166 }
167}
168
169// ---------------------------------------------------------------------------
170// Default labels by language (no AI required)
171// ---------------------------------------------------------------------------
172
173const DEFAULT_LABELS: Array<{ name: string; color: string; description: string }> = [
174 { name: "bug", color: "d73a4a", description: "Something isn't working" },
175 { name: "feature", color: "0075ca", description: "New feature or request" },
176 { name: "docs", color: "0052cc", description: "Improvements or additions to documentation" },
177 { name: "performance", color: "e4e669", description: "Performance improvement" },
178 { name: "security", color: "e11d48", description: "Security vulnerability or fix" },
179 { name: "breaking-change", color: "b60205", description: "Breaking change that requires version bump" },
180];
181
182const LANGUAGE_LABELS: Record<string, Array<{ name: string; color: string; description: string }>> = {
183 TypeScript: [
184 { name: "types", color: "3178c6", description: "TypeScript type definitions or fixes" },
185 { name: "deps", color: "0366d6", description: "Dependency update" },
186 ],
187 Python: [
188 { name: "pep8", color: "ffd43b", description: "Code style: PEP 8 compliance" },
189 { name: "deps", color: "0366d6", description: "Dependency update" },
190 ],
191 Go: [
192 { name: "goroutine", color: "00add8", description: "Concurrency / goroutine related" },
193 { name: "deps", color: "0366d6", description: "Go module dependency" },
194 ],
195 Rust: [
196 { name: "unsafe", color: "e83e8c", description: "Involves unsafe Rust code" },
197 { name: "deps", color: "0366d6", description: "Cargo dependency" },
198 ],
199};
200
201function buildDefaultLabels(
202 language: string
203): Array<{ name: string; color: string; description: string }> {
204 const extras = LANGUAGE_LABELS[language] ?? [];
205 return [...DEFAULT_LABELS, ...extras];
206}
207
208// ---------------------------------------------------------------------------
209// gates.yml starters
210// ---------------------------------------------------------------------------
211
212function buildDefaultGatesConfig(language: string, framework?: string): string {
213 const lang = language.toLowerCase();
214 if (lang === "typescript" || lang === "javascript") {
215 return `version: 1
216gates:
217 - id: type-check
218 run: npx tsc --noEmit
219 on: push
220 - id: lint
221 run: npx eslint . --ext .ts,.tsx,.js,.jsx
222 on: push
223 - id: test
224 run: ${framework === "Hono" || lang === "typescript" ? "bun test" : "npm test"}
225 on: push
226`;
227 }
228 if (lang === "python") {
229 return `version: 1
230gates:
231 - id: lint
232 run: ruff check .
233 on: push
234 - id: type-check
235 run: mypy .
236 on: push
237 - id: test
238 run: pytest
239 on: push
240`;
241 }
242 if (lang === "go") {
243 return `version: 1
244gates:
245 - id: vet
246 run: go vet ./...
247 on: push
248 - id: test
249 run: go test ./...
250 on: push
251`;
252 }
253 if (lang === "rust") {
254 return `version: 1
255gates:
256 - id: check
257 run: cargo check
258 on: push
259 - id: clippy
260 run: cargo clippy -- -D warnings
261 on: push
262 - id: test
263 run: cargo test
264 on: push
265`;
266 }
267 return `version: 1
268gates:
269 - id: test
270 run: echo "Add your test command here"
271 on: push
272`;
273}
274
275// ---------------------------------------------------------------------------
276// First-commit suggestions
277// ---------------------------------------------------------------------------
278
279function buildFirstCommitSuggestions(language: string): string[] {
280 const lang = language.toLowerCase();
281 const suggestions = [`Add a .gitignore for ${language}`];
282 if (lang === "typescript" || lang === "javascript") {
283 suggestions.push("Add a tsconfig.json");
284 suggestions.push("Add ESLint + Prettier config");
285 suggestions.push("Add a README.md");
286 suggestions.push("Add tests/ directory with a sample test");
287 } else if (lang === "python") {
288 suggestions.push("Add requirements.txt or pyproject.toml");
289 suggestions.push("Add a README.md");
290 suggestions.push("Add tests/ directory with pytest");
291 } else if (lang === "go") {
292 suggestions.push("Initialize go.mod (go mod init)");
293 suggestions.push("Add a README.md");
294 suggestions.push("Add *_test.go files");
295 } else if (lang === "rust") {
296 suggestions.push("Cargo.toml is auto-generated — add a README.md");
297 suggestions.push("Add integration tests in tests/");
298 } else {
299 suggestions.push("Add a README.md");
300 suggestions.push("Add tests/");
301 }
302 return suggestions;
303}
304
305// ---------------------------------------------------------------------------
306// Main generation function
307// ---------------------------------------------------------------------------
308
309export async function generateRepoOnboarding(
310 repoId: string,
311 ownerName: string,
312 repoName: string
313): Promise<RepoOnboarding> {
314 const files = await listAllFiles(ownerName, repoName);
315 const language = detectLanguage(files);
316 const framework = await detectFramework(ownerName, repoName, files);
317
318 const existingReadme =
319 (await readFileFromGit(ownerName, repoName, "README.md")) ||
320 (await readFileFromGit(ownerName, repoName, "readme.md")) ||
321 (await readFileFromGit(ownerName, repoName, "README.txt")) ||
322 null;
323
324 const defaultLabels = buildDefaultLabels(language);
325 const defaultGatesConfig = buildDefaultGatesConfig(language, framework);
326 const defaultSuggestions = buildFirstCommitSuggestions(language);
327
328 // Without AI: return sensible defaults immediately
329 if (!isAiAvailable()) {
330 const readmeDraft = existingReadme ?? buildFallbackReadme(repoName, language, framework);
331 return {
332 detectedLanguage: language,
333 detectedFramework: framework,
334 suggestedReadme: readmeDraft,
335 suggestedLabels: defaultLabels,
336 suggestedGatesConfig: defaultGatesConfig,
337 firstCommitSuggestions: defaultSuggestions,
338 };
339 }
340
341 // With AI: call Claude Sonnet 4.6 for richer content
342 try {
343 const fileTree = files.slice(0, 120).join("\n");
344 const readmeContext = existingReadme
345 ? existingReadme.slice(0, 2000)
346 : "(none)";
347
348 const prompt = `Generate onboarding content for a new ${language}${framework ? ` ${framework}` : ""} repository named "${repoName}".
349
350File tree (if any):
351${fileTree || "(empty — no commits yet)"}
352
353Existing README (if any):
354${readmeContext}
355
356Return ONLY valid JSON (no prose, no fenced block) with this exact shape:
357{
358 "suggestedReadme": "# ${repoName}\\n\\n...",
359 "suggestedLabels": [{"name": "bug", "color": "d73a4a", "description": "Something isn't working"}, ...],
360 "suggestedGatesConfig": "version: 1\\ngates:\\n ...",
361 "firstCommitSuggestions": ["Add a .gitignore for ${language}", ...]
362}
363
364Rules:
365- suggestedReadme must include ## About, ## Installation, ## Usage, ## Contributing sections. Use placeholder content where specifics are unknown.
366- suggestedLabels: 6-8 labels appropriate for this type of project. Include bug, feature, docs, security, breaking-change and 1-3 language-specific ones.
367- suggestedGatesConfig: a real gates.yml starter for ${language}${framework ? ` / ${framework}` : ""} with lint + test gates.
368- firstCommitSuggestions: 3-5 practical next steps for this language/framework.`;
369
370 const anthropic = getAnthropic();
371 const message = await anthropic.messages.create({
372 model: MODEL_SONNET,
373 max_tokens: 2048,
374 messages: [{ role: "user", content: prompt }],
375 });
376
377 const text = extractText(message);
378 const parsed = parseJsonResponse<{
379 suggestedReadme?: string;
380 suggestedLabels?: Array<{ name: string; color: string; description: string }>;
381 suggestedGatesConfig?: string;
382 firstCommitSuggestions?: string[];
383 }>(text);
384
385 if (parsed) {
386 return {
387 detectedLanguage: language,
388 detectedFramework: framework,
389 suggestedReadme: parsed.suggestedReadme ?? buildFallbackReadme(repoName, language, framework),
390 suggestedLabels: parsed.suggestedLabels ?? defaultLabels,
391 suggestedGatesConfig: parsed.suggestedGatesConfig ?? defaultGatesConfig,
392 firstCommitSuggestions: parsed.firstCommitSuggestions ?? defaultSuggestions,
393 };
394 }
395 } catch (err) {
396 console.warn("[repo-onboarding] AI generation failed:", err instanceof Error ? err.message : err);
397 }
398
399 // AI failed — fall back to defaults
400 return {
401 detectedLanguage: language,
402 detectedFramework: framework,
403 suggestedReadme: existingReadme ?? buildFallbackReadme(repoName, language, framework),
404 suggestedLabels: defaultLabels,
405 suggestedGatesConfig: defaultGatesConfig,
406 firstCommitSuggestions: defaultSuggestions,
407 };
408}
409
410function buildFallbackReadme(repoName: string, language: string, framework?: string): string {
411 const stack = framework ? `${language} / ${framework}` : language;
412 return `# ${repoName}
413
414> A ${stack} project.
415
416## About
417
418TODO: Describe what this project does and why it exists.
419
420## Installation
421
422\`\`\`bash
423# TODO: Add installation steps
424\`\`\`
425
426## Usage
427
428\`\`\`bash
429# TODO: Add usage examples
430\`\`\`
431
432## Contributing
433
4341. Fork the repository
4352. Create a feature branch (\`git checkout -b feat/my-feature\`)
4363. Commit your changes
4374. Push and open a pull request
438
439## License
440
441TODO: Add license
442`;
443}
444
445// ---------------------------------------------------------------------------
446// Idempotent store / retrieve
447// ---------------------------------------------------------------------------
448
449/**
450 * ensureRepoOnboarding — called fire-and-forget on first push.
451 * Generates onboarding data and stores it if not already present.
452 * Never throws.
453 */
454export async function ensureRepoOnboarding(
455 repoId: string,
456 ownerName: string,
457 repoName: string
458): Promise<void> {
459 try {
460 // Check if already generated
461 const [existing] = await db
462 .select({ repositoryId: repoOnboardingData.repositoryId })
463 .from(repoOnboardingData)
464 .where(eq(repoOnboardingData.repositoryId, repoId))
465 .limit(1);
466 if (existing) return; // already generated
467
468 const onboarding = await generateRepoOnboarding(repoId, ownerName, repoName);
469
470 await db
471 .insert(repoOnboardingData)
472 .values({
473 repositoryId: repoId,
474 detectedLanguage: onboarding.detectedLanguage,
475 detectedFramework: onboarding.detectedFramework ?? null,
476 suggestedReadme: onboarding.suggestedReadme,
477 suggestedLabels: onboarding.suggestedLabels,
478 suggestedGatesConfig: onboarding.suggestedGatesConfig,
479 firstCommitSuggestions: onboarding.firstCommitSuggestions,
480 })
481 .onConflictDoNothing();
482
483 console.log(
484 `[repo-onboarding] generated for ${ownerName}/${repoName}: ${onboarding.detectedLanguage}${onboarding.detectedFramework ? ` / ${onboarding.detectedFramework}` : ""}`
485 );
486 } catch (err) {
487 console.warn(
488 `[repo-onboarding] ensureRepoOnboarding failed for ${ownerName}/${repoName}:`,
489 err instanceof Error ? err.message : err
490 );
491 }
492}
Modifiedsrc/routes/issues.tsx+84−0View fileUnifiedSplit
@@ -1711,6 +1711,90 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
17111711 </form>
17121712 )}
17131713 </div>
1714 {/* Issue keyboard hints bar */}
1715 <div class="kbd-hints" aria-label="Keyboard shortcuts for this issue">
1716 <kbd>c</kbd> comment · <kbd>e</kbd> edit title · <kbd>x</kbd> close/reopen · <kbd>?</kbd> shortcuts
1717 </div>
1718 <style dangerouslySetInnerHTML={{ __html: `
1719 .kbd-hints {
1720 position: fixed;
1721 bottom: 0;
1722 left: 0;
1723 right: 0;
1724 z-index: 90;
1725 padding: 6px 24px;
1726 background: var(--bg-secondary);
1727 border-top: 1px solid var(--border);
1728 font-size: 12px;
1729 color: var(--text-muted);
1730 display: flex;
1731 align-items: center;
1732 gap: 8px;
1733 flex-wrap: wrap;
1734 }
1735 .kbd-hints kbd {
1736 font-family: var(--font-mono);
1737 font-size: 10px;
1738 background: var(--bg-elevated);
1739 border: 1px solid var(--border);
1740 border-bottom-width: 2px;
1741 border-radius: 4px;
1742 padding: 1px 5px;
1743 color: var(--text);
1744 line-height: 1.5;
1745 }
1746 main { padding-bottom: 40px; }
1747 ` }} />
1748 {/* Repo context commands for command palette */}
1749 <script
1750 id="cmdk-repo-context"
1751 dangerouslySetInnerHTML={{
1752 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
1753 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
1754 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
1755 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
1756 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
1757 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
1758 ])};`,
1759 }}
1760 />
1761 {/* Issue keyboard shortcuts */}
1762 <script dangerouslySetInnerHTML={{ __html: `
1763 (function(){
1764 function isTyping(t){
1765 t = t || {};
1766 var tag = (t.tagName || '').toLowerCase();
1767 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
1768 }
1769 document.addEventListener('keydown', function(e){
1770 if (isTyping(e.target)) return;
1771 if (e.metaKey || e.ctrlKey || e.altKey) return;
1772 if (e.key === 'c') {
1773 e.preventDefault();
1774 var box = document.querySelector('textarea[name="body"]');
1775 if (box) { box.focus(); box.scrollIntoView({block:'center'}); }
1776 }
1777 if (e.key === 'e') {
1778 e.preventDefault();
1779 // Focus the issue title and make it editable if possible
1780 var titleEl = document.querySelector('.issues-title');
1781 if (titleEl) titleEl.scrollIntoView({block:'center'});
1782 }
1783 if (e.key === 'x') {
1784 e.preventDefault();
1785 var closeBtn = document.querySelector('button[formaction*="/close"], button[formaction*="/reopen"]');
1786 if (closeBtn) {
1787 var confirmed = window.confirm('Close/reopen this issue?');
1788 if (confirmed) closeBtn.click();
1789 }
1790 }
1791 if (e.key === 'Escape') {
1792 var focused = document.activeElement;
1793 if (focused) focused.blur();
1794 }
1795 });
1796 })();
1797 ` }} />
17141798 </Layout>
17151799 );
17161800});
Modifiedsrc/routes/pulls.tsx+95−0View fileUnifiedSplit
@@ -5303,6 +5303,101 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
53035303 )}
53045304 </>
53055305 )}
5306 {/* Keyboard hint bar — shown at the bottom of PR pages */}
5307 <div class="kbd-hints" aria-label="Keyboard shortcuts for this pull request">
5308 <kbd>c</kbd> comment · <kbd>e</kbd> edit title · <kbd>m</kbd> merge · <kbd>a</kbd> approve · <kbd>r</kbd> request changes · <kbd>?</kbd> shortcuts
5309 </div>
5310 <style dangerouslySetInnerHTML={{ __html: `
5311 .kbd-hints {
5312 position: fixed;
5313 bottom: 0;
5314 left: 0;
5315 right: 0;
5316 z-index: 90;
5317 padding: 6px 24px;
5318 background: var(--bg-secondary);
5319 border-top: 1px solid var(--border);
5320 font-size: 12px;
5321 color: var(--text-muted);
5322 display: flex;
5323 align-items: center;
5324 gap: 8px;
5325 flex-wrap: wrap;
5326 }
5327 .kbd-hints kbd {
5328 font-family: var(--font-mono);
5329 font-size: 10px;
5330 background: var(--bg-elevated);
5331 border: 1px solid var(--border);
5332 border-bottom-width: 2px;
5333 border-radius: 4px;
5334 padding: 1px 5px;
5335 color: var(--text);
5336 line-height: 1.5;
5337 }
5338 /* Padding so the page footer doesn't overlap the hint bar */
5339 main { padding-bottom: 40px; }
5340 ` }} />
5341 {/* Repo context commands for command palette */}
5342 <script
5343 id="cmdk-repo-context"
5344 dangerouslySetInnerHTML={{
5345 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
5346 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
5347 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
5348 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
5349 { label: `View commits — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/commits`, kw: "history log" },
5350 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
5351 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
5352 ])};`,
5353 }}
5354 />
5355 {/* PR keyboard shortcuts script */}
5356 <script dangerouslySetInnerHTML={{ __html: `
5357 (function(){
5358 var commentBox = document.querySelector('textarea[name="body"]');
5359 var mergeBtn = document.querySelector('[data-merge-btn], .prs-merge-btn, button[form*="merge"], form[action*="/merge"] button[type="submit"]');
5360 var editBtn = document.getElementById('pr-edit-toggle');
5361 var approveUrl = ${JSON.stringify(`/${ownerName}/${repoName}/pulls/${pr.number}/review`)};
5362
5363 function isTyping(t){
5364 t = t || {};
5365 var tag = (t.tagName || '').toLowerCase();
5366 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
5367 }
5368
5369 document.addEventListener('keydown', function(e){
5370 if (isTyping(e.target)) return;
5371 if (e.metaKey || e.ctrlKey || e.altKey) return;
5372 if (e.key === 'c') {
5373 e.preventDefault();
5374 if (commentBox) { commentBox.focus(); commentBox.scrollIntoView({block:'center'}); }
5375 }
5376 if (e.key === 'e') {
5377 e.preventDefault();
5378 if (editBtn) { editBtn.click(); }
5379 }
5380 if (e.key === 'm') {
5381 e.preventDefault();
5382 var mBtn = document.querySelector('.prs-merge-btn, form[action*="/merge"] button[type="submit"]');
5383 if (mBtn) { mBtn.focus(); mBtn.scrollIntoView({block:'center'}); }
5384 }
5385 if (e.key === 'a') {
5386 e.preventDefault();
5387 // Navigate to approve review page
5388 window.location.href = approveUrl + '?action=approve';
5389 }
5390 if (e.key === 'r') {
5391 e.preventDefault();
5392 window.location.href = approveUrl + '?action=request_changes';
5393 }
5394 if (e.key === 'Escape') {
5395 var focused = document.activeElement;
5396 if (focused) focused.blur();
5397 }
5398 });
5399 })();
5400 ` }} />
53065401 </Layout>
53075402 );
53085403});
Modifiedsrc/routes/search.tsx+14−3View fileUnifiedSplit
@@ -943,10 +943,12 @@ search.get("/shortcuts", async (c) => {
943943 const unread = user ? await getUnreadCount(user.id) : 0;
944944 const shortcuts: Array<{ keys: string; desc: string; section?: string }> = [
945945 { keys: "/", desc: "Focus global search", section: "Global" },
946 { keys: "Cmd/Ctrl + K", desc: "Open command palette / AI assistant" },
946 { keys: "Cmd/Ctrl + K", desc: "Open command palette (merges repo-context commands on repo pages)" },
947947 { keys: "?", desc: "Show keyboard shortcuts" },
948 { keys: "n", desc: "New repository" },
949 { keys: "g d", desc: "Go to dashboard" },
948 { keys: "n", desc: "New repository (or wait for chord)" },
949 { keys: "n i", desc: "New issue in current repo (on repo pages)", section: "Repo chords" },
950 { keys: "n p", desc: "New pull request in current repo (on repo pages)" },
951 { keys: "g d", desc: "Go to dashboard", section: "Global" },
950952 { keys: "g n", desc: "Go to notifications" },
951953 { keys: "g e", desc: "Go to explore" },
952954 { keys: "g a", desc: "Go to AI ask" },
@@ -954,6 +956,15 @@ search.get("/shortcuts", async (c) => {
954956 { keys: "k", desc: "Move selection up on list pages" },
955957 { keys: "Enter", desc: "Open selected item" },
956958 { keys: "x", desc: "Toggle select on focused item" },
959 { keys: "c", desc: "Focus comment textarea", section: "Pull requests" },
960 { keys: "e", desc: "Edit PR title" },
961 { keys: "m", desc: "Focus merge button" },
962 { keys: "a", desc: "Approve (navigate to review page with approve action)" },
963 { keys: "r", desc: "Request changes (navigate to review page)" },
964 { keys: "Escape", desc: "Blur current focus" },
965 { keys: "c", desc: "Focus comment textarea", section: "Issues" },
966 { keys: "e", desc: "Scroll to issue title" },
967 { keys: "x", desc: "Close/reopen issue (with confirm)" },
957968 ];
958969
959970 const sections = [...new Set(shortcuts.map((s) => s.section ?? "Global"))];
Modifiedsrc/routes/web.tsx+429−0View fileUnifiedSplit
@@ -19,6 +19,7 @@ import {
1919 issues,
2020 labels,
2121 issueLabels,
22 repoOnboardingData,
2223} from "../db/schema";
2324import { Layout } from "../views/layout";
2425import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
@@ -2349,6 +2350,383 @@ web.post("/:owner/:repo/star", requireAuth, async (c) => {
23492350 return c.redirect(`/${ownerName}/${repoName}`);
23502351});
23512352
2353// ---------------------------------------------------------------------------
2354// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2355// ---------------------------------------------------------------------------
2356const obCss = `
2357 .ob-card {
2358 position: relative;
2359 margin: 14px 0 18px;
2360 background: linear-gradient(135deg, rgba(140,109,255,0.08), rgba(54,197,214,0.05));
2361 border: 1px solid rgba(140,109,255,0.30);
2362 border-radius: 14px;
2363 overflow: hidden;
2364 }
2365 .ob-card::before {
2366 content: '';
2367 position: absolute;
2368 top: 0; left: 0; right: 0;
2369 height: 2px;
2370 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2371 pointer-events: none;
2372 }
2373 .ob-header {
2374 padding: 16px 20px 10px;
2375 border-bottom: 1px solid var(--border);
2376 }
2377 .ob-header h3 {
2378 font-size: 16px;
2379 font-weight: 700;
2380 margin: 0 0 4px;
2381 color: var(--text-strong);
2382 }
2383 .ob-header p {
2384 font-size: 13px;
2385 color: var(--text-muted);
2386 margin: 0;
2387 }
2388 .ob-sections {
2389 display: grid;
2390 grid-template-columns: repeat(3, 1fr);
2391 gap: 0;
2392 }
2393 @media (max-width: 760px) {
2394 .ob-sections { grid-template-columns: 1fr; }
2395 }
2396 .ob-section {
2397 padding: 14px 20px;
2398 border-right: 1px solid var(--border);
2399 }
2400 .ob-section:last-child { border-right: none; }
2401 .ob-section h4 {
2402 font-size: 12px;
2403 font-weight: 700;
2404 text-transform: uppercase;
2405 letter-spacing: 0.08em;
2406 color: var(--accent);
2407 margin: 0 0 8px;
2408 }
2409 .ob-preview {
2410 font-family: var(--font-mono);
2411 font-size: 11.5px;
2412 background: var(--bg);
2413 border: 1px solid var(--border);
2414 border-radius: 6px;
2415 padding: 8px 10px;
2416 color: var(--text-muted);
2417 line-height: 1.55;
2418 margin-bottom: 8px;
2419 white-space: pre-wrap;
2420 overflow: hidden;
2421 max-height: 80px;
2422 position: relative;
2423 }
2424 .ob-preview::after {
2425 content: '';
2426 position: absolute;
2427 bottom: 0; left: 0; right: 0;
2428 height: 24px;
2429 background: linear-gradient(transparent, var(--bg));
2430 pointer-events: none;
2431 }
2432 .ob-labels {
2433 display: flex;
2434 flex-wrap: wrap;
2435 gap: 5px;
2436 margin-bottom: 8px;
2437 }
2438 .ob-label-chip {
2439 display: inline-flex;
2440 align-items: center;
2441 padding: 2px 8px;
2442 border-radius: 9999px;
2443 font-size: 11.5px;
2444 font-weight: 600;
2445 line-height: 1.5;
2446 border: 1px solid transparent;
2447 }
2448 .ob-section ul {
2449 margin: 0;
2450 padding: 0 0 0 16px;
2451 font-size: 13px;
2452 color: var(--text-muted);
2453 line-height: 1.7;
2454 }
2455 .ob-section ul li { margin-bottom: 2px; }
2456 .ob-footer {
2457 display: flex;
2458 align-items: center;
2459 justify-content: flex-end;
2460 padding: 10px 20px;
2461 border-top: 1px solid var(--border);
2462 gap: 10px;
2463 }
2464 .ob-dismiss {
2465 appearance: none;
2466 background: transparent;
2467 border: 1px solid var(--border);
2468 color: var(--text-muted);
2469 border-radius: 6px;
2470 padding: 5px 12px;
2471 font-size: 12.5px;
2472 font-family: inherit;
2473 cursor: pointer;
2474 transition: background var(--t-fast), color var(--t-fast);
2475 }
2476 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2477 .btn-sm {
2478 appearance: none;
2479 background: var(--bg-elevated);
2480 border: 1px solid var(--border);
2481 color: var(--text-strong);
2482 border-radius: 6px;
2483 padding: 5px 12px;
2484 font-size: 12.5px;
2485 font-weight: 600;
2486 font-family: inherit;
2487 cursor: pointer;
2488 text-decoration: none;
2489 display: inline-flex;
2490 align-items: center;
2491 transition: background var(--t-fast);
2492 }
2493 .btn-sm:hover { background: var(--bg-hover); }
2494 .btn-sm.btn-primary {
2495 background: var(--accent);
2496 color: #fff;
2497 border-color: var(--accent);
2498 }
2499 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2500`;
2501
2502// Onboarding card component — shown to repo owner until dismissed
2503function RepoOnboardingCard({
2504 owner,
2505 repo,
2506 data,
2507}: {
2508 owner: string;
2509 repo: string;
2510 data: typeof repoOnboardingData.$inferSelect;
2511}) {
2512 const labels = (data.suggestedLabels ?? []) as Array<{
2513 name: string;
2514 color: string;
2515 description: string;
2516 }>;
2517 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2518 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2519
2520 return (
2521 <>
2522 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2523 <div class="ob-card" id="repo-onboarding">
2524 <div class="ob-header">
2525 <h3>Get started with {owner}/{repo}</h3>
2526 <p>
2527 Detected: {data.detectedLanguage ?? "Unknown"}
2528 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2529 Here’s what we suggest to hit the ground running.
2530 </p>
2531 </div>
2532 <div class="ob-sections">
2533 <div class="ob-section">
2534 <h4>Suggested README</h4>
2535 <div class="ob-preview">{readmePreview}</div>
2536 <button
2537 class="btn-sm"
2538 type="button"
2539 onclick={`(function(){var t=${JSON.stringify(data.suggestedReadme ?? "")};try{navigator.clipboard.writeText(t).then(function(){var b=document.querySelector('.ob-copy-btn');if(b){b.textContent='Copied!';setTimeout(function(){b.textContent='Copy to clipboard';},2000);}});}catch(_){};})()`}
2540 aria-label="Copy suggested README to clipboard"
2541 >
2542 Copy to clipboard
2543 </button>
2544 </div>
2545 <div class="ob-section">
2546 <h4>Suggested labels ({labels.length})</h4>
2547 <div class="ob-labels">
2548 {labels.slice(0, 6).map((l) => (
2549 <span
2550 class="ob-label-chip"
2551 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2552 title={l.description}
2553 >
2554 {l.name}
2555 </span>
2556 ))}
2557 </div>
2558 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2559 <button class="btn-sm btn-primary" type="submit">
2560 Create all labels
2561 </button>
2562 </form>
2563 </div>
2564 <div class="ob-section">
2565 <h4>First steps</h4>
2566 <ul>
2567 {suggestions.map((s) => (
2568 <li>{s}</li>
2569 ))}
2570 </ul>
2571 </div>
2572 </div>
2573 <div class="ob-footer">
2574 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2575 <button class="ob-dismiss" type="submit">
2576 Dismiss
2577 </button>
2578 </form>
2579 </div>
2580 <script
2581 dangerouslySetInnerHTML={{
2582 __html: `
2583 (function(){
2584 var card = document.getElementById('repo-onboarding');
2585 if (!card) return;
2586 document.addEventListener('keydown', function(e){
2587 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2588 card.style.display = 'none';
2589 }
2590 });
2591 })();
2592 `,
2593 }}
2594 />
2595 </div>
2596 </>
2597 );
2598}
2599
2600// ---------------------------------------------------------------------------
2601// Setup routes — create suggested labels + dismiss onboarding
2602// ---------------------------------------------------------------------------
2603
2604// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2605// requireAuth + write access. Idempotent (label UPSERT by name).
2606web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2607 const { owner, repo } = c.req.param();
2608 const user = c.get("user")!;
2609
2610 // Resolve repo + verify write access
2611 let repoRow: { id: string; ownerId: string } | null = null;
2612 try {
2613 const [ownerUser] = await db
2614 .select({ id: users.id })
2615 .from(users)
2616 .where(eq(users.username, owner))
2617 .limit(1);
2618 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2619 const [r] = await db
2620 .select({ id: repositories.id, ownerId: repositories.ownerId })
2621 .from(repositories)
2622 .where(
2623 and(
2624 eq(repositories.ownerId, ownerUser.id),
2625 eq(repositories.name, repo)
2626 )
2627 )
2628 .limit(1);
2629 repoRow = r ?? null;
2630 } catch {
2631 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2632 }
2633 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2634 if (repoRow.ownerId !== user.id) {
2635 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2636 }
2637
2638 // Fetch the onboarding data
2639 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2640 try {
2641 const [r] = await db
2642 .select()
2643 .from(repoOnboardingData)
2644 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2645 .limit(1);
2646 obRow = r ?? null;
2647 } catch {
2648 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2649 }
2650 if (!obRow) {
2651 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2652 }
2653
2654 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2655 name: string;
2656 color: string;
2657 description: string;
2658 }>;
2659
2660 // Create labels — import the labels table which was already imported
2661 let created = 0;
2662 for (const l of suggestedLabels) {
2663 try {
2664 await db
2665 .insert(labels)
2666 .values({
2667 repositoryId: repoRow.id,
2668 name: l.name,
2669 color: l.color,
2670 description: l.description ?? "",
2671 })
2672 .onConflictDoNothing();
2673 created++;
2674 } catch {
2675 /* skip duplicates */
2676 }
2677 }
2678
2679 // Mark onboarding dismissed
2680 try {
2681 await db
2682 .update(repositories)
2683 .set({ onboardingShown: true })
2684 .where(eq(repositories.id, repoRow.id));
2685 } catch {
2686 /* ignore */
2687 }
2688
2689 return c.redirect(
2690 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2691 );
2692});
2693
2694// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2695web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2696 const { owner, repo } = c.req.param();
2697 const user = c.get("user")!;
2698
2699 try {
2700 const [ownerUser] = await db
2701 .select({ id: users.id })
2702 .from(users)
2703 .where(eq(users.username, owner))
2704 .limit(1);
2705 if (ownerUser) {
2706 const [r] = await db
2707 .select({ id: repositories.id, ownerId: repositories.ownerId })
2708 .from(repositories)
2709 .where(
2710 and(
2711 eq(repositories.ownerId, ownerUser.id),
2712 eq(repositories.name, repo)
2713 )
2714 )
2715 .limit(1);
2716 if (r && r.ownerId === user.id) {
2717 await db
2718 .update(repositories)
2719 .set({ onboardingShown: true })
2720 .where(eq(repositories.id, r.id));
2721 }
2722 }
2723 } catch {
2724 /* swallow — dismiss should never fail visibly */
2725 }
2726
2727 return c.redirect(`/${owner}/${repo}`);
2728});
2729
23522730// Repository overview — file tree at HEAD
23532731web.get("/:owner/:repo", async (c) => {
23542732 const { owner, repo } = c.req.param();
@@ -2586,6 +2964,32 @@ web.get("/:owner/:repo", async (c) => {
25862964 }
25872965 }
25882966
2967 // Onboarding card — shown to repo owner until dismissed. Best-effort;
2968 // if the DB is down the card simply won't appear. Only the owner sees it.
2969 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2970 let showOnboarding = false;
2971 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
2972 try {
2973 // Check if onboarding_shown is still false (not yet dismissed)
2974 const [repoRow2] = await db
2975 .select({ onboardingShown: repositories.onboardingShown })
2976 .from(repositories)
2977 .where(eq(repositories.id, repoId))
2978 .limit(1);
2979 if (repoRow2 && !repoRow2.onboardingShown) {
2980 const [obRow] = await db
2981 .select()
2982 .from(repoOnboardingData)
2983 .where(eq(repoOnboardingData.repositoryId, repoId))
2984 .limit(1);
2985 onboardingRow = obRow ?? null;
2986 showOnboarding = !!onboardingRow;
2987 }
2988 } catch {
2989 /* swallow — onboarding is optional */
2990 }
2991 }
2992
25892993 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
25902994 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
25912995 const repoHomeCss = `
@@ -3343,6 +3747,24 @@ web.get("/:owner/:repo", async (c) => {
33433747 twitterCard="summary"
33443748 >
33453749 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
3750 {/* Repo-context commands for the command palette (Feature 2) */}
3751 <script
3752 id="cmdk-repo-context"
3753 dangerouslySetInnerHTML={{
3754 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3755 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3756 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3757 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3758 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3759 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3760 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3761 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3762 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3763 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3764 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3765 ])};`,
3766 }}
3767 />
33463768 <div class="repo-home-hero">
33473769 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
33483770 <div class="repo-home-hero-orb" />
@@ -3453,6 +3875,13 @@ web.get("/:owner/:repo", async (c) => {
34533875 repo={repo}
34543876 count={repoHomePendingCount}
34553877 />
3878 {showOnboarding && onboardingRow && (
3879 <RepoOnboardingCard
3880 owner={owner}
3881 repo={repo}
3882 data={onboardingRow}
3883 />
3884 )}
34563885 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
34573886 row sits just below the nav as a slim CTA strip. Scoped under
34583887 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
Modifiedsrc/views/layout.tsx+34−3View fileUnifiedSplit
@@ -820,6 +820,13 @@ const navScript = `
820820 list.innerHTML = html;
821821 }
822822
823 function getAllCommands(){
824 // Merge global COMMANDS with repo-context commands injected by repo pages.
825 var extra = (window.__CMDK_REPO_COMMANDS && Array.isArray(window.__CMDK_REPO_COMMANDS))
826 ? window.__CMDK_REPO_COMMANDS : [];
827 return COMMANDS.concat(extra);
828 }
829
823830 function openPalette(){
824831 backdrop = document.getElementById('cmdk-backdrop');
825832 panel = document.getElementById('cmdk-panel');
@@ -830,7 +837,7 @@ const navScript = `
830837 panel.style.display = 'block';
831838 input.value = '';
832839 selected = 0;
833 filtered = COMMANDS.slice();
840 filtered = getAllCommands();
834841 render();
835842 input.focus();
836843 }
@@ -850,7 +857,7 @@ const navScript = `
850857 document.addEventListener('input', function(e){
851858 if (e.target && e.target.id === 'cmdk-input') {
852859 var q = e.target.value;
853 filtered = COMMANDS.filter(function(c){ return fuzzyMatch(c, q); });
860 filtered = getAllCommands().filter(function(c){ return fuzzyMatch(c, q); });
854861 selected = 0;
855862 render();
856863 }
@@ -896,7 +903,15 @@ const navScript = `
896903 e.preventDefault(); window.location.href = '/shortcuts'; return;
897904 }
898905 if (e.key === 'n' && !e.ctrlKey && !e.metaKey) {
899 e.preventDefault(); window.location.href = '/new'; return;
906 // Start 'n' chord — wait 1.2s for next key (i → issue, p → PR).
907 // If no second key arrives, navigate to /new (create repo).
908 chord = 'n';
909 clearTimeout(chordTimer);
910 chordTimer = setTimeout(function(){
911 if (chord === 'n') { window.location.href = '/new'; }
912 chord = null;
913 }, 1200);
914 return;
900915 }
901916 // "g" chord
902917 if (e.key === 'g' && !e.ctrlKey && !e.metaKey) {
@@ -912,6 +927,22 @@ const navScript = `
912927 else if (e.key === 'a') { e.preventDefault(); window.location.href = '/ask'; }
913928 chord = null;
914929 }
930 // "n" chord — new issue / new PR (repo-context-aware)
931 if (chord === 'n') {
932 var repoCtx = window.__CMDK_REPO_COMMANDS;
933 var newIssuePath = repoCtx && repoCtx.length ? repoCtx[0].href.replace('/issues/new', '/issues/new') : null;
934 // Find the "new issue" and "new PR" hrefs from repo context commands
935 var issueCmd = repoCtx && repoCtx.find(function(c){ return c.href && c.href.indexOf('/issues/new') !== -1; });
936 var prCmd = repoCtx && repoCtx.find(function(c){ return c.href && c.href.indexOf('/pulls/new') !== -1; });
937 if (e.key === 'i' && issueCmd) {
938 e.preventDefault(); clearTimeout(chordTimer); chord = null;
939 window.location.href = issueCmd.href; return;
940 }
941 if (e.key === 'p' && prCmd) {
942 e.preventDefault(); clearTimeout(chordTimer); chord = null;
943 window.location.href = prCmd.href; return;
944 }
945 }
915946 // j/k list navigation — move through .prs-row, .issue-row, .notif-item rows
916947 if (e.key === 'j' || e.key === 'k') {
917948 var selectors = '.prs-row, .issue-row, .issue-list-item, .notif-item, .repo-item, .exp-repo-card, .disc-row';
918949