Commit9c5223funknown_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+1242−69c5223f480bda83594395e15466dbd69708f4581
9 changed files+1242−6
Addeddrizzle/0088_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+31−0View fileUnifiedSplit
@@ -236,6 +236,9 @@ export const repositories = pgTable(
236236 dataRegion: text("data_region").default("us").notNull(),
237237 previewBuildCommand: text("preview_build_command"),
238238 previewOutputDir: text("preview_output_dir").default("dist"),
239 // Migration 0088 — smart empty states. Set to true once the onboarding
240 // card has been dismissed by the repo owner. Generated on first push.
241 onboardingShown: boolean("onboarding_shown").default(false).notNull(),
239242 },
240243 (table) => [
241244 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
@@ -4254,3 +4257,31 @@ export const prPreviews = pgTable(
42544257
42554258export type PrPreview = typeof prPreviews.$inferSelect;
42564259export type NewPrPreview = typeof prPreviews.$inferInsert;
4260
4261// ---------------------------------------------------------------------------
4262// Migration 0088 — Smart empty states: repo onboarding data
4263// Generated by generateRepoOnboarding() on first push to a repo.
4264// ---------------------------------------------------------------------------
4265export const repoOnboardingData = pgTable("repo_onboarding_data", {
4266 repositoryId: uuid("repository_id")
4267 .primaryKey()
4268 .references(() => repositories.id, { onDelete: "cascade" }),
4269 detectedLanguage: text("detected_language"),
4270 detectedFramework: text("detected_framework"),
4271 suggestedReadme: text("suggested_readme"),
4272 suggestedLabels: jsonb("suggested_labels")
4273 .notNull()
4274 .default([])
4275 .$type<Array<{ name: string; color: string; description: string }>>(),
4276 suggestedGatesConfig: text("suggested_gates_config"),
4277 firstCommitSuggestions: jsonb("first_commit_suggestions")
4278 .notNull()
4279 .default([])
4280 .$type<string[]>(),
4281 generatedAt: timestamp("generated_at", { withTimezone: true })
4282 .notNull()
4283 .defaultNow(),
4284});
4285
4286export type RepoOnboardingData = typeof repoOnboardingData.$inferSelect;
4287export type NewRepoOnboardingData = typeof repoOnboardingData.$inferInsert;
Modifiedsrc/hooks/post-receive.ts+46−0View fileUnifiedSplit
@@ -36,6 +36,7 @@ import {
3636 startDeployRow,
3737} from "../lib/server-target-store";
3838import { deployToTarget } from "../lib/server-targets";
39import { ensureRepoOnboarding } from "../lib/repo-onboarding";
3940
4041interface PushRef {
4142 oldSha: string;
@@ -160,6 +161,15 @@ export async function onPostReceive(
160161 console.warn("[ai-doc-updater] dispatch error:", err)
161162 );
162163
164 // 4g. Smart empty states — repo onboarding. On the very first push to a
165 // repo's default branch (oldSha all-zeros), generate a README draft,
166 // suggested labels, and gates.yml starter using Claude Sonnet.
167 // Idempotent: ensureRepoOnboarding skips if a row already exists.
168 // Fire-and-forget; never blocks the push path.
169 void fireRepoOnboarding(owner, repo, refs).catch((err) =>
170 console.warn("[repo-onboarding] dispatch error:", err)
171 );
172
163173 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
164174 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
165175 // default branch. The branch case (`Main` vs `main`) is determined by
@@ -780,6 +790,41 @@ async function fireDependencyScan(
780790 }
781791}
782792
793/**
794 * Migration 0088 — trigger repo onboarding on first push to the default branch.
795 * Detects "first push" by checking whether oldSha is all-zeros on a push to
796 * the repo's default branch (or any branch for a repo with no prior history).
797 * Resolves the repo DB id, then calls ensureRepoOnboarding which is idempotent.
798 * Never throws.
799 */
800async function fireRepoOnboarding(
801 owner: string,
802 repo: string,
803 refs: PushRef[]
804): Promise<void> {
805 // Only fire on first push — oldSha all-zeros means the branch is brand-new.
806 const firstPushRefs = refs.filter(
807 (r) => r.refName.startsWith("refs/heads/") && /^0+$/.test(r.oldSha) && !r.newSha.startsWith("0000")
808 );
809 if (firstPushRefs.length === 0) return;
810
811 let repositoryId = "";
812 try {
813 const [row] = await db
814 .select({ id: repositories.id })
815 .from(repositories)
816 .innerJoin(users, eq(repositories.ownerId, users.id))
817 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
818 .limit(1);
819 repositoryId = row?.id || "";
820 } catch {
821 return;
822 }
823 if (!repositoryId) return;
824
825 await ensureRepoOnboarding(repositoryId, owner, repo);
826}
827
783828/** Test-only access to internal helpers. */
784829export const __test = {
785830 triggerCrontechDeploy,
@@ -792,4 +837,5 @@ export const __test = {
792837 fireDocDriftCheck,
793838 fireServerTargetDeploys,
794839 fireDependencyScan,
840 fireRepoOnboarding,
795841};
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
@@ -1616,6 +1616,90 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
16161616 </form>
16171617 )}
16181618 </div>
1619 {/* Issue keyboard hints bar */}
1620 <div class="kbd-hints" aria-label="Keyboard shortcuts for this issue">
1621 <kbd>c</kbd> comment · <kbd>e</kbd> edit title · <kbd>x</kbd> close/reopen · <kbd>?</kbd> shortcuts
1622 </div>
1623 <style dangerouslySetInnerHTML={{ __html: `
1624 .kbd-hints {
1625 position: fixed;
1626 bottom: 0;
1627 left: 0;
1628 right: 0;
1629 z-index: 90;
1630 padding: 6px 24px;
1631 background: var(--bg-secondary);
1632 border-top: 1px solid var(--border);
1633 font-size: 12px;
1634 color: var(--text-muted);
1635 display: flex;
1636 align-items: center;
1637 gap: 8px;
1638 flex-wrap: wrap;
1639 }
1640 .kbd-hints kbd {
1641 font-family: var(--font-mono);
1642 font-size: 10px;
1643 background: var(--bg-elevated);
1644 border: 1px solid var(--border);
1645 border-bottom-width: 2px;
1646 border-radius: 4px;
1647 padding: 1px 5px;
1648 color: var(--text);
1649 line-height: 1.5;
1650 }
1651 main { padding-bottom: 40px; }
1652 ` }} />
1653 {/* Repo context commands for command palette */}
1654 <script
1655 id="cmdk-repo-context"
1656 dangerouslySetInnerHTML={{
1657 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
1658 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
1659 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
1660 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
1661 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
1662 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
1663 ])};`,
1664 }}
1665 />
1666 {/* Issue keyboard shortcuts */}
1667 <script dangerouslySetInnerHTML={{ __html: `
1668 (function(){
1669 function isTyping(t){
1670 t = t || {};
1671 var tag = (t.tagName || '').toLowerCase();
1672 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
1673 }
1674 document.addEventListener('keydown', function(e){
1675 if (isTyping(e.target)) return;
1676 if (e.metaKey || e.ctrlKey || e.altKey) return;
1677 if (e.key === 'c') {
1678 e.preventDefault();
1679 var box = document.querySelector('textarea[name="body"]');
1680 if (box) { box.focus(); box.scrollIntoView({block:'center'}); }
1681 }
1682 if (e.key === 'e') {
1683 e.preventDefault();
1684 // Focus the issue title and make it editable if possible
1685 var titleEl = document.querySelector('.issues-title');
1686 if (titleEl) titleEl.scrollIntoView({block:'center'});
1687 }
1688 if (e.key === 'x') {
1689 e.preventDefault();
1690 var closeBtn = document.querySelector('button[formaction*="/close"], button[formaction*="/reopen"]');
1691 if (closeBtn) {
1692 var confirmed = window.confirm('Close/reopen this issue?');
1693 if (confirmed) closeBtn.click();
1694 }
1695 }
1696 if (e.key === 'Escape') {
1697 var focused = document.activeElement;
1698 if (focused) focused.blur();
1699 }
1700 });
1701 })();
1702 ` }} />
16191703 </Layout>
16201704 );
16211705});
Modifiedsrc/routes/pulls.tsx+95−0View fileUnifiedSplit
@@ -4340,6 +4340,101 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
43404340 )}
43414341 </>
43424342 )}
4343 {/* Keyboard hint bar — shown at the bottom of PR pages */}
4344 <div class="kbd-hints" aria-label="Keyboard shortcuts for this pull request">
4345 <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
4346 </div>
4347 <style dangerouslySetInnerHTML={{ __html: `
4348 .kbd-hints {
4349 position: fixed;
4350 bottom: 0;
4351 left: 0;
4352 right: 0;
4353 z-index: 90;
4354 padding: 6px 24px;
4355 background: var(--bg-secondary);
4356 border-top: 1px solid var(--border);
4357 font-size: 12px;
4358 color: var(--text-muted);
4359 display: flex;
4360 align-items: center;
4361 gap: 8px;
4362 flex-wrap: wrap;
4363 }
4364 .kbd-hints kbd {
4365 font-family: var(--font-mono);
4366 font-size: 10px;
4367 background: var(--bg-elevated);
4368 border: 1px solid var(--border);
4369 border-bottom-width: 2px;
4370 border-radius: 4px;
4371 padding: 1px 5px;
4372 color: var(--text);
4373 line-height: 1.5;
4374 }
4375 /* Padding so the page footer doesn't overlap the hint bar */
4376 main { padding-bottom: 40px; }
4377 ` }} />
4378 {/* Repo context commands for command palette */}
4379 <script
4380 id="cmdk-repo-context"
4381 dangerouslySetInnerHTML={{
4382 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
4383 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
4384 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
4385 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
4386 { label: `View commits — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/commits`, kw: "history log" },
4387 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
4388 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
4389 ])};`,
4390 }}
4391 />
4392 {/* PR keyboard shortcuts script */}
4393 <script dangerouslySetInnerHTML={{ __html: `
4394 (function(){
4395 var commentBox = document.querySelector('textarea[name="body"]');
4396 var mergeBtn = document.querySelector('[data-merge-btn], .prs-merge-btn, button[form*="merge"], form[action*="/merge"] button[type="submit"]');
4397 var editBtn = document.getElementById('pr-edit-toggle');
4398 var approveUrl = ${JSON.stringify(`/${ownerName}/${repoName}/pulls/${pr.number}/review`)};
4399
4400 function isTyping(t){
4401 t = t || {};
4402 var tag = (t.tagName || '').toLowerCase();
4403 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
4404 }
4405
4406 document.addEventListener('keydown', function(e){
4407 if (isTyping(e.target)) return;
4408 if (e.metaKey || e.ctrlKey || e.altKey) return;
4409 if (e.key === 'c') {
4410 e.preventDefault();
4411 if (commentBox) { commentBox.focus(); commentBox.scrollIntoView({block:'center'}); }
4412 }
4413 if (e.key === 'e') {
4414 e.preventDefault();
4415 if (editBtn) { editBtn.click(); }
4416 }
4417 if (e.key === 'm') {
4418 e.preventDefault();
4419 var mBtn = document.querySelector('.prs-merge-btn, form[action*="/merge"] button[type="submit"]');
4420 if (mBtn) { mBtn.focus(); mBtn.scrollIntoView({block:'center'}); }
4421 }
4422 if (e.key === 'a') {
4423 e.preventDefault();
4424 // Navigate to approve review page
4425 window.location.href = approveUrl + '?action=approve';
4426 }
4427 if (e.key === 'r') {
4428 e.preventDefault();
4429 window.location.href = approveUrl + '?action=request_changes';
4430 }
4431 if (e.key === 'Escape') {
4432 var focused = document.activeElement;
4433 if (focused) focused.blur();
4434 }
4435 });
4436 })();
4437 ` }} />
43434438 </Layout>
43444439 );
43454440});
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";
@@ -2347,6 +2348,383 @@ web.post("/:owner/:repo/star", requireAuth, async (c) => {
23472348 return c.redirect(`/${ownerName}/${repoName}`);
23482349});
23492350
2351// ---------------------------------------------------------------------------
2352// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2353// ---------------------------------------------------------------------------
2354const obCss = `
2355 .ob-card {
2356 position: relative;
2357 margin: 14px 0 18px;
2358 background: linear-gradient(135deg, rgba(140,109,255,0.08), rgba(54,197,214,0.05));
2359 border: 1px solid rgba(140,109,255,0.30);
2360 border-radius: 14px;
2361 overflow: hidden;
2362 }
2363 .ob-card::before {
2364 content: '';
2365 position: absolute;
2366 top: 0; left: 0; right: 0;
2367 height: 2px;
2368 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2369 pointer-events: none;
2370 }
2371 .ob-header {
2372 padding: 16px 20px 10px;
2373 border-bottom: 1px solid var(--border);
2374 }
2375 .ob-header h3 {
2376 font-size: 16px;
2377 font-weight: 700;
2378 margin: 0 0 4px;
2379 color: var(--text-strong);
2380 }
2381 .ob-header p {
2382 font-size: 13px;
2383 color: var(--text-muted);
2384 margin: 0;
2385 }
2386 .ob-sections {
2387 display: grid;
2388 grid-template-columns: repeat(3, 1fr);
2389 gap: 0;
2390 }
2391 @media (max-width: 760px) {
2392 .ob-sections { grid-template-columns: 1fr; }
2393 }
2394 .ob-section {
2395 padding: 14px 20px;
2396 border-right: 1px solid var(--border);
2397 }
2398 .ob-section:last-child { border-right: none; }
2399 .ob-section h4 {
2400 font-size: 12px;
2401 font-weight: 700;
2402 text-transform: uppercase;
2403 letter-spacing: 0.08em;
2404 color: var(--accent);
2405 margin: 0 0 8px;
2406 }
2407 .ob-preview {
2408 font-family: var(--font-mono);
2409 font-size: 11.5px;
2410 background: var(--bg);
2411 border: 1px solid var(--border);
2412 border-radius: 6px;
2413 padding: 8px 10px;
2414 color: var(--text-muted);
2415 line-height: 1.55;
2416 margin-bottom: 8px;
2417 white-space: pre-wrap;
2418 overflow: hidden;
2419 max-height: 80px;
2420 position: relative;
2421 }
2422 .ob-preview::after {
2423 content: '';
2424 position: absolute;
2425 bottom: 0; left: 0; right: 0;
2426 height: 24px;
2427 background: linear-gradient(transparent, var(--bg));
2428 pointer-events: none;
2429 }
2430 .ob-labels {
2431 display: flex;
2432 flex-wrap: wrap;
2433 gap: 5px;
2434 margin-bottom: 8px;
2435 }
2436 .ob-label-chip {
2437 display: inline-flex;
2438 align-items: center;
2439 padding: 2px 8px;
2440 border-radius: 9999px;
2441 font-size: 11.5px;
2442 font-weight: 600;
2443 line-height: 1.5;
2444 border: 1px solid transparent;
2445 }
2446 .ob-section ul {
2447 margin: 0;
2448 padding: 0 0 0 16px;
2449 font-size: 13px;
2450 color: var(--text-muted);
2451 line-height: 1.7;
2452 }
2453 .ob-section ul li { margin-bottom: 2px; }
2454 .ob-footer {
2455 display: flex;
2456 align-items: center;
2457 justify-content: flex-end;
2458 padding: 10px 20px;
2459 border-top: 1px solid var(--border);
2460 gap: 10px;
2461 }
2462 .ob-dismiss {
2463 appearance: none;
2464 background: transparent;
2465 border: 1px solid var(--border);
2466 color: var(--text-muted);
2467 border-radius: 6px;
2468 padding: 5px 12px;
2469 font-size: 12.5px;
2470 font-family: inherit;
2471 cursor: pointer;
2472 transition: background var(--t-fast), color var(--t-fast);
2473 }
2474 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2475 .btn-sm {
2476 appearance: none;
2477 background: var(--bg-elevated);
2478 border: 1px solid var(--border);
2479 color: var(--text-strong);
2480 border-radius: 6px;
2481 padding: 5px 12px;
2482 font-size: 12.5px;
2483 font-weight: 600;
2484 font-family: inherit;
2485 cursor: pointer;
2486 text-decoration: none;
2487 display: inline-flex;
2488 align-items: center;
2489 transition: background var(--t-fast);
2490 }
2491 .btn-sm:hover { background: var(--bg-hover); }
2492 .btn-sm.btn-primary {
2493 background: var(--accent);
2494 color: #fff;
2495 border-color: var(--accent);
2496 }
2497 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2498`;
2499
2500// Onboarding card component — shown to repo owner until dismissed
2501function RepoOnboardingCard({
2502 owner,
2503 repo,
2504 data,
2505}: {
2506 owner: string;
2507 repo: string;
2508 data: typeof repoOnboardingData.$inferSelect;
2509}) {
2510 const labels = (data.suggestedLabels ?? []) as Array<{
2511 name: string;
2512 color: string;
2513 description: string;
2514 }>;
2515 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2516 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2517
2518 return (
2519 <>
2520 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2521 <div class="ob-card" id="repo-onboarding">
2522 <div class="ob-header">
2523 <h3>Get started with {owner}/{repo}</h3>
2524 <p>
2525 Detected: {data.detectedLanguage ?? "Unknown"}
2526 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2527 Here’s what we suggest to hit the ground running.
2528 </p>
2529 </div>
2530 <div class="ob-sections">
2531 <div class="ob-section">
2532 <h4>Suggested README</h4>
2533 <div class="ob-preview">{readmePreview}</div>
2534 <button
2535 class="btn-sm"
2536 type="button"
2537 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(_){};})()`}
2538 aria-label="Copy suggested README to clipboard"
2539 >
2540 Copy to clipboard
2541 </button>
2542 </div>
2543 <div class="ob-section">
2544 <h4>Suggested labels ({labels.length})</h4>
2545 <div class="ob-labels">
2546 {labels.slice(0, 6).map((l) => (
2547 <span
2548 class="ob-label-chip"
2549 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2550 title={l.description}
2551 >
2552 {l.name}
2553 </span>
2554 ))}
2555 </div>
2556 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2557 <button class="btn-sm btn-primary" type="submit">
2558 Create all labels
2559 </button>
2560 </form>
2561 </div>
2562 <div class="ob-section">
2563 <h4>First steps</h4>
2564 <ul>
2565 {suggestions.map((s) => (
2566 <li>{s}</li>
2567 ))}
2568 </ul>
2569 </div>
2570 </div>
2571 <div class="ob-footer">
2572 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2573 <button class="ob-dismiss" type="submit">
2574 Dismiss
2575 </button>
2576 </form>
2577 </div>
2578 <script
2579 dangerouslySetInnerHTML={{
2580 __html: `
2581 (function(){
2582 var card = document.getElementById('repo-onboarding');
2583 if (!card) return;
2584 document.addEventListener('keydown', function(e){
2585 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2586 card.style.display = 'none';
2587 }
2588 });
2589 })();
2590 `,
2591 }}
2592 />
2593 </div>
2594 </>
2595 );
2596}
2597
2598// ---------------------------------------------------------------------------
2599// Setup routes — create suggested labels + dismiss onboarding
2600// ---------------------------------------------------------------------------
2601
2602// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2603// requireAuth + write access. Idempotent (label UPSERT by name).
2604web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2605 const { owner, repo } = c.req.param();
2606 const user = c.get("user")!;
2607
2608 // Resolve repo + verify write access
2609 let repoRow: { id: string; ownerId: string } | null = null;
2610 try {
2611 const [ownerUser] = await db
2612 .select({ id: users.id })
2613 .from(users)
2614 .where(eq(users.username, owner))
2615 .limit(1);
2616 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2617 const [r] = await db
2618 .select({ id: repositories.id, ownerId: repositories.ownerId })
2619 .from(repositories)
2620 .where(
2621 and(
2622 eq(repositories.ownerId, ownerUser.id),
2623 eq(repositories.name, repo)
2624 )
2625 )
2626 .limit(1);
2627 repoRow = r ?? null;
2628 } catch {
2629 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2630 }
2631 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2632 if (repoRow.ownerId !== user.id) {
2633 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2634 }
2635
2636 // Fetch the onboarding data
2637 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2638 try {
2639 const [r] = await db
2640 .select()
2641 .from(repoOnboardingData)
2642 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2643 .limit(1);
2644 obRow = r ?? null;
2645 } catch {
2646 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2647 }
2648 if (!obRow) {
2649 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2650 }
2651
2652 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2653 name: string;
2654 color: string;
2655 description: string;
2656 }>;
2657
2658 // Create labels — import the labels table which was already imported
2659 let created = 0;
2660 for (const l of suggestedLabels) {
2661 try {
2662 await db
2663 .insert(labels)
2664 .values({
2665 repositoryId: repoRow.id,
2666 name: l.name,
2667 color: l.color,
2668 description: l.description ?? "",
2669 })
2670 .onConflictDoNothing();
2671 created++;
2672 } catch {
2673 /* skip duplicates */
2674 }
2675 }
2676
2677 // Mark onboarding dismissed
2678 try {
2679 await db
2680 .update(repositories)
2681 .set({ onboardingShown: true })
2682 .where(eq(repositories.id, repoRow.id));
2683 } catch {
2684 /* ignore */
2685 }
2686
2687 return c.redirect(
2688 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2689 );
2690});
2691
2692// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2693web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2694 const { owner, repo } = c.req.param();
2695 const user = c.get("user")!;
2696
2697 try {
2698 const [ownerUser] = await db
2699 .select({ id: users.id })
2700 .from(users)
2701 .where(eq(users.username, owner))
2702 .limit(1);
2703 if (ownerUser) {
2704 const [r] = await db
2705 .select({ id: repositories.id, ownerId: repositories.ownerId })
2706 .from(repositories)
2707 .where(
2708 and(
2709 eq(repositories.ownerId, ownerUser.id),
2710 eq(repositories.name, repo)
2711 )
2712 )
2713 .limit(1);
2714 if (r && r.ownerId === user.id) {
2715 await db
2716 .update(repositories)
2717 .set({ onboardingShown: true })
2718 .where(eq(repositories.id, r.id));
2719 }
2720 }
2721 } catch {
2722 /* swallow — dismiss should never fail visibly */
2723 }
2724
2725 return c.redirect(`/${owner}/${repo}`);
2726});
2727
23502728// Repository overview — file tree at HEAD
23512729web.get("/:owner/:repo", async (c) => {
23522730 const { owner, repo } = c.req.param();
@@ -2584,6 +2962,32 @@ web.get("/:owner/:repo", async (c) => {
25842962 }
25852963 }
25862964
2965 // Onboarding card — shown to repo owner until dismissed. Best-effort;
2966 // if the DB is down the card simply won't appear. Only the owner sees it.
2967 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2968 let showOnboarding = false;
2969 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
2970 try {
2971 // Check if onboarding_shown is still false (not yet dismissed)
2972 const [repoRow2] = await db
2973 .select({ onboardingShown: repositories.onboardingShown })
2974 .from(repositories)
2975 .where(eq(repositories.id, repoId))
2976 .limit(1);
2977 if (repoRow2 && !repoRow2.onboardingShown) {
2978 const [obRow] = await db
2979 .select()
2980 .from(repoOnboardingData)
2981 .where(eq(repoOnboardingData.repositoryId, repoId))
2982 .limit(1);
2983 onboardingRow = obRow ?? null;
2984 showOnboarding = !!onboardingRow;
2985 }
2986 } catch {
2987 /* swallow — onboarding is optional */
2988 }
2989 }
2990
25872991 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
25882992 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
25892993 const repoHomeCss = `
@@ -3341,6 +3745,24 @@ web.get("/:owner/:repo", async (c) => {
33413745 twitterCard="summary"
33423746 >
33433747 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
3748 {/* Repo-context commands for the command palette (Feature 2) */}
3749 <script
3750 id="cmdk-repo-context"
3751 dangerouslySetInnerHTML={{
3752 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3753 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3754 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3755 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3756 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3757 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3758 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3759 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3760 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3761 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3762 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3763 ])};`,
3764 }}
3765 />
33443766 <div class="repo-home-hero">
33453767 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
33463768 <div class="repo-home-hero-orb" />
@@ -3451,6 +3873,13 @@ web.get("/:owner/:repo", async (c) => {
34513873 repo={repo}
34523874 count={repoHomePendingCount}
34533875 />
3876 {showOnboarding && onboardingRow && (
3877 <RepoOnboardingCard
3878 owner={owner}
3879 repo={repo}
3880 data={onboardingRow}
3881 />
3882 )}
34543883 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
34553884 row sits just below the nav as a slim CTA strip. Scoped under
34563885 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
Modifiedsrc/views/layout.tsx+34−3View fileUnifiedSplit
@@ -808,6 +808,13 @@ const navScript = `
808808 list.innerHTML = html;
809809 }
810810
811 function getAllCommands(){
812 // Merge global COMMANDS with repo-context commands injected by repo pages.
813 var extra = (window.__CMDK_REPO_COMMANDS && Array.isArray(window.__CMDK_REPO_COMMANDS))
814 ? window.__CMDK_REPO_COMMANDS : [];
815 return COMMANDS.concat(extra);
816 }
817
811818 function openPalette(){
812819 backdrop = document.getElementById('cmdk-backdrop');
813820 panel = document.getElementById('cmdk-panel');
@@ -818,7 +825,7 @@ const navScript = `
818825 panel.style.display = 'block';
819826 input.value = '';
820827 selected = 0;
821 filtered = COMMANDS.slice();
828 filtered = getAllCommands();
822829 render();
823830 input.focus();
824831 }
@@ -838,7 +845,7 @@ const navScript = `
838845 document.addEventListener('input', function(e){
839846 if (e.target && e.target.id === 'cmdk-input') {
840847 var q = e.target.value;
841 filtered = COMMANDS.filter(function(c){ return fuzzyMatch(c, q); });
848 filtered = getAllCommands().filter(function(c){ return fuzzyMatch(c, q); });
842849 selected = 0;
843850 render();
844851 }
@@ -884,7 +891,15 @@ const navScript = `
884891 e.preventDefault(); window.location.href = '/shortcuts'; return;
885892 }
886893 if (e.key === 'n' && !e.ctrlKey && !e.metaKey) {
887 e.preventDefault(); window.location.href = '/new'; return;
894 // Start 'n' chord — wait 1.2s for next key (i → issue, p → PR).
895 // If no second key arrives, navigate to /new (create repo).
896 chord = 'n';
897 clearTimeout(chordTimer);
898 chordTimer = setTimeout(function(){
899 if (chord === 'n') { window.location.href = '/new'; }
900 chord = null;
901 }, 1200);
902 return;
888903 }
889904 // "g" chord
890905 if (e.key === 'g' && !e.ctrlKey && !e.metaKey) {
@@ -900,6 +915,22 @@ const navScript = `
900915 else if (e.key === 'a') { e.preventDefault(); window.location.href = '/ask'; }
901916 chord = null;
902917 }
918 // "n" chord — new issue / new PR (repo-context-aware)
919 if (chord === 'n') {
920 var repoCtx = window.__CMDK_REPO_COMMANDS;
921 var newIssuePath = repoCtx && repoCtx.length ? repoCtx[0].href.replace('/issues/new', '/issues/new') : null;
922 // Find the "new issue" and "new PR" hrefs from repo context commands
923 var issueCmd = repoCtx && repoCtx.find(function(c){ return c.href && c.href.indexOf('/issues/new') !== -1; });
924 var prCmd = repoCtx && repoCtx.find(function(c){ return c.href && c.href.indexOf('/pulls/new') !== -1; });
925 if (e.key === 'i' && issueCmd) {
926 e.preventDefault(); clearTimeout(chordTimer); chord = null;
927 window.location.href = issueCmd.href; return;
928 }
929 if (e.key === 'p' && prCmd) {
930 e.preventDefault(); clearTimeout(chordTimer); chord = null;
931 window.location.href = prCmd.href; return;
932 }
933 }
903934 // j/k list navigation — move through .prs-row, .issue-row, .notif-item rows
904935 if (e.key === 'j' || e.key === 'k') {
905936 var selectors = '.prs-row, .issue-row, .issue-list-item, .notif-item, .repo-item, .exp-repo-card, .disc-row';
906937