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/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; diff --git a/templates/clips/desktop/src/lib/recorder.ts b/templates/clips/desktop/src/lib/recorder.ts index c8be252bf..19c81fc96 100644 --- a/templates/clips/desktop/src/lib/recorder.ts +++ b/templates/clips/desktop/src/lib/recorder.ts @@ -446,6 +446,86 @@ 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 +549,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, @@ -505,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 ? (() => { @@ -832,6 +913,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 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", }),