diff --git a/e2e/regression/ai-notes.spec.ts b/e2e/regression/ai-notes.spec.ts index 7f8b841..7e3991e 100644 --- a/e2e/regression/ai-notes.spec.ts +++ b/e2e/regression/ai-notes.spec.ts @@ -369,3 +369,87 @@ test.describe("AI notes API auth", () => { expect(response.status()).toBe(401); }); }); + +test.describe("Transcript paste", () => { + test("saves a pasted transcript to the meeting", async ({ page, request }) => { + test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated transcript data"); + + const fixture = await createAiNotesFixture(request); + const transcript = `Alice: ship Friday. Bob: flag the support risk. ${Date.now()}`; + + try { + await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`); + await waitForApp(page); + await expect(page.getByRole("heading", { name: /AI notes session/ })).toBeVisible({ + timeout: 20_000, + }); + + await page.getByRole("button", { name: "Transcript" }).click(); + await page.getByPlaceholder("Paste transcript...").fill(transcript); + + await expect(async () => { + const rows = await rest( + request, + `meetings?id=eq.${fixture.meetingId}&select=transcript_raw` + ); + expect(rows[0]?.transcript_raw).toBe(transcript); + }).toPass({ timeout: 10_000 }); + } finally { + await deleteSeries(request, fixture.seriesId); + } + }); + + test("enables enhancement from a transcript alone", async ({ page, request }) => { + test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated transcript data"); + + const fixture = await createAiNotesFixture(request); + + try { + // Strip notes so only a transcript remains. + await rest(request, `meetings?id=eq.${fixture.meetingId}`, { + method: "PATCH", + headers: serviceHeaders("return=minimal"), + data: { + notes_markdown: "", + raw_notes_markdown: "", + transcript_raw: "Alice owns onboarding by Friday.", + }, + }); + + await page.route("**/api/meetings/*/enhance-notes", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ai_notes: { + summary: ["Onboarding owned by Alice."], + action_items: [], + decisions: [], + risks: [], + blockers: [], + follow_ups: [], + open_questions: [], + }, + ai_notes_markdown: "## Summary\nOnboarding owned by Alice.", + model: "test-model", + prompt_version: "ai-notes-v1", + generated_at: "2026-06-04T00:00:00.000Z", + }), + }); + }); + + await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`); + await waitForApp(page); + await expect(page.getByRole("heading", { name: /AI notes session/ })).toBeVisible({ + timeout: 20_000, + }); + + const enhance = page.getByRole("button", { name: "Enhance notes" }); + await expect(enhance).toBeEnabled(); + await enhance.click(); + await expect(page.getByRole("dialog", { name: "AI notes preview" })).toBeVisible(); + } finally { + await deleteSeries(request, fixture.seriesId); + } + }); +}); diff --git a/scripts/verify-ai-notes-contract.mjs b/scripts/verify-ai-notes-contract.mjs index 383791e..8d64cb6 100644 --- a/scripts/verify-ai-notes-contract.mjs +++ b/scripts/verify-ai-notes-contract.mjs @@ -323,4 +323,22 @@ for (const copy of ["Carry-over briefing", "Generate briefing"]) { assert(carryoverPanel.includes(copy), `Carry-over panel missing UI copy: ${copy}`); } +// Transcript paste entry (unblocks augmented notes). +assert(migrations.includes("transcript_raw"), "Schema must include transcript_raw column"); +assert(types.includes("transcript_raw"), "Meeting type must include transcript_raw"); +assert( + meetingDetail.includes("useUpdateMeetingTranscript"), + "Meeting detail must persist pasted transcripts via useUpdateMeetingTranscript" +); +assert(meetingDetail.includes(">Transcript<"), "Meeting detail must render a Transcript section"); +assert( + meetingDetail.includes("Paste transcript..."), + "Meeting detail must offer a transcript paste field" +); +const meetingsHook = read("src/lib/hooks/use-meetings.ts"); +assert( + meetingsHook.includes("transcript_raw: transcript"), + "useUpdateMeetingTranscript must write transcript_raw" +); + console.log("AI notes contract verified"); diff --git a/src/app/(app)/series/[id]/meetings/[meetingId]/meeting-detail-content.tsx b/src/app/(app)/series/[id]/meetings/[meetingId]/meeting-detail-content.tsx index 0856237..85d813b 100644 --- a/src/app/(app)/series/[id]/meetings/[meetingId]/meeting-detail-content.tsx +++ b/src/app/(app)/series/[id]/meetings/[meetingId]/meeting-detail-content.tsx @@ -13,6 +13,7 @@ import { useStartOrJoinMeeting, useApplyAiMeetingNotes, useUpdateMeetingNotes, + useUpdateMeetingTranscript, } from "@/lib/hooks/use-meetings"; import { useSeriesDetail, useSeriesParticipantRole } from "@/lib/hooks/use-series"; import { useIssues, useCreateIssue, useUpdateIssueStatus, useUpdateIssue, issueKeys } from "@/lib/hooks/use-issues"; @@ -33,7 +34,7 @@ import { Textarea } from "@/components/ui/textarea"; import { ShareButton } from "@/components/minutia/share-button"; import { SendMeetingNotesButton } from "@/components/minutia/send-meeting-notes-button"; import { CarryoverBriefingPanel } from "@/components/minutia/carryover-briefing-panel"; -import { ArrowLeft, Square, Play, Check, X, Copy, CheckCheck, Sparkles, Loader2, ListChecks, FileText, CheckSquare, Gavel, AlertTriangle, Ban, RotateCcw, HelpCircle } from "lucide-react"; +import { ArrowLeft, Square, Play, Check, X, Copy, CheckCheck, Sparkles, Loader2, ListChecks, FileText, CheckSquare, Gavel, AlertTriangle, Ban, RotateCcw, HelpCircle, ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { formatShortDate } from "@/lib/date-utils"; import type { IssueCategory, IssueStatus, Issue, Decision, Meeting, MeetingAiSuggestion } from "@/lib/types"; @@ -748,6 +749,8 @@ export function MeetingDetailContent({ const { isOnline, pendingCount, syncStatus, refreshCount } = useOfflineSync(); const [notes, setNotes] = React.useState(meeting?.notes_markdown ?? ""); + const [transcript, setTranscript] = React.useState(meeting?.transcript_raw ?? ""); + const [transcriptOpen, setTranscriptOpen] = React.useState(!!meeting?.transcript_raw); const [aiPreview, setAiPreview] = React.useState(null); const [aiError, setAiError] = React.useState(null); const [enhancingNotes, setEnhancingNotes] = React.useState(false); @@ -757,6 +760,7 @@ export function MeetingDetailContent({ const [loadingSuggestions, setLoadingSuggestions] = React.useState(false); const [reviewingSuggestionId, setReviewingSuggestionId] = React.useState(null); const updateNotes = useUpdateMeetingNotes(); + const updateTranscript = useUpdateMeetingTranscript(); const applyAiNotes = useApplyAiMeetingNotes(); const timer = useLiveTimer(meeting?.status === "live" ? meeting.created_at : null); @@ -764,6 +768,10 @@ export function MeetingDetailContent({ if (meeting?.notes_markdown) setNotes(meeting.notes_markdown); }, [meeting?.notes_markdown]); + React.useEffect(() => { + if (meeting?.transcript_raw != null) setTranscript(meeting.transcript_raw); + }, [meeting?.transcript_raw]); + // Auto-save notes with debounce const saveTimerRef = React.useRef>(null); const handleNotesChange = React.useCallback( @@ -777,9 +785,23 @@ export function MeetingDetailContent({ [meetingId, updateNotes] ); + // Auto-save transcript with its own debounce timer. + const saveTranscriptTimerRef = React.useRef>(null); + const handleTranscriptChange = React.useCallback( + (value: string) => { + setTranscript(value); + if (saveTranscriptTimerRef.current) clearTimeout(saveTranscriptTimerRef.current); + saveTranscriptTimerRef.current = setTimeout(() => { + updateTranscript.mutate({ meetingId, transcript: value }); + }, 1000); + }, + [meetingId, updateTranscript] + ); + React.useEffect(() => { return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + if (saveTranscriptTimerRef.current) clearTimeout(saveTranscriptTimerRef.current); }; }, []); @@ -1430,7 +1452,7 @@ export function MeetingDetailContent({ variant="outline" size="sm" onClick={handleReviewAiSuggestions} - disabled={loadingSuggestions || !notes.trim()} + disabled={loadingSuggestions || (!notes.trim() && !transcript.trim())} className="border-rule bg-paper text-ink hover:bg-paper-2" > {loadingSuggestions ? ( @@ -1654,7 +1676,7 @@ export function MeetingDetailContent({ variant="outline" size="sm" onClick={handleEnhanceNotes} - disabled={enhancingNotes || !notes.trim()} + disabled={enhancingNotes || (!notes.trim() && !transcript.trim())} className="border-rule bg-card text-ink hover:bg-paper-2" > {enhancingNotes ? ( @@ -1677,6 +1699,35 @@ export function MeetingDetailContent({ className="min-h-[120px] font-sans text-sm" /> +
+ +

+ Paste a meeting transcript to power AI notes and suggestions. +

+ {transcriptOpen && ( +