From 6ec581ea8ad21718bc3441e0c66c4b79e43b0e6b Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:03:29 -0400 Subject: [PATCH 1/4] feat(clips): add audio cue when desktop recording starts --- templates/clips/desktop/src/lib/recorder.ts | 83 +++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/templates/clips/desktop/src/lib/recorder.ts b/templates/clips/desktop/src/lib/recorder.ts index c8be252bf..d54a73c83 100644 --- a/templates/clips/desktop/src/lib/recorder.ts +++ b/templates/clips/desktop/src/lib/recorder.ts @@ -446,6 +446,87 @@ function createSyntheticAudioStream(): { } } +interface RecordingStartCue { + play(): void; + cleanup(): void; +} + +const noopRecordingStartCue: RecordingStartCue = { + play() {}, + cleanup() {}, +}; + +function createRecordingStartCue(): RecordingStartCue { + try { + const AudioCtx = window.AudioContext || (window as any).webkitAudioContext; + if (!AudioCtx) return noopRecordingStartCue; + + const ctx = new AudioCtx(); + let played = false; + let closed = false; + let cleanupTimer: ReturnType | null = + null; + + const close = () => { + if (closed) return; + closed = true; + if (cleanupTimer) { + window.clearTimeout(cleanupTimer); + cleanupTimer = null; + } + ctx.close().catch(() => {}); + }; + + const play = () => { + if (played || closed) return; + played = true; + + const startedAt = ctx.currentTime + 0.005; + const oscillator = ctx.createOscillator(); + const gain = ctx.createGain(); + + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(880, startedAt); + oscillator.frequency.exponentialRampToValueAtTime(660, startedAt + 0.14); + + gain.gain.setValueAtTime(0.0001, startedAt); + gain.gain.exponentialRampToValueAtTime(0.07, startedAt + 0.018); + gain.gain.exponentialRampToValueAtTime(0.0001, startedAt + 0.18); + + oscillator.connect(gain); + gain.connect(ctx.destination); + + oscillator.addEventListener("ended", close, { once: true }); + oscillator.start(startedAt); + oscillator.stop(startedAt + 0.2); + }; + + const cue: RecordingStartCue = { + play() { + if (ctx.state === "running") { + play(); + return; + } + ctx + .resume() + .then(play) + .catch((err) => { + console.warn("[clips-recorder] start cue unavailable:", err); + close(); + }); + }, + cleanup: close, + }; + + ctx.resume().catch(() => {}); + cleanupTimer = window.setTimeout(() => cue.cleanup(), 5 * 60_000); + return cue; + } catch (err) { + console.warn("[clips-recorder] start cue unavailable:", err); + return noopRecordingStartCue; + } +} + export async function startNativeRecording( params: StartParams, ): Promise { @@ -469,6 +550,7 @@ async function startNativeRecordingInner( const wantsScreen = params.mode !== "camera"; const wantsCamera = params.mode !== "screen" && params.cameraOn; const wantsAudio = params.micOn; + const recordingStartCue = createRecordingStartCue(); console.log("[clips-recorder] startNativeRecording", { serverUrl: params.serverUrl, mode: params.mode, @@ -832,6 +914,7 @@ async function startNativeRecordingInner( stateUnlistens = toolbarUnlistens; recorder.start(2_000); + recordingStartCue.play(); // The toolbar is already open (the popover's bubble-session effect // spawns it alongside the bubble in its pre-record, disabled state). // Now that MediaRecorder is actually ticking, flip the toolbar's From dabd94f620bb4381799b2eba2672125056531395 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:04:32 -0400 Subject: [PATCH 2/4] fix(clips): tighten recorder audio cue logic --- templates/clips/desktop/src/lib/recorder.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/templates/clips/desktop/src/lib/recorder.ts b/templates/clips/desktop/src/lib/recorder.ts index d54a73c83..19c81fc96 100644 --- a/templates/clips/desktop/src/lib/recorder.ts +++ b/templates/clips/desktop/src/lib/recorder.ts @@ -464,8 +464,7 @@ function createRecordingStartCue(): RecordingStartCue { const ctx = new AudioCtx(); let played = false; let closed = false; - let cleanupTimer: ReturnType | null = - null; + let cleanupTimer: ReturnType | null = null; const close = () => { if (closed) return; @@ -587,7 +586,7 @@ async function startNativeRecordingInner( if (wantsAudio) { console.log("[clips-recorder] acquiring audioStream (mic only)"); } - const streamCleanups: Array<() => void> = []; + const streamCleanups: Array<() => void> = [recordingStartCue.cleanup]; const displayStreamPromise: Promise | null = wantsScreen ? (() => { From 51f38a4609b3d6c0bc5cb5873980a2e90f759ac9 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:06:34 -0400 Subject: [PATCH 3/4] feat(calendar): expose attendee response status in command palette + event detail --- templates/calendar/AGENTS.md | 1 + templates/calendar/actions/create-event.ts | 1 + templates/calendar/actions/get-event.ts | 1 + templates/calendar/actions/search-events.ts | 1 + templates/calendar/actions/update-event.ts | 1 + .../components/calendar/CommandPalette.tsx | 37 +++++++++++++++- templates/calendar/app/pages/CalendarView.tsx | 42 +++++++++++++++++++ templates/calendar/server/handlers/events.ts | 3 ++ .../calendar/server/lib/google-calendar.ts | 7 ++++ templates/calendar/shared/api.ts | 2 + 10 files changed, 95 insertions(+), 1 deletion(-) diff --git a/templates/calendar/AGENTS.md b/templates/calendar/AGENTS.md index 08ceb690a..02fa3e4de 100644 --- a/templates/calendar/AGENTS.md +++ b/templates/calendar/AGENTS.md @@ -221,6 +221,7 @@ title: ```` - `id` — the Google Calendar event id (raw id like `abc123xyz`, or the prefixed form `google-abc123xyz`) +- `htmlLink` — Google Calendar web URL for opening a Google event in the browser when available - `calendarId` — calendar id, almost always `primary` - `aspect` — recommended `3/2` for a compact card diff --git a/templates/calendar/actions/create-event.ts b/templates/calendar/actions/create-event.ts index ed1e0f3b7..71628920b 100644 --- a/templates/calendar/actions/create-event.ts +++ b/templates/calendar/actions/create-event.ts @@ -125,6 +125,7 @@ export default defineAction({ calEvent.id = `google-${result.id}`; calEvent.googleEventId = result.id; } + if (result.htmlLink) calEvent.htmlLink = result.htmlLink; if (result.meetLink) calEvent.hangoutLink = result.meetLink; if (result.conferenceData) calEvent.conferenceData = result.conferenceData; if (zoomMeetingLink) calEvent.meetingLink = zoomMeetingLink; diff --git a/templates/calendar/actions/get-event.ts b/templates/calendar/actions/get-event.ts index e78ec6e63..1274b1a8a 100644 --- a/templates/calendar/actions/get-event.ts +++ b/templates/calendar/actions/get-event.ts @@ -51,6 +51,7 @@ export default defineAction({ allDay: !evt.start?.dateTime, source: "google", googleEventId: evt.id || undefined, + htmlLink: evt.htmlLink || undefined, accountEmail: acctEmail, responseStatus: selfAttendee?.responseStatus || undefined, attendees: evt.attendees?.map((a: any) => ({ diff --git a/templates/calendar/actions/search-events.ts b/templates/calendar/actions/search-events.ts index cfa54fc84..27d5837ae 100644 --- a/templates/calendar/actions/search-events.ts +++ b/templates/calendar/actions/search-events.ts @@ -88,6 +88,7 @@ export default defineAction({ location: e.location || undefined, accountEmail: e.accountEmail || undefined, googleEventId: e.googleEventId || undefined, + htmlLink: e.htmlLink || undefined, attendees: e.attendees || [], conferenceData: e.conferenceData || undefined, hangoutLink: e.hangoutLink || undefined, diff --git a/templates/calendar/actions/update-event.ts b/templates/calendar/actions/update-event.ts index d57d32301..30efc4638 100644 --- a/templates/calendar/actions/update-event.ts +++ b/templates/calendar/actions/update-event.ts @@ -172,6 +172,7 @@ export default defineAction({ id: `google-${googleEventId}`, accountEmail, updated: updatedKeys, + htmlLink: result.htmlLink, hangoutLink: result.meetLink, meetingLink: zoomMeetingLink, conferenceData: result.conferenceData, diff --git a/templates/calendar/app/components/calendar/CommandPalette.tsx b/templates/calendar/app/components/calendar/CommandPalette.tsx index 610006377..c7811df49 100644 --- a/templates/calendar/app/components/calendar/CommandPalette.tsx +++ b/templates/calendar/app/components/calendar/CommandPalette.tsx @@ -8,6 +8,7 @@ import { IconArrowRight, IconUsers, IconLink, + IconExternalLink, } from "@tabler/icons-react"; import { CommandMenu } from "@agent-native/core/client"; import { cn } from "@/lib/utils"; @@ -24,6 +25,8 @@ interface CommandPaletteProps { onCreateEvent: () => void; onViewChange: (view: ViewMode) => void; onToday: () => void; + selectedEvent?: CalendarEvent | null; + onOpenSelectedEventInGoogleCalendar?: (event: CalendarEvent) => void; onAddPeopleCalendar?: () => void; onAddUrlCalendar?: () => void; } @@ -47,6 +50,8 @@ export function CommandPalette({ onCreateEvent, onViewChange, onToday, + selectedEvent, + onOpenSelectedEventInGoogleCalendar, onAddPeopleCalendar, onAddUrlCalendar, }: CommandPaletteProps) { @@ -86,6 +91,11 @@ export function CommandPalette({ } } + const selectedGoogleEvent = + selectedEvent?.source === "google" && selectedEvent.htmlLink + ? selectedEvent + : null; + return ( )} - {(parsedDate || matchingEvents.length > 0) && } + {selectedGoogleEvent && onOpenSelectedEventInGoogleCalendar && ( + + + onOpenSelectedEventInGoogleCalendar(selectedGoogleEvent) + } + keywords={[ + "open", + "google", + "calendar", + "selected", + "event", + selectedGoogleEvent.title.toLowerCase(), + ]} + > + + + Open in Google Calendar + + + + )} + + {(parsedDate || matchingEvents.length > 0 || selectedGoogleEvent) && ( + + )} { + const candidate = sidebarEvent ?? focusedEvent; + if (!candidate) return null; + return events.find((event) => event.id === candidate.id) ?? candidate; + }, [events, sidebarEvent, focusedEvent]); + function handleNavigate(direction: "prev" | "next") { const fns = direction === "next" @@ -271,6 +277,38 @@ export default function CalendarView() { setViewMode("day"); } + function handleOpenSelectedEventInGoogleCalendar(event: CalendarEvent) { + if (!event.htmlLink) { + toast.error("Google Calendar link unavailable"); + return; + } + + try { + const url = new URL(event.htmlLink); + const isGoogleCalendarUrl = + url.protocol === "https:" && + (url.hostname === "calendar.google.com" || + (url.hostname === "www.google.com" && + url.pathname.startsWith("/calendar/"))); + + if (!isGoogleCalendarUrl) { + toast.error("Google Calendar link unavailable"); + return; + } + + const opened = window.open( + url.toString(), + "_blank", + "noopener,noreferrer", + ); + if (!opened) { + window.location.assign(url.toString()); + } + } catch { + toast.error("Google Calendar link unavailable"); + } + } + function handleDirectDelete(ev: CalendarEvent) { const isOrganizer = ev.organizer?.self || @@ -829,6 +867,10 @@ export default function CalendarView() { }} onViewChange={setViewMode} onToday={handleToday} + selectedEvent={selectedEvent} + onOpenSelectedEventInGoogleCalendar={ + handleOpenSelectedEventInGoogleCalendar + } onAddPeopleCalendar={() => { setCommandPaletteOpen(false); setAddCalendarDefaultTab("people"); diff --git a/templates/calendar/server/handlers/events.ts b/templates/calendar/server/handlers/events.ts index c45859714..9544c1c30 100644 --- a/templates/calendar/server/handlers/events.ts +++ b/templates/calendar/server/handlers/events.ts @@ -159,6 +159,7 @@ export const getEvent = defineEventHandler(async (event: H3Event) => { allDay: !evt.start?.dateTime, source: "google", googleEventId: evt.id || undefined, + htmlLink: evt.htmlLink || undefined, accountEmail: acctEmail, responseStatus: selfAttendee?.responseStatus || undefined, attendees: evt.attendees?.map((a: any) => ({ @@ -270,6 +271,7 @@ export const createEvent = defineEventHandler(async (event: H3Event) => { calEvent.id = `google-${result.id}`; calEvent.googleEventId = result.id; } + if (result.htmlLink) calEvent.htmlLink = result.htmlLink; if (result.meetLink) calEvent.hangoutLink = result.meetLink; if (result.conferenceData) calEvent.conferenceData = result.conferenceData; if (zoomMeetingLink) calEvent.meetingLink = zoomMeetingLink; @@ -369,6 +371,7 @@ export const updateEvent = defineEventHandler(async (event: H3Event) => { sendUpdates, addGoogleMeet: addGoogleMeet === true, }); + if (result.htmlLink) updates.htmlLink = result.htmlLink; if (result.meetLink) updates.hangoutLink = result.meetLink; if (result.conferenceData) updates.conferenceData = result.conferenceData; if (zoomMeetingLink) updates.meetingLink = zoomMeetingLink; diff --git a/templates/calendar/server/lib/google-calendar.ts b/templates/calendar/server/lib/google-calendar.ts index f19c9fb35..24b009df2 100644 --- a/templates/calendar/server/lib/google-calendar.ts +++ b/templates/calendar/server/lib/google-calendar.ts @@ -417,6 +417,7 @@ export async function listEvents( allDay: !event.start?.dateTime, source: "google" as const, googleEventId: event.id || undefined, + htmlLink: event.htmlLink || undefined, accountEmail: email, responseStatus: selfAttendee?.responseStatus, attendees: event.attendees?.map((a: any) => ({ @@ -527,6 +528,7 @@ export async function listOverlayEvents( allDay: !event.start?.dateTime, source: "google" as const, googleEventId: event.id || undefined, + htmlLink: event.htmlLink || undefined, accountEmail: undefined, overlayEmail, createdAt: event.created || new Date().toISOString(), @@ -574,6 +576,7 @@ export async function getEvent( allDay: !event.start?.dateTime, source: "google", googleEventId: event.id || undefined, + htmlLink: event.htmlLink || undefined, accountEmail, responseStatus: selfAttendee?.responseStatus || undefined, attendees: event.attendees?.map((a: any) => ({ @@ -621,6 +624,7 @@ export async function createEvent( }, ): Promise<{ id?: string; + htmlLink?: string; meetLink?: string; conferenceData?: CalendarEvent["conferenceData"]; }> { @@ -664,6 +668,7 @@ export async function createEvent( return { id: response.id || undefined, + htmlLink: response.htmlLink || undefined, meetLink: response.hangoutLink || undefined, conferenceData: mapConferenceData(response.conferenceData), }; @@ -674,6 +679,7 @@ export async function updateEvent( event: Partial, options?: { sendUpdates?: "all" | "none"; addGoogleMeet?: boolean }, ): Promise<{ + htmlLink?: string; meetLink?: string; conferenceData?: CalendarEvent["conferenceData"]; }> { @@ -725,6 +731,7 @@ export async function updateEvent( ); return { + htmlLink: response?.htmlLink || undefined, meetLink: response?.hangoutLink || undefined, conferenceData: mapConferenceData(response?.conferenceData), }; diff --git a/templates/calendar/shared/api.ts b/templates/calendar/shared/api.ts index 47c6ccc52..2aec247df 100644 --- a/templates/calendar/shared/api.ts +++ b/templates/calendar/shared/api.ts @@ -8,6 +8,8 @@ export interface CalendarEvent { allDay: boolean; source: "local" | "google" | "ical"; googleEventId?: string; + /** Absolute Google Calendar web URL for Google events */ + htmlLink?: string; accountEmail?: string; /** Set when this event belongs to an overlaid person's calendar */ overlayEmail?: string; From 43a65af4477df99379db4c039bdddeb0fc47f85e Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:08:31 -0400 Subject: [PATCH 4/4] fix(editors): disable StarterKit link to avoid TipTap duplicate-extension warning --- .changeset/tiptap-link-starterkit.md | 5 +++++ packages/core/src/client/resources/ResourceEditor.tsx | 1 + templates/content/app/components/editor/VisualEditor.tsx | 1 + templates/mail/app/components/email/ComposeEditor.tsx | 1 + templates/slides/app/components/editor/SlideInlineEditor.tsx | 5 ++++- 5 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/tiptap-link-starterkit.md diff --git a/.changeset/tiptap-link-starterkit.md b/.changeset/tiptap-link-starterkit.md new file mode 100644 index 000000000..c898db6bf --- /dev/null +++ b/.changeset/tiptap-link-starterkit.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Avoid duplicate TipTap link extensions when editors provide custom link behavior. diff --git a/packages/core/src/client/resources/ResourceEditor.tsx b/packages/core/src/client/resources/ResourceEditor.tsx index 324970ea5..cdf0a1d97 100644 --- a/packages/core/src/client/resources/ResourceEditor.tsx +++ b/packages/core/src/client/resources/ResourceEditor.tsx @@ -851,6 +851,7 @@ function VisualMarkdownEditor({ StarterKit.configure({ heading: { levels: [1, 2, 3] }, codeBlock: {}, + link: false, dropcursor: { color: "hsl(var(--ring))", width: 2 }, }), Placeholder.configure({ diff --git a/templates/content/app/components/editor/VisualEditor.tsx b/templates/content/app/components/editor/VisualEditor.tsx index 05330b353..206029e29 100644 --- a/templates/content/app/components/editor/VisualEditor.tsx +++ b/templates/content/app/components/editor/VisualEditor.tsx @@ -442,6 +442,7 @@ export function VisualEditor({ heading: { levels: [1, 2, 3] }, codeBlock: false, paragraph: false, + link: false, horizontalRule: {}, dropcursor: { color: "hsl(243 75% 59%)", width: 2 }, // Disable built-in history when Collaboration is active (Yjs tracks undo) diff --git a/templates/mail/app/components/email/ComposeEditor.tsx b/templates/mail/app/components/email/ComposeEditor.tsx index dfd4675e6..ba5c07c26 100644 --- a/templates/mail/app/components/email/ComposeEditor.tsx +++ b/templates/mail/app/components/email/ComposeEditor.tsx @@ -81,6 +81,7 @@ export const ComposeEditor = forwardRef< (StarterKit as any).configure({ heading: { levels: [1, 2, 3] }, codeBlock: false, + link: false, dropcursor: { color: "hsl(220 10% 40%)", width: 2 }, }), CodeBlockLowlight.configure({ diff --git a/templates/slides/app/components/editor/SlideInlineEditor.tsx b/templates/slides/app/components/editor/SlideInlineEditor.tsx index 3eacf4427..178e71a24 100644 --- a/templates/slides/app/components/editor/SlideInlineEditor.tsx +++ b/templates/slides/app/components/editor/SlideInlineEditor.tsx @@ -172,7 +172,10 @@ export function SlideInlineEditor({ const editor = useEditor({ extensions: [ // eslint-disable-next-line @typescript-eslint/no-explicit-any - StarterKit.configure(ydoc ? ({ history: false } as any) : {}), + StarterKit.configure({ + link: false, + ...(ydoc ? ({ history: false } as any) : {}), + }), Placeholder.configure({ placeholder: "Start typing… or press / for commands", }),