+ {/* Try Again Button for Retryable Errors */}
+ {showRetry && (
+
+ )}
+
+ {/* Special Sign In link for Auth Expired */}
+ {errorType === 'auth_expired' && (
+
+ Sign In Again
+
+ )}
+
+ {/* Special Repo Selector link for Repo Not Found */}
+ {errorType === 'repo_not_found' && (
+
+ )}
+
+ {/* Go back Link (Always shown if onBack is provided) */}
+ {onBack && (
+
+ )}
+
+
+ );
+}
diff --git a/components/GenerateButton.tsx b/components/GenerateButton.tsx
new file mode 100644
index 0000000..4375961
--- /dev/null
+++ b/components/GenerateButton.tsx
@@ -0,0 +1,158 @@
+'use client';
+
+import React, { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { LoadingView } from '@/components/LoadingView';
+import { ErrorView, ErrorType } from '@/components/ErrorView';
+
+interface GenerateButtonProps {
+ /** The full repository name (e.g. "owner/repo"). Null when no repo is selected. */
+ selectedRepo: string | null;
+ /** Callback to clear the repository selection in the parent component. */
+ onResetRepo?: () => void;
+}
+
+/**
+ * VOC-131 & VOC-133 — Generate Post trigger button and error handling flow.
+ *
+ * Behaviour matrix:
+ * • No repo selected → disabled button with muted label
+ * • Repo selected → primary CTA; calls POST /api/generate on click
+ * • Loading → button replaced by LoadingView (no double-submit possible)
+ * • Error → ErrorView shows mapped user-facing message and retry/back options
+ * • Success → stores result in sessionStorage, redirects to /drafts
+ */
+export function GenerateButton({ selectedRepo, onResetRepo }: GenerateButtonProps) {
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState<{ type: ErrorType; message?: string } | null>(null);
+ const router = useRouter();
+
+ async function handleGenerate() {
+ if (!selectedRepo) return;
+
+ setIsLoading(true);
+ setError(null);
+
+ // Set up AbortController for a client-side request timeout of 45 seconds (VOC-133 requirement)
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => {
+ controller.abort();
+ }, 45000);
+
+ try {
+ const res = await fetch('/api/generate', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ repoFullName: selectedRepo }),
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ // Attempt to parse JSON response. Fallback to empty object if response is not JSON.
+ const data: any = await res.json().catch(() => ({}));
+
+ if (!res.ok) {
+ let type: ErrorType = 'server_error';
+
+ if (res.status === 401) {
+ type = 'auth_expired';
+ } else if (res.status === 404) {
+ type = 'repo_not_found';
+ } else if (res.status === 504 || res.status === 408) {
+ type = 'timeout';
+ } else if (
+ res.status === 502 ||
+ res.status === 503 ||
+ (data.error && (data.error.toLowerCase().includes('claude') || data.error.toLowerCase().includes('ai')))
+ ) {
+ type = 'ai_failure';
+ } else if (
+ data.error &&
+ (data.error.toLowerCase().includes('activity') || data.error.toLowerCase().includes('commits'))
+ ) {
+ type = 'no_activity';
+ }
+
+ setError({ type, message: data.error });
+ setIsLoading(false);
+ return;
+ }
+
+ if (data.noActivity) {
+ setError({ type: 'no_activity' });
+ setIsLoading(false);
+ return;
+ }
+
+ // Store result in sessionStorage so the drafts page can read it.
+ // sessionStorage is used deliberately — drafts are session-specific and shouldn't persist.
+ sessionStorage.setItem('voca_drafts', JSON.stringify(data));
+ router.push('/drafts');
+ } catch (err: unknown) {
+ clearTimeout(timeoutId);
+ setIsLoading(false);
+
+ if (err instanceof DOMException && err.name === 'AbortError') {
+ setError({ type: 'timeout' });
+ } else if (err instanceof TypeError || (err instanceof Error && err.message.toLowerCase().includes('fetch'))) {
+ setError({ type: 'network' });
+ } else {
+ setError({
+ type: 'server_error',
+ message: err instanceof Error ? err.message : 'Something went wrong',
+ });
+ }
+ }
+ }
+
+ function handleBack() {
+ // If the error was repo_not_found, we should reset the repo selection in parent
+ if (error?.type === 'repo_not_found' && onResetRepo) {
+ onResetRepo();
+ }
+ setError(null);
+ }
+
+ // ── State: loading ──────────────────────────────────────────────────────────
+ if (isLoading) {
+ return ;
+ }
+
+ // ── State: error ────────────────────────────────────────────────────────────
+ if (error) {
+ return (
+
+ );
+ }
+
+ // ── State: idle ─────────────────────────────────────────────────────────────
+ return (
+
+ );
+}
diff --git a/components/LoadingView.tsx b/components/LoadingView.tsx
new file mode 100644
index 0000000..6595db6
--- /dev/null
+++ b/components/LoadingView.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+
+// ─── Step definitions ──────────────────────────────────────────────────────────
+
+/**
+ * Each step has a human-readable message and an auto-advance duration in ms.
+ * `Infinity` on the last step means it will never auto-advance — it stays visible
+ * until the request resolves (success or error).
+ *
+ * Timing contract (from VOC-132):
+ * 0–3s → step 0
+ * 3–6s → step 1
+ * 6–10s → step 2
+ * 10–20s → step 3
+ * 20s+ → step 4 (appears only after 20 total seconds; never auto-advances)
+ */
+const STEPS = [
+ { message: 'Reading your latest commits...', duration: 3000 },
+ { message: 'Finding the most meaningful changes...', duration: 3000 },
+ { message: 'Writing 3 post variations for you...', duration: 4000 },
+ { message: 'Almost there — polishing the drafts...', duration: 10000 },
+ {
+ message: 'Taking a bit longer than usual — still working...',
+ duration: Infinity,
+ },
+] as const;
+
+/**
+ * Number of steps shown in the dot progress indicator.
+ * We show only the first 4 planned steps — step 5 is an overflow/timeout state,
+ * not a scheduled phase, so including it in the indicator would be misleading.
+ */
+const INDICATOR_STEP_COUNT = STEPS.length - 1; // 4
+
+// ─── Component ─────────────────────────────────────────────────────────────────
+
+/**
+ * VOC-132 — LoadingView.
+ *
+ * Shown during the 8–20 second window while POST /api/generate is in flight.
+ * Cycles through contextual step messages so the user understands meaningful work
+ * is happening instead of experiencing an opaque blank screen.
+ *
+ * Behaviour:
+ * - Steps 0–3 auto-advance after their configured duration.
+ * - Step 4 ("Taking a bit longer...") appears only after 20 cumulative seconds
+ * and never auto-advances — it persists until the parent replaces this view
+ * with either success (drafts page) or an ErrorView.
+ * - No cancel button: generation is not cancellable once started.
+ * - Zero layout shift: the text container has a fixed min-height sized to the
+ * longest message, so the spinner never jumps when text changes.
+ * - Memory-safe: all timers are cleared in useEffect cleanup so unmounting
+ * mid-flight (e.g. unexpected navigation) causes no leaks.
+ */
+export function LoadingView() {
+ const [stepIndex, setStepIndex] = useState(0);
+
+ useEffect(() => {
+ const step = STEPS[stepIndex];
+
+ // Step 4 has Infinity duration — bail immediately; no timer to set.
+ if (step.duration === Infinity) return;
+
+ const timer = setTimeout(() => {
+ // Cap at STEPS.length - 1 as a safety net against any stale closure firing
+ // after the component has been unmounted and remounted.
+ setStepIndex((i) => Math.min(i + 1, STEPS.length - 1));
+ }, step.duration);
+
+ // Cleanup: cancel the pending timer if the component unmounts or stepIndex
+ // changes before the timer fires (e.g. error dismisses this view).
+ return () => clearTimeout(timer);
+ }, [stepIndex]);
+
+ const currentStep = STEPS[stepIndex];
+ const isOverflowStep = stepIndex === STEPS.length - 1;
+
+ return (
+
+ {/* ── Spinner ──────────────────────────────────────────────────────────── */}
+
+
+ {/* ── Step message ─────────────────────────────────────────────────────── */}
+ {/*
+ min-h reserves space equal to two lines of text at the font size used (text-lg).
+ This prevents layout shift when the step message changes length — the spinner
+ and dots remain pinned in place regardless of how long the current message is.
+ At text-lg (18px) + leading-7, 2 lines ≈ 56px. We use min-h-[3.5rem] (56px).
+ */}
+
+ {currentStep.message}
+
+
+ {/* ── Step indicator dots ───────────────────────────────────────────────── */}
+ {/*
+ Shows the 4 planned steps (0–3). When in the overflow step (4), all 4 dots
+ remain filled to signal "we've completed all phases, just waiting on the API".
+ This is a deliberate UX choice: an empty dot indicator in the overflow state
+ would look like a regression.
+ */}
+
+
+ {/* ── Overflow state sub-label ─────────────────────────────────────────── */}
+ {/*
+ Only visible during the overflow step (20s+). Gives extra reassurance without
+ looking broken — the user understands something is taking longer, not that
+ the app has crashed.
+ */}
+ {isOverflowStep && (
+
+ Complex repositories with lots of activity can take up to 30 seconds.
+
+ )}
+
+ );
+}
diff --git a/components/RepoSelector.tsx b/components/RepoSelector.tsx
index 9a19851..c31d905 100644
--- a/components/RepoSelector.tsx
+++ b/components/RepoSelector.tsx
@@ -1,100 +1,211 @@
-"use client"
+'use client';
-import React, { useEffect, useState } from 'react'
-import { useRouter } from 'next/navigation'
+import React, { useEffect, useState } from 'react';
-type Repo = { name: string; full_name: string; private: boolean; updated_at: string }
+// ─── Types ─────────────────────────────────────────────────────────────────────
-export default function RepoSelector() {
- const [repos, setRepos] = useState(null)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [selected, setSelected] = useState(null)
- const [saving, setSaving] = useState(false)
- const router = useRouter()
+type Repo = {
+ name: string;
+ full_name: string;
+ private: boolean;
+ updated_at: string;
+};
+interface RepoSelectorProps {
+ /**
+ * Called whenever the user changes their selection.
+ * Receives the repo's `full_name` (e.g. "owner/repo") or null on deselect.
+ * Optional: when omitted, the component manages selection internally (legacy usage).
+ */
+ onRepoSelect?: (repoFullName: string | null) => void;
+ /**
+ * Controlled selected value. Pass this when the parent owns the selection state.
+ * Optional: when omitted, the component manages selection internally (legacy usage).
+ */
+ selectedRepo?: string | null;
+}
+
+// ─── Component ─────────────────────────────────────────────────────────────────
+
+/**
+ * RepoSelector — lists the authenticated user's GitHub repositories and lets
+ * them pick one.
+ *
+ * Supports two modes:
+ * 1. **Controlled** (VOC-131): parent passes `onRepoSelect` + `selectedRepo`.
+ * The component calls back with the `full_name` on each click; no internal
+ * router.push is performed. This mode is used on the dashboard.
+ * 2. **Uncontrolled / legacy**: no props passed. The component manages its own
+ * selection state and calls `router.push('/commits')` on confirm — preserving
+ * the original behaviour for any existing callers.
+ */
+export default function RepoSelector({
+ onRepoSelect,
+ selectedRepo: controlledRepo,
+}: RepoSelectorProps) {
+ const isControlled = onRepoSelect !== undefined;
+
+ const [repos, setRepos] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Internal selection state is only used in uncontrolled mode.
+ const [internalSelected, setInternalSelected] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ // In controlled mode, the "selected" value comes from the parent.
+ const selectedFullName = isControlled ? controlledRepo : internalSelected;
+
+ // ── Fetch repositories on mount ────────────────────────────────────────────
useEffect(() => {
- setLoading(true)
+ setLoading(true);
fetch('/api/repos', { credentials: 'same-origin' })
.then(async (r) => {
- const contentType = r.headers.get('content-type') || ''
+ const contentType = r.headers.get('content-type') ?? '';
if (!r.ok) {
- // Try to read JSON error, else fallback to text
if (contentType.includes('application/json')) {
- const err = await r.json()
- throw new Error(err.error || 'Request failed')
- } else {
- const txt = await r.text()
- throw new Error(`Unexpected response: ${txt.slice(0, 200)}`)
+ const err = await r.json();
+ throw new Error((err as { error?: string }).error ?? 'Request failed');
}
+ const txt = await r.text();
+ throw new Error(`Unexpected response: ${txt.slice(0, 200)}`);
}
if (!contentType.includes('application/json')) {
- const txt = await r.text()
- throw new Error(`Expected JSON but got: ${txt.slice(0,200)}`)
+ const txt = await r.text();
+ throw new Error(`Expected JSON but got: ${txt.slice(0, 200)}`);
}
- return r.json()
+ return r.json() as Promise<{ repos: Repo[]; error?: string }>;
})
.then((d) => {
- if (d.error) throw new Error(d.error)
- setRepos(d.repos)
+ if (d.error) throw new Error(d.error);
+ const testRepos: Repo[] = [
+ { name: '[TEST] Trigger No Activity', full_name: 'trigger-no-activity', private: false, updated_at: new Date().toISOString() },
+ { name: '[TEST] Trigger Claude/AI Failure', full_name: 'trigger-ai-failure', private: false, updated_at: new Date().toISOString() },
+ { name: '[TEST] Trigger Auth Expired (401)', full_name: 'trigger-auth-expired', private: false, updated_at: new Date().toISOString() },
+ { name: '[TEST] Trigger Repo Access Denied (404)', full_name: 'trigger-repo-not-found', private: false, updated_at: new Date().toISOString() },
+ { name: '[TEST] Trigger Server Error (500)', full_name: 'trigger-server-error', private: false, updated_at: new Date().toISOString() },
+ { name: '[TEST] Trigger Timeout (50s)', full_name: 'trigger-timeout', private: false, updated_at: new Date().toISOString() },
+ ];
+ setRepos([...(d.repos || []), ...testRepos]);
})
- .catch((e) => setError(e.message))
- .finally(() => setLoading(false))
- }, [])
-
- async function confirm() {
- if (!selected) return
- setSaving(true)
- const [name, full] = selected.split('|')
+ .catch((e: unknown) =>
+ setError(e instanceof Error ? e.message : 'Failed to load repositories'),
+ )
+ .finally(() => setLoading(false));
+ }, []);
+
+ // ── Handlers ───────────────────────────────────────────────────────────────
+
+ function handleSelect(fullName: string) {
+ if (isControlled) {
+ // Controlled mode: toggle off if already selected, otherwise select.
+ onRepoSelect(selectedFullName === fullName ? null : fullName);
+ } else {
+ setInternalSelected((prev) => (prev === fullName ? null : fullName));
+ }
+ }
+
+ // Uncontrolled-only: persists the selection via API and navigates.
+ async function confirmLegacy() {
+ if (!internalSelected || saving) return;
+
+ setSaving(true);
+ const repo = repos?.find((r) => r.full_name === internalSelected);
+ if (!repo) {
+ setSaving(false);
+ return;
+ }
+
try {
const res = await fetch('/api/repos/select', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ repoName: name, repoFullName: full }),
- })
- const data = await res.json()
- if (!res.ok) throw new Error(data.error || 'Save failed')
- // navigate to commits page (server returns redirect hint)
- const target = data.redirect || '/commits'
- router.push(target)
- } catch (e: any) {
- setError(e.message)
+ body: JSON.stringify({ repoName: repo.name, repoFullName: repo.full_name }),
+ });
+ const data = (await res.json()) as { error?: string; redirect?: string };
+ if (!res.ok) throw new Error(data.error ?? 'Save failed');
+ // Use window.location for a full navigation after server-side persistence.
+ // This is intentional: the legacy flow expects a clean page load on /commits.
+ window.location.href = data.redirect ?? '/commits';
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : 'Failed to save selection');
} finally {
- setSaving(false)
+ setSaving(false);
}
}
- if (loading) return
Loading repositories…
- if (error) return
{error}
- if (!repos || repos.length === 0) return
No repositories found.
+ // ── Render states ──────────────────────────────────────────────────────────
+
+ if (loading) {
+ return (
+