Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions e2e/regression/ai-notes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
18 changes: 18 additions & 0 deletions scripts/verify-ai-notes-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<AiNotesPreview | null>(null);
const [aiError, setAiError] = React.useState<string | null>(null);
const [enhancingNotes, setEnhancingNotes] = React.useState(false);
Expand All @@ -757,13 +760,18 @@ export function MeetingDetailContent({
const [loadingSuggestions, setLoadingSuggestions] = React.useState(false);
const [reviewingSuggestionId, setReviewingSuggestionId] = React.useState<string | null>(null);
const updateNotes = useUpdateMeetingNotes();
const updateTranscript = useUpdateMeetingTranscript();
const applyAiNotes = useApplyAiMeetingNotes();
const timer = useLiveTimer(meeting?.status === "live" ? meeting.created_at : null);

React.useEffect(() => {
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<ReturnType<typeof setTimeout>>(null);
const handleNotesChange = React.useCallback(
Expand All @@ -777,9 +785,23 @@ export function MeetingDetailContent({
[meetingId, updateNotes]
);

// Auto-save transcript with its own debounce timer.
const saveTranscriptTimerRef = React.useRef<ReturnType<typeof setTimeout>>(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);
};
}, []);

Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -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 ? (
Expand All @@ -1677,6 +1699,35 @@ export function MeetingDetailContent({
className="min-h-[120px] font-sans text-sm"
/>
</section>
<section className="mt-8">
<button
type="button"
onClick={() => setTranscriptOpen((open) => !open)}
className="flex w-full items-center justify-between text-left"
aria-expanded={transcriptOpen}
>
<div className="flex items-center gap-2">
<h2 className="font-display text-lg font-medium text-ink">Transcript</h2>
{transcript.trim() && (
<span className="text-xs text-ink-4">{transcript.length} chars</span>
)}
</div>
<ChevronDown
className={`size-4 text-ink-3 transition-transform ${transcriptOpen ? "rotate-180" : ""}`}
/>
</button>
<p className="mt-1 text-xs text-ink-4">
Paste a meeting transcript to power AI notes and suggestions.
</p>
{transcriptOpen && (
<Textarea
value={transcript}
onChange={(e) => handleTranscriptChange(e.target.value)}
placeholder="Paste transcript..."
className="mt-3 min-h-[160px] font-sans text-sm"
/>
)}
</section>
</div>
{aiPreview && (
<div className="fixed inset-0 z-[90] bg-ink/25 px-4 py-6 backdrop-blur-sm sm:px-6">
Expand Down
24 changes: 24 additions & 0 deletions src/lib/hooks/use-meetings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,30 @@ export function useUpdateMeetingNotes() {
});
}

// ---------------------------------------------------------------------------
// useUpdateMeetingTranscript - persist a pasted transcript into transcript_raw
// ---------------------------------------------------------------------------
export function useUpdateMeetingTranscript() {
const supabase = createClient();
const queryClient = useQueryClient();

return useMutation({
mutationFn: async ({ meetingId, transcript }: { meetingId: string; transcript: string }) => {
const { error } = await supabase
.from("meetings")
.update({ transcript_raw: transcript })
.eq("id", meetingId);

if (error) throw error;
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: meetingKeys.detail(variables.meetingId),
});
},
});
}

// ---------------------------------------------------------------------------
// useApplyAiMeetingNotes - explicit apply from preview to visible notes
// ---------------------------------------------------------------------------
Expand Down
Loading