mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Meeting prep: attendee notes + proactive 6h-ahead prep briefs (#636)
* feat(meetings): inline meeting prep under next meeting Resolve a meeting's attendees against the knowledge base and render each attendee's existing person.md inline beneath the next upcoming meeting in the Meetings view. Deterministic (email-exact match, then unambiguous name/alias), no LLM in the hot path. - core: resolveMeetingPrep() + meeting-prep:resolve IPC - renderer: notes-first rows with expandable person.md; unmatched attendees collapse into a 'no notes yet' row with a Create note action that hands off to the Copilot - prep re-resolves when a People note changes * feat(meetings): per-meeting prep toggle Add a Prep toggle to every eligible meeting row, not just the next one. The next meeting still auto-expands; other meetings resolve their attendee notes lazily when their toggle is opened. All-day and solo events get no toggle. * feat(meetings): org matching, cached index, and field-bleed fix #2 Matching: resolve the meeting's external companies from attendee email domains (and matched people's Organization field), excluding the user's own domain, and surface those org notes in a Companies section. Normalize display names (strip '(via ...)' suffixes) before name/alias matching. #3 Perf: meeting prep reads a cached knowledge index instead of rescanning the whole knowledge dir on every resolve; invalidated whenever a file under knowledge/ changes (wired to the workspace watcher). Fix: extractField in the knowledge index consumed newlines after the label, so an empty field (e.g. **Role:**) bled the next line's value. Restrict it to spaces/tabs so empty fields resolve to undefined. Also strip the folder prefix from link targets for display (Organizations/Rowboat Labs -> Rowboat Labs). * feat(meetings): proactive prep notes generated 6h ahead Generate a meeting prep note ~6h before each meeting and surface its brief in the prep card. - Generator (meeting_prep_brief.ts): assembles roster + 'last time' recap (extracts the prior instance's Action items) + agenda, ordered by meeting type (recurring -> recap first, one-off -> agenda first), then one model call for a 'what matters' brief. Writes knowledge/Meetings/prep/<slug>-<date>.md with eventId/recurringEventId frontmatter. Reuses the summarizeMeeting LLM path (configured model, useCase: meeting_prep). - Scheduler (meeting_prep_scheduler.ts): calendar-aware tick (5m), generates prep within the 6h lead window, state file dedupes, self-heals on changes. - Card: shows the brief (bulleted, compact) above a People list whose rows link out to each person's note instead of rendering it inline. Generated prep notes are kept out of the past-notes table. * chore(meetings): poll prep scheduler every 15m instead of 5m
This commit is contained in:
parent
932a10c2f9
commit
08a4077d43
11 changed files with 1187 additions and 11 deletions
|
|
@ -62,6 +62,9 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
|||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||
import { search } from '@x/core/dist/search/search.js';
|
||||
import { resolveMeetingPrep } from '@x/core/dist/knowledge/meeting_prep.js';
|
||||
import { readPrepNoteForEvent } from '@x/core/dist/knowledge/meeting_prep_brief.js';
|
||||
import { invalidateKnowledgeIndex } from '@x/core/dist/knowledge/knowledge_index.js';
|
||||
import { versionHistory, voice } from '@x/core';
|
||||
import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||
|
|
@ -507,7 +510,25 @@ function queueChange(relPath: string): void {
|
|||
/**
|
||||
* Handle workspace change event from core watcher
|
||||
*/
|
||||
function touchesKnowledge(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): boolean {
|
||||
const hit = (p: string | undefined) => typeof p === 'string' && p.startsWith('knowledge/');
|
||||
switch (event.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
return hit(event.path);
|
||||
case 'moved':
|
||||
return hit(event.from) || hit(event.to);
|
||||
case 'bulkChanged':
|
||||
return !event.paths || event.paths.some(hit);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): void {
|
||||
// Any knowledge-base change drops the cached index so the next read rebuilds.
|
||||
if (touchesKnowledge(event)) invalidateKnowledgeIndex();
|
||||
// Debounce 'changed' events, emit others immediately
|
||||
if (event.type === 'changed' && event.path) {
|
||||
queueChange(event.path);
|
||||
|
|
@ -1544,6 +1565,11 @@ export function setupIpcHandlers() {
|
|||
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
|
||||
return { notes };
|
||||
},
|
||||
'meeting-prep:resolve': async (_event, args) => {
|
||||
const result = await resolveMeetingPrep(args.attendees);
|
||||
const prepNote = args.eventId ? await readPrepNoteForEvent(args.eventId) : null;
|
||||
return { ...result, prepNote };
|
||||
},
|
||||
'inline-task:classifySchedule': async (_event, args) => {
|
||||
const schedule = await classifySchedule(args.instruction);
|
||||
return { schedule };
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"
|
|||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
|
||||
import { init as initMeetingPrep } from "@x/core/dist/knowledge/meeting_prep_scheduler.js";
|
||||
import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js";
|
||||
import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js";
|
||||
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
|
||||
|
|
@ -450,6 +451,9 @@ app.whenReady().then(async () => {
|
|||
// start calendar meeting notification service (fires 1-minute warnings)
|
||||
initCalendarNotifications();
|
||||
|
||||
// start meeting prep scheduler (generates prep notes ~6h before a meeting)
|
||||
void initMeetingPrep();
|
||||
|
||||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
|
|
|
|||
|
|
@ -5238,6 +5238,20 @@ function App() {
|
|||
return () => window.removeEventListener('email-block:draft-with-assistant', handler)
|
||||
}, [])
|
||||
|
||||
// Meeting prep: create a person note for an unmatched attendee via Copilot.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const pending = window.__pendingMeetingPrepCreate
|
||||
if (pending) {
|
||||
setPresetMessage(pending.prompt)
|
||||
setIsChatSidebarOpen(true)
|
||||
window.__pendingMeetingPrepCreate = undefined
|
||||
}
|
||||
}
|
||||
window.addEventListener('meeting-prep:create-note', handler)
|
||||
return () => window.removeEventListener('meeting-prep:create-note', handler)
|
||||
}, [])
|
||||
|
||||
const resolveWikiFilePath = useCallback((wikiPath: string) => {
|
||||
const normalized = normalizeWikiPath(wikiPath)
|
||||
const { path: basePath } = splitWikiFragment(normalized)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react'
|
||||
import { Calendar, ChevronDown, ChevronRight, Clock, ExternalLink, FileText, Loader2, MapPin, Mic, Sparkles, Square, UserPlus, UserRound, UsersRound, Video, X } from 'lucide-react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
|
@ -14,6 +15,39 @@ const MEETINGS_ROOT = 'knowledge/Meetings'
|
|||
const CALENDAR_DIR = 'calendar_sync'
|
||||
const UPCOMING_MAX_DAYS = 4 // today + next 3
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__pendingMeetingPrepCreate?: { prompt: string }
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the `meeting-prep:resolve` IPC response shape.
|
||||
type PrepNote = {
|
||||
path: string
|
||||
name: string
|
||||
role?: string
|
||||
organization?: string
|
||||
markdown: string
|
||||
}
|
||||
type PrepAttendee = {
|
||||
label: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
note: PrepNote | null
|
||||
}
|
||||
type PrepOrg = {
|
||||
path: string
|
||||
name: string
|
||||
markdown: string
|
||||
}
|
||||
type PrepResult = {
|
||||
attendees: PrepAttendee[]
|
||||
organizations: PrepOrg[]
|
||||
prepNote: { path: string; brief: string } | null
|
||||
matchedCount: number
|
||||
unmatchedCount: number
|
||||
}
|
||||
|
||||
type MeetingNoteRow = {
|
||||
path: string
|
||||
name: string
|
||||
|
|
@ -358,7 +392,178 @@ function parseDescriptionParts(value: string): DescriptionPart[] {
|
|||
}).filter((part) => part.text.length > 0)
|
||||
}
|
||||
|
||||
function UpcomingEvents() {
|
||||
// Hand the unmatched attendee off to the Copilot to research + create a note.
|
||||
function requestCreateNote(attendee: PrepAttendee, meetingSummary: string) {
|
||||
const who = attendee.displayName || attendee.label
|
||||
const email = attendee.email ? ` <${attendee.email}>` : ''
|
||||
window.__pendingMeetingPrepCreate = {
|
||||
prompt: `Create a person note in my knowledge base for ${who}${email}. They're attending my "${meetingSummary}" meeting. Pull together what you know about them from my emails, past meetings, and calendar.`,
|
||||
}
|
||||
window.dispatchEvent(new Event('meeting-prep:create-note'))
|
||||
}
|
||||
|
||||
// One note row (used for both people and organizations): a clickable row that
|
||||
// navigates to the note. The markdown is NOT rendered inline in the card.
|
||||
function PrepNoteRow({ title, subtitle, path, onOpenNote }: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
path: string
|
||||
onOpenNote: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(path)}
|
||||
title={`Open ${title}`}
|
||||
className="flex w-full items-center gap-3 border-b px-5 py-1.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-[12.5px] font-semibold text-foreground">{title}</span>
|
||||
{subtitle ? <span className="truncate text-[11px] text-muted-foreground">{subtitle}</span> : null}
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PrepAttendeeNote({ attendee, onOpenNote }: { attendee: PrepAttendee; onOpenNote: (path: string) => void }) {
|
||||
const note = attendee.note
|
||||
if (!note) return null
|
||||
const subtitle = [note.role, note.organization].filter(Boolean).join(' · ')
|
||||
return <PrepNoteRow title={note.name} subtitle={subtitle || undefined} path={note.path} onOpenNote={onOpenNote} />
|
||||
}
|
||||
|
||||
function PrepUnmatchedSection({ attendees, meetingSummary }: { attendees: PrepAttendee[]; meetingSummary: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (attendees.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-5 py-2.5 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
{open ? <ChevronDown className="size-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="size-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{attendees.length} {attendees.length === 1 ? 'other' : 'others'} — no notes yet
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="flex flex-col gap-1 px-5 pb-3 pl-12">
|
||||
{attendees.map((att, idx) => (
|
||||
<div key={`${att.email ?? att.label}-${idx}`} className="flex items-center justify-between gap-3 py-1">
|
||||
<span className="min-w-0 truncate text-sm text-foreground">{att.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestCreateNote(att, meetingSummary)}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<UserPlus className="size-3.5" />
|
||||
Create note
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline prep for a single event: resolves the attendees against the knowledge
|
||||
// base and renders their notes directly beneath the event row. Re-resolves when
|
||||
// a person note changes (e.g. after "Create note") so it stays fresh.
|
||||
function InlineMeetingPrep({ event, onOpenNote }: { event: UpcomingEvent; onOpenNote: (path: string) => void }) {
|
||||
const [prep, setPrep] = useState<PrepResult | null>(null)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
try {
|
||||
const attendees = event.attendees.map((a) => ({ email: a.email, displayName: a.displayName, self: a.self }))
|
||||
const result = await window.ipc.invoke('meeting-prep:resolve', { attendees, eventId: event.id })
|
||||
if (!cancelled) setPrep(result)
|
||||
} catch (err) {
|
||||
console.error('Meeting prep failed:', err)
|
||||
if (!cancelled) setPrep(null)
|
||||
}
|
||||
}
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [event.id, refreshTick])
|
||||
|
||||
// Refresh when a People note is created/changed so newly-created notes appear.
|
||||
useEffect(() => {
|
||||
const isPeoplePath = (p: string | undefined) =>
|
||||
typeof p === 'string' && p.startsWith('knowledge/People/')
|
||||
const cleanup = window.ipc.on('workspace:didChange', (e) => {
|
||||
switch (e.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
if (isPeoplePath(e.path)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
case 'moved':
|
||||
if (isPeoplePath(e.from) || isPeoplePath(e.to)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
case 'bulkChanged':
|
||||
if (!e.paths || e.paths.some(isPeoplePath)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
if (!prep || prep.attendees.length === 0) return null
|
||||
|
||||
const matched = prep.attendees.filter((a) => a.note)
|
||||
const unmatched = prep.attendees.filter((a) => !a.note)
|
||||
|
||||
return (
|
||||
<div className="bg-muted/10">
|
||||
{prep.prepNote && prep.prepNote.brief ? (
|
||||
<div className="border-b px-5 pb-3 pt-3">
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0.5 [&_p]:text-[12.5px] [&_li]:text-[12.5px]">
|
||||
{prep.prepNote.brief}
|
||||
</Streamdown>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(prep.prepNote!.path)}
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FileText className="size-3.5" />
|
||||
Open full prep
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1.5 px-5 pb-1 pt-2.5">
|
||||
<UsersRound className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">People</span>
|
||||
</div>
|
||||
{matched.map((att, idx) => (
|
||||
<PrepAttendeeNote key={att.note!.path + idx} attendee={att} onOpenNote={onOpenNote} />
|
||||
))}
|
||||
<PrepUnmatchedSection attendees={unmatched} meetingSummary={event.summary} />
|
||||
{prep.organizations.length > 0 ? (
|
||||
<>
|
||||
<div className="px-5 pb-1 pt-2.5">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{prep.organizations.length === 1 ? 'Company' : 'Companies'}
|
||||
</span>
|
||||
</div>
|
||||
{prep.organizations.map((org) => (
|
||||
<PrepNoteRow key={org.path} title={org.name} subtitle="Organization" path={org.path} onOpenNote={onOpenNote} />
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UpcomingEvents({ onOpenNote }: { onOpenNote: (path: string) => void }) {
|
||||
const [events, setEvents] = useState<UpcomingEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -487,6 +692,20 @@ function UpcomingEvents() {
|
|||
return selectVisibleDays(window)
|
||||
}, [events])
|
||||
|
||||
// The next meeting that's worth prepping for — soonest timed event with at
|
||||
// least one other attendee that hasn't ended. Its row gets inline prep.
|
||||
// `events` is sorted (all-day first, then by start), so `find` returns it.
|
||||
const prepEventId = useMemo(() => {
|
||||
const nowMs = Date.now()
|
||||
const candidate = events.find((ev) => {
|
||||
if (ev.isAllDay) return false
|
||||
if (ev.attendees.every((a) => a.self)) return false
|
||||
const endMs = ev.end ? ev.end.getTime() : ev.start.getTime() + 30 * 60 * 1000
|
||||
return endMs > nowMs
|
||||
})
|
||||
return candidate?.id ?? null
|
||||
}, [events])
|
||||
|
||||
const totalVisible = visibleDays.reduce((s, d) => s + d.events.length, 0)
|
||||
const now = new Date()
|
||||
const todayKey = localDateKey(now)
|
||||
|
|
@ -532,6 +751,8 @@ function UpcomingEvents() {
|
|||
key={day.dateKey}
|
||||
day={day}
|
||||
isToday={day.dateKey === todayKey}
|
||||
prepEventId={prepEventId}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -542,7 +763,7 @@ function UpcomingEvents() {
|
|||
)
|
||||
}
|
||||
|
||||
function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean }) {
|
||||
function UpcomingDayCard({ day, isToday, prepEventId, onOpenNote }: { day: DayGroup; isToday: boolean; prepEventId: string | null; onOpenNote: (path: string) => void }) {
|
||||
const dayNum = day.date.getDate()
|
||||
const month = day.date.toLocaleDateString([], { month: 'short' })
|
||||
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
|
||||
|
|
@ -573,7 +794,13 @@ function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean })
|
|||
</div>
|
||||
) : (
|
||||
day.events.map((ev, idx) => (
|
||||
<UpcomingEventItem key={ev.id} event={ev} isLast={idx === count - 1} />
|
||||
<UpcomingEventItem
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
isLast={idx === count - 1}
|
||||
isPrepTarget={ev.id === prepEventId}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -588,14 +815,20 @@ function NowBadge() {
|
|||
)
|
||||
}
|
||||
|
||||
function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: boolean }) {
|
||||
function UpcomingEventItem({ event, isLast, isPrepTarget, onOpenNote }: { event: UpcomingEvent; isLast: boolean; isPrepTarget: boolean; onOpenNote: (path: string) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
// The next meeting auto-expands its prep; any other meeting with attendees
|
||||
// can be expanded on demand via the Prep toggle (resolves lazily on open).
|
||||
const prepEligible = !event.isAllDay && event.attendees.some((a) => !a.self)
|
||||
const [prepOpen, setPrepOpen] = useState(isPrepTarget)
|
||||
const showPrep = prepEligible && prepOpen
|
||||
const isNow = isEventNow(event)
|
||||
const platform = meetingPlatformLabel(event.conferenceLink)
|
||||
const subtitle = platform ?? event.location
|
||||
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
|
||||
|
||||
return (
|
||||
<div className={cn(!isLast && 'border-b')}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
|
|
@ -604,7 +837,7 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
title={titleAndLocation}
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-4 px-5 py-3 text-left transition-colors',
|
||||
!isLast && 'border-b',
|
||||
showPrep && 'border-b',
|
||||
isNow ? 'bg-muted' : 'hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
|
|
@ -625,7 +858,24 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{prepEligible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setPrepOpen((v) => !v) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
aria-expanded={prepOpen}
|
||||
title={prepOpen ? 'Hide prep' : 'Show meeting prep'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||
prepOpen ? 'bg-accent text-foreground' : 'bg-background text-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
Prep
|
||||
<ChevronDown className={cn('size-3 transition-transform', prepOpen && 'rotate-180')} />
|
||||
</button>
|
||||
) : null}
|
||||
{event.conferenceLink ? (
|
||||
<SplitJoinButton
|
||||
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
|
||||
|
|
@ -647,6 +897,8 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
</PopoverTrigger>
|
||||
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
|
||||
</Popover>
|
||||
{showPrep ? <InlineMeetingPrep event={event} onOpenNote={onOpenNote} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -917,6 +1169,9 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
|
||||
const rows = entries
|
||||
.filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md'))
|
||||
// Generated prep notes live under Meetings/prep/ — they're upcoming
|
||||
// prep, not past meeting notes, so keep them out of this table.
|
||||
.filter((entry) => !entry.path.startsWith(`${MEETINGS_ROOT}/prep/`))
|
||||
.map((entry) => {
|
||||
const relative = entry.path.slice(`${MEETINGS_ROOT}/`.length)
|
||||
const parts = relative.split('/')
|
||||
|
|
@ -1017,7 +1272,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<UpcomingEvents />
|
||||
<UpcomingEvents onOpenNote={onOpenNote} />
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue