@agent-native/core 0.192.0 → 0.192.1-nightly-20260926122223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/actions/cancel-booking.ts +12 -4
  3. package/corpus/templates/calendar/actions/list-bookings.ts +23 -2
  4. package/corpus/templates/calendar/actions/list-events.ts +42 -29
  5. package/corpus/templates/calendar/actions/update-booking-link.ts +27 -1
  6. package/corpus/templates/calendar/app/hooks/use-bookings.ts +9 -2
  7. package/corpus/templates/calendar/app/i18n/en-US.ts +8 -0
  8. package/corpus/templates/calendar/app/lib/booking-sorting.ts +7 -0
  9. package/corpus/templates/calendar/app/lib/event-colors.ts +29 -4
  10. package/corpus/templates/calendar/app/pages/BookingsList.tsx +99 -19
  11. package/corpus/templates/calendar/app/pages/CalendarView.tsx +5 -11
  12. package/corpus/templates/calendar/app/pages/ManageBookingPage.tsx +5 -0
  13. package/corpus/templates/calendar/changelog/2026-09-25-ambiguous-zoom-bookings-need-review.md +6 -0
  14. package/corpus/templates/calendar/changelog/2026-09-25-bookings-list-shows-newest-bookings-first.md +6 -0
  15. package/corpus/templates/calendar/changelog/2026-09-25-canceling-a-booking-now-resolves-its-zoom-meeting-first.md +6 -0
  16. package/corpus/templates/calendar/changelog/2026-09-25-colleague-events-use-sidebar-colors.md +6 -0
  17. package/corpus/templates/calendar/changelog/2026-09-25-public-booking-pages-reject-malformed-meeting-settings-befor.md +6 -0
  18. package/corpus/templates/calendar/changelog/2026-09-25-shared-booking-cancellations-require-editor-access-and-unres.md +6 -0
  19. package/corpus/templates/calendar/server/db/schema.ts +5 -0
  20. package/corpus/templates/calendar/server/handlers/bookings.ts +142 -26
  21. package/corpus/templates/calendar/server/lib/booking-link-utils.ts +46 -1
  22. package/corpus/templates/calendar/server/lib/zoom.ts +66 -1
  23. package/corpus/templates/calendar/server/plugins/db.ts +13 -0
  24. package/corpus/templates/calendar/shared/api.ts +4 -0
  25. package/corpus/templates/clips/app/components/capture-install-options.tsx +9 -6
  26. package/corpus/templates/clips/app/components/clips-command-menu.tsx +4 -0
  27. package/corpus/templates/clips/app/i18n/en-US.ts +5 -4
  28. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +4 -0
  29. package/corpus/templates/clips/app/routes/share.$shareId.tsx +6 -30
  30. package/corpus/templates/clips/changelog/2026-09-26-desktop-platform-icon.md +6 -0
  31. package/corpus/templates/clips/changelog/2026-09-26-the-empty-comments-state-now-explains-how-screen-recordings-.md +6 -0
  32. package/corpus/templates/design/app/routes/design-systems.tsx +2 -4
  33. package/corpus/templates/design/app/routes/design.$id.tsx +2 -1
  34. package/corpus/templates/design/app/routes/public-design-meta.ts +0 -39
  35. package/corpus/templates/design/app/routes/templates.tsx +2 -4
  36. package/corpus/templates/design/app/routes/visual-edit_.$id.tsx +2 -1
  37. package/corpus/templates/design/changelog/2026-09-26-opening-shared-designs-no-longer-fails-during-client-startup.md +6 -0
  38. package/corpus/templates/design/package.json +1 -1
  39. package/corpus/templates/design/scripts/check-client-server-boundary.ts +55 -0
  40. package/corpus/templates/design/server/lib/public-design-meta.server.ts +40 -0
  41. package/dist/automation/index.d.ts +1 -1
  42. package/dist/client/settings/UsageSection.js +18 -5
  43. package/dist/client/settings/recent-prompt-groups.d.ts +12 -0
  44. package/dist/client/settings/recent-prompt-groups.js +20 -0
  45. package/dist/collab/struct-routes.d.ts +1 -1
  46. package/dist/observability/routes.d.ts +5 -5
  47. package/dist/progress/routes.d.ts +1 -1
  48. package/dist/provider-api/actions/custom-provider-registration.d.ts +14 -14
  49. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  50. package/dist/resources/handlers.d.ts +1 -1
  51. package/dist/secrets/routes.d.ts +3 -3
  52. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  53. package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
  54. package/dist/server/core-routes-plugin.d.ts +1 -1
  55. package/dist/sharing/actions/list-resource-shares.d.ts +1 -1
  56. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { getOrgContext, orgMembers } from "@agent-native/core/org";
3
3
  import {
4
4
  getSession,
5
5
  getAppProductionUrl,
6
+ getRequestContext,
6
7
  recordChange,
7
8
  readBody,
8
9
  runWithRequestContext,
@@ -29,7 +30,6 @@ import type {
29
30
  Booking,
30
31
  CalendarEvent,
31
32
  AvailabilityConfig,
32
- ConferencingConfig,
33
33
  CustomField,
34
34
  TimeSlot,
35
35
  } from "../../shared/api.js";
@@ -55,12 +55,18 @@ import {
55
55
  import {
56
56
  getBookingLinkCoHostEmails,
57
57
  getBookingLinkRequiredHostEmails,
58
+ isBookingLinkHost,
58
59
  normalizeBookingHosts,
60
+ parseBookingConferencingConfig,
59
61
  } from "../lib/booking-link-utils.js";
60
62
  import { getOwnerBookingTimeZone } from "../lib/booking-timezone.js";
61
63
  import { eventBlocksAvailability } from "../lib/calendar-availability.js";
62
64
  import * as googleCalendar from "../lib/google-calendar.js";
63
- import { createZoomMeeting } from "../lib/zoom.js";
65
+ import {
66
+ createZoomMeeting,
67
+ deleteZoomMeeting,
68
+ needsZoomCancellationReview,
69
+ } from "../lib/zoom.js";
64
70
  import { getBookingUsernameOwner } from "./booking-usernames.js";
65
71
 
66
72
  async function requireRequestContext<T>(
@@ -89,16 +95,23 @@ async function getBookingLinkSlugsForOwners(
89
95
  return Array.from(new Set(rows.map((row) => row.slug)));
90
96
  }
91
97
 
92
- async function getBookingLinkOwnerEmail(
93
- slug: string,
94
- ): Promise<string | undefined> {
98
+ async function getBookingLinkDetails(slug: string) {
95
99
  if (!slug) return undefined;
96
- const row = await getDb()
97
- .select({ ownerEmail: schema.bookingLinks.ownerEmail })
100
+ return getDb()
101
+ .select({
102
+ ownerEmail: schema.bookingLinks.ownerEmail,
103
+ hosts: schema.bookingLinks.hosts,
104
+ conferencing: schema.bookingLinks.conferencing,
105
+ })
98
106
  .from(schema.bookingLinks)
99
107
  .where(eq(schema.bookingLinks.slug, slug))
100
108
  .then((rows) => rows[0]);
101
- return row?.ownerEmail;
109
+ }
110
+
111
+ async function getBookingLinkOwnerEmail(
112
+ slug: string,
113
+ ): Promise<string | undefined> {
114
+ return (await getBookingLinkDetails(slug))?.ownerEmail;
102
115
  }
103
116
 
104
117
  function stripCrlf(value: unknown): string {
@@ -1387,6 +1400,20 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1387
1400
  }
1388
1401
 
1389
1402
  // Check for conflicts + insert atomically in a transaction
1403
+ const parsedConferencing = parseBookingConferencingConfig(
1404
+ link?.conferencing,
1405
+ );
1406
+ if (parsedConferencing.status === "invalid") {
1407
+ setResponseStatus(event, 422);
1408
+ return {
1409
+ error: "Failed to create booking",
1410
+ code: "invalid_conferencing_config",
1411
+ };
1412
+ }
1413
+ const conferencing =
1414
+ parsedConferencing.status === "valid"
1415
+ ? parsedConferencing.config
1416
+ : undefined;
1390
1417
  const db = getDb();
1391
1418
  const insertResult = await db.transaction(async (tx) => {
1392
1419
  if (viewer) {
@@ -1469,6 +1496,7 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1469
1496
  ? JSON.stringify(fieldResponses)
1470
1497
  : null,
1471
1498
  cancelToken,
1499
+ zoomNeedsReview: conferencing?.type === "zoom",
1472
1500
  status: "confirmed",
1473
1501
  createdAt: now,
1474
1502
  ownerEmail: hostEmail,
@@ -1486,17 +1514,12 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1486
1514
  return { error: "This time slot is no longer available" };
1487
1515
  }
1488
1516
 
1489
- // Resolve conferencing config
1490
- let conferencing: ConferencingConfig | undefined;
1491
- if (link?.conferencing) {
1492
- try {
1493
- conferencing = JSON.parse(link.conferencing);
1494
- } catch {}
1495
- }
1496
1517
  let meetingLink: string | undefined;
1497
1518
  let googleEventId: string | undefined;
1498
1519
  let calendarAccountId: string | undefined;
1499
1520
  let meetingLinkPending = false;
1521
+ let zoomMeetingId: string | undefined;
1522
+ let zoomAccountId: string | undefined;
1500
1523
 
1501
1524
  // For custom-URL conferencing, use the static URL — only http(s).
1502
1525
  if (conferencing?.type === "custom" && conferencing.url) {
@@ -1536,6 +1559,8 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1536
1559
  throw new Error("Zoom meeting was not created");
1537
1560
  }
1538
1561
  meetingLink = zoomResult.meetingUrl;
1562
+ zoomMeetingId = zoomResult.meetingId;
1563
+ zoomAccountId = zoomResult.accountId;
1539
1564
  } catch (error) {
1540
1565
  console.error(
1541
1566
  `[bookings] Failed to create Zoom meeting for ${hostEmail}:`,
@@ -1543,8 +1568,7 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1543
1568
  );
1544
1569
  // Zoom may have created the meeting even if its response was lost or
1545
1570
  // unreadable, so keep the booking to reserve the slot and prevent a
1546
- // retry from silently creating a duplicate meeting. Finish the
1547
- // independent calendar write before returning the provider error.
1571
+ // retry from silently creating a duplicate meeting.
1548
1572
  meetingLinkPending = true;
1549
1573
  }
1550
1574
  }
@@ -1634,10 +1658,23 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1634
1658
  meetingLink?: string;
1635
1659
  googleEventId?: string;
1636
1660
  calendarAccountId?: string;
1661
+ zoomNeedsReview?: boolean;
1662
+ zoomMeetingId?: string;
1663
+ zoomAccountId?: string;
1637
1664
  meetingLinkPending: boolean;
1638
1665
  } = { meetingLinkPending };
1639
1666
  if (meetingLink) providerUpdates.meetingLink = meetingLink;
1640
1667
  if (googleEventId) providerUpdates.googleEventId = googleEventId;
1668
+ if (zoomMeetingId) providerUpdates.zoomMeetingId = zoomMeetingId;
1669
+ if (zoomAccountId) providerUpdates.zoomAccountId = zoomAccountId;
1670
+ if (
1671
+ conferencing?.type === "zoom" &&
1672
+ meetingLink &&
1673
+ zoomMeetingId &&
1674
+ zoomAccountId
1675
+ ) {
1676
+ providerUpdates.zoomNeedsReview = false;
1677
+ }
1641
1678
  if (googleEventId && calendarAccountId) {
1642
1679
  providerUpdates.calendarAccountId = calendarAccountId;
1643
1680
  }
@@ -1664,6 +1701,7 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1664
1701
  ...(meetingLinkPending ? { meetingLinkPending: true } : {}),
1665
1702
  googleEventId,
1666
1703
  cancelToken,
1704
+ zoomNeedsReview: conferencing?.type === "zoom" && meetingLinkPending,
1667
1705
  status: "confirmed",
1668
1706
  createdAt: now,
1669
1707
  };
@@ -1897,6 +1935,7 @@ export const getAvailableSlots = defineEventHandler(async (event: H3Event) => {
1897
1935
  export async function cancelBookingById(
1898
1936
  id: string,
1899
1937
  origin = getAppProductionUrl(),
1938
+ options: { zoomMeetingResolved?: boolean } = {},
1900
1939
  ) {
1901
1940
  if (!id)
1902
1941
  throw createError({ statusCode: 400, statusMessage: "id is required" });
@@ -1914,11 +1953,23 @@ export async function cancelBookingById(
1914
1953
 
1915
1954
  // Bookings have no ownerEmail of their own — scope through the booking link.
1916
1955
  const accessibleLinks = await db
1917
- .select({ slug: schema.bookingLinks.slug })
1956
+ .select({
1957
+ slug: schema.bookingLinks.slug,
1958
+ ownerEmail: schema.bookingLinks.ownerEmail,
1959
+ hosts: schema.bookingLinks.hosts,
1960
+ conferencing: schema.bookingLinks.conferencing,
1961
+ })
1918
1962
  .from(schema.bookingLinks)
1919
- .where(accessFilter(schema.bookingLinks, schema.bookingLinkShares));
1920
- const accessibleSlugs = new Set(accessibleLinks.map((link) => link.slug));
1921
- if (!accessibleSlugs.has(existing.slug)) {
1963
+ .where(
1964
+ accessFilter(
1965
+ schema.bookingLinks,
1966
+ schema.bookingLinkShares,
1967
+ undefined,
1968
+ "editor",
1969
+ ),
1970
+ );
1971
+ const link = accessibleLinks.find((item) => item.slug === existing.slug);
1972
+ if (!link) {
1922
1973
  throw createError({ statusCode: 403, statusMessage: "Access denied" });
1923
1974
  }
1924
1975
 
@@ -1926,7 +1977,35 @@ export async function cancelBookingById(
1926
1977
  return { success: true, alreadyCancelled: true };
1927
1978
  }
1928
1979
 
1929
- const hostEmail = await getBookingLinkOwnerEmail(existing.slug);
1980
+ if (
1981
+ options.zoomMeetingResolved === true &&
1982
+ !isBookingLinkHost(link, getRequestContext()?.userEmail)
1983
+ ) {
1984
+ throw createError({ statusCode: 403, statusMessage: "Access denied" });
1985
+ }
1986
+
1987
+ if (
1988
+ needsZoomCancellationReview({
1989
+ ...existing,
1990
+ conferencing: link.conferencing,
1991
+ }) &&
1992
+ options.zoomMeetingResolved !== true
1993
+ ) {
1994
+ throw createError({
1995
+ statusCode: 409,
1996
+ statusMessage:
1997
+ "Check the Zoom meeting and resolve it before canceling this booking",
1998
+ });
1999
+ }
2000
+
2001
+ if (existing.zoomMeetingId && existing.zoomAccountId) {
2002
+ await deleteZoomMeeting({
2003
+ accountId: existing.zoomAccountId,
2004
+ meetingId: existing.zoomMeetingId,
2005
+ });
2006
+ }
2007
+
2008
+ const hostEmail = link.ownerEmail;
1930
2009
  const bookingTimeZone = await getOwnerBookingTimeZone(hostEmail);
1931
2010
  const bookAgainUrl = existing.slug
1932
2011
  ? `${origin}/book/${existing.slug}`
@@ -1940,7 +2019,7 @@ export async function cancelBookingById(
1940
2019
  await deleteGoogleEventForBooking({ booking: existing, hostEmail });
1941
2020
  await db
1942
2021
  .update(schema.bookings)
1943
- .set({ status: "cancelled" })
2022
+ .set({ status: "cancelled", zoomNeedsReview: false })
1944
2023
  .where(eq(schema.bookings.id, id));
1945
2024
  recordBookingsChanged(hostEmail);
1946
2025
  return { success: true };
@@ -1949,9 +2028,19 @@ export async function cancelBookingById(
1949
2028
  export const deleteBooking = defineEventHandler(async (event: H3Event) => {
1950
2029
  return requireRequestContext(event, async () => {
1951
2030
  try {
2031
+ const body = await readBody(event);
2032
+ const parsed = z
2033
+ .object({ zoomMeetingResolved: z.boolean().optional() })
2034
+ .strict()
2035
+ .safeParse(body ?? {});
2036
+ if (!parsed.success) {
2037
+ setResponseStatus(event, 400);
2038
+ return { error: "Invalid cancellation options" };
2039
+ }
1952
2040
  return await cancelBookingById(
1953
2041
  getRouterParam(event, "id") as string,
1954
2042
  getRequestURL(event).origin,
2043
+ parsed.data,
1955
2044
  );
1956
2045
  } catch (error: any) {
1957
2046
  setResponseStatus(event, error?.statusCode ?? 500);
@@ -1982,6 +2071,7 @@ export const getBookingByToken = defineEventHandler(async (event: H3Event) => {
1982
2071
 
1983
2072
  // Return limited info — don't expose internal IDs
1984
2073
  const booking = rowToBooking(row);
2074
+ const link = await getBookingLinkDetails(row.slug);
1985
2075
  return {
1986
2076
  eventTitle: booking.eventTitle,
1987
2077
  name: booking.name,
@@ -1989,6 +2079,10 @@ export const getBookingByToken = defineEventHandler(async (event: H3Event) => {
1989
2079
  end: booking.end,
1990
2080
  slug: booking.slug,
1991
2081
  meetingLink: booking.meetingLink,
2082
+ zoomCancellationNeedsReview: needsZoomCancellationReview({
2083
+ ...row,
2084
+ conferencing: link?.conferencing,
2085
+ }),
1992
2086
  meetingLinkPending: booking.meetingLinkPending,
1993
2087
  status: booking.status,
1994
2088
  };
@@ -2024,12 +2118,34 @@ export const cancelBookingByToken = defineEventHandler(
2024
2118
  return { success: true, alreadyCancelled: true };
2025
2119
  }
2026
2120
 
2121
+ const link = await getBookingLinkDetails(row.slug);
2122
+ if (
2123
+ needsZoomCancellationReview({
2124
+ ...row,
2125
+ conferencing: link?.conferencing,
2126
+ })
2127
+ ) {
2128
+ setResponseStatus(event, 409);
2129
+ return {
2130
+ error:
2131
+ "The organizer must review the Zoom meeting before this booking can be canceled",
2132
+ code: "zoom_meeting_review_required",
2133
+ };
2134
+ }
2135
+
2136
+ if (row.zoomMeetingId && row.zoomAccountId) {
2137
+ await deleteZoomMeeting({
2138
+ accountId: row.zoomAccountId,
2139
+ meetingId: row.zoomMeetingId,
2140
+ });
2141
+ }
2142
+
2027
2143
  await db
2028
2144
  .update(schema.bookings)
2029
- .set({ status: "cancelled" })
2145
+ .set({ status: "cancelled", zoomNeedsReview: false })
2030
2146
  .where(eq(schema.bookings.id, row.id));
2031
2147
 
2032
- const hostEmail = await getBookingLinkOwnerEmail(row.slug);
2148
+ const hostEmail = link?.ownerEmail;
2033
2149
  const bookingTimeZone = await getOwnerBookingTimeZone(hostEmail);
2034
2150
  const reqUrl = getRequestURL(event);
2035
2151
  const bookAgainUrl = row.slug
@@ -2046,7 +2162,7 @@ export const cancelBookingByToken = defineEventHandler(
2046
2162
 
2047
2163
  return { success: true, slug: row.slug };
2048
2164
  } catch (error: any) {
2049
- setResponseStatus(event, 500);
2165
+ setResponseStatus(event, error?.statusCode ?? 500);
2050
2166
  return { error: error.message };
2051
2167
  }
2052
2168
  },
@@ -1,7 +1,17 @@
1
- import type { BookingHost, BookingLink } from "../../shared/api.js";
1
+ import { z } from "zod";
2
+
3
+ import type {
4
+ BookingHost,
5
+ BookingLink,
6
+ ConferencingConfig,
7
+ } from "../../shared/api.js";
2
8
  import { schema } from "../db/index.js";
3
9
 
4
10
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11
+ const httpUrlSchema = z
12
+ .string()
13
+ .url()
14
+ .refine((value) => /^https?:\/\//i.test(value));
5
15
 
6
16
  function stripCrlf(value: unknown): string {
7
17
  return (
@@ -29,6 +39,33 @@ function parseJson<T>(value: string | null, fallback: T): T {
29
39
  }
30
40
  }
31
41
 
42
+ const conferencingConfigSchema = z.discriminatedUnion("type", [
43
+ z.object({ type: z.literal("none"), url: z.string().optional() }),
44
+ z.object({ type: z.literal("google_meet"), url: z.string().optional() }),
45
+ z.object({ type: z.literal("zoom"), url: z.string().optional() }),
46
+ z.object({
47
+ type: z.literal("custom"),
48
+ url: httpUrlSchema,
49
+ }),
50
+ ]);
51
+
52
+ export function parseBookingConferencingConfig(
53
+ value: string | null | undefined,
54
+ ):
55
+ | { status: "absent" }
56
+ | { status: "invalid" }
57
+ | { status: "valid"; config: ConferencingConfig } {
58
+ if (value == null) return { status: "absent" };
59
+ try {
60
+ const parsed = conferencingConfigSchema.safeParse(JSON.parse(value));
61
+ return parsed.success
62
+ ? { status: "valid", config: parsed.data }
63
+ : { status: "invalid" };
64
+ } catch {
65
+ return { status: "invalid" };
66
+ }
67
+ }
68
+
32
69
  export function normalizeBookingHosts(
33
70
  input: unknown,
34
71
  ownerEmail?: string | null,
@@ -100,6 +137,14 @@ export function getBookingLinkRequiredHostEmails(
100
137
  return emails;
101
138
  }
102
139
 
140
+ export function isBookingLinkHost(
141
+ row: Pick<typeof schema.bookingLinks.$inferSelect, "hosts" | "ownerEmail">,
142
+ userEmail: unknown,
143
+ ): boolean {
144
+ const email = normalizeBookingHostEmail(userEmail);
145
+ return !!email && getBookingLinkRequiredHostEmails(row).includes(email);
146
+ }
147
+
103
148
  export function rowToBookingLink(
104
149
  row: typeof schema.bookingLinks.$inferSelect,
105
150
  ): BookingLink {
@@ -24,6 +24,8 @@ import {
24
24
  */
25
25
  import { nanoid } from "nanoid";
26
26
 
27
+ import { parseBookingConferencingConfig } from "./booking-link-utils.js";
28
+
27
29
  const PROVIDER = "zoom_video";
28
30
  const SCOPES = [
29
31
  "meeting:write:meeting",
@@ -56,6 +58,51 @@ export function getZoomAuthUrl(redirectUri: string, state: string) {
56
58
  return `https://zoom.us/oauth/authorize?${params}`;
57
59
  }
58
60
 
61
+ function createProvider(creds: { clientId: string; clientSecret: string }) {
62
+ return createZoomProvider({
63
+ clientId: creds.clientId,
64
+ clientSecret: creds.clientSecret,
65
+ getAccessToken: (credentialId) => resolveAccessToken(credentialId),
66
+ updateTokens: async (credentialId, tokens) => {
67
+ const existing = await getOAuthTokens(PROVIDER, credentialId);
68
+ await saveOAuthTokens(PROVIDER, credentialId, {
69
+ ...existing,
70
+ accessToken: tokens.accessToken,
71
+ refreshToken: tokens.refreshToken ?? (existing as any)?.refreshToken,
72
+ expiresAt: tokens.expiresAt?.getTime(),
73
+ });
74
+ },
75
+ });
76
+ }
77
+
78
+ export function needsZoomCancellationReview(booking: {
79
+ zoomNeedsReview?: boolean;
80
+ meetingLink?: string | null;
81
+ zoomMeetingId?: string | null;
82
+ zoomAccountId?: string | null;
83
+ conferencing?: string | null;
84
+ status?: string;
85
+ }): boolean {
86
+ if (booking.status === "cancelled") return false;
87
+ if (booking.zoomMeetingId && booking.zoomAccountId) return false;
88
+ if (booking.zoomNeedsReview) return true;
89
+ const conferencing = parseBookingConferencingConfig(booking.conferencing);
90
+ if (conferencing.status === "invalid") return true;
91
+ if (conferencing.status === "valid" && conferencing.config.type === "zoom") {
92
+ return true;
93
+ }
94
+ if (!booking.meetingLink) return false;
95
+
96
+ try {
97
+ const hostname = new URL(booking.meetingLink).hostname.toLowerCase();
98
+ return ["zoom.us", "zoom.com", "zoomgov.com"].some(
99
+ (domain) => hostname === domain || hostname.endsWith(`.${domain}`),
100
+ );
101
+ } catch {
102
+ return true;
103
+ }
104
+ }
105
+
59
106
  /**
60
107
  * Exchange an authorization code for Zoom tokens. Stores them in
61
108
  * `oauth_tokens(provider="zoom_video", account_id=<zoom_user_id>, owner=<ownerEmail>)`.
@@ -168,7 +215,12 @@ export async function disconnectZoom(ownerEmail: string) {
168
215
  * owned by the host. `not_started` means the meeting creation request was not sent.
169
216
  */
170
217
  export type ZoomMeetingResult =
171
- | { status: "created"; meetingUrl: string; meetingId: string }
218
+ | {
219
+ status: "created";
220
+ meetingUrl: string;
221
+ meetingId: string;
222
+ accountId: string;
223
+ }
172
224
  | { status: "not_started" }
173
225
  | { status: "rejected" };
174
226
 
@@ -248,9 +300,22 @@ export async function createZoomMeeting(opts: {
248
300
  status: "created",
249
301
  meetingUrl: result.meetingUrl,
250
302
  meetingId: result.meetingId,
303
+ accountId: credentialId,
251
304
  };
252
305
  }
253
306
 
307
+ export async function deleteZoomMeeting(opts: {
308
+ accountId: string;
309
+ meetingId: string;
310
+ }): Promise<void> {
311
+ const creds = getZoomCreds();
312
+ if (!creds) throw new Error("Zoom OAuth is not configured");
313
+ await createProvider(creds).deleteMeeting!({
314
+ credentialId: opts.accountId,
315
+ meetingId: opts.meetingId,
316
+ });
317
+ }
318
+
254
319
  /**
255
320
  * Resolve a fresh access token for a Zoom credential, refreshing it if it's
256
321
  * expired (or near-expiry). Persists the refreshed tokens back to oauth_tokens.
@@ -222,6 +222,19 @@ CREATE INDEX IF NOT EXISTS idx_bookings_slug_start ON bookings (slug, "start");`
222
222
  name: "bookings-meeting-link-pending",
223
223
  sql: `ALTER TABLE bookings ADD COLUMN IF NOT EXISTS meeting_link_pending BOOLEAN NOT NULL DEFAULT false`,
224
224
  },
225
+ {
226
+ version: 25,
227
+ name: "bookings-zoom-needs-review",
228
+ sql: `ALTER TABLE bookings ADD COLUMN IF NOT EXISTS zoom_needs_review BOOLEAN NOT NULL DEFAULT false`,
229
+ },
230
+ {
231
+ version: 26,
232
+ name: "bookings-zoom-provider-identifiers",
233
+ sql: `
234
+ ALTER TABLE bookings ADD COLUMN IF NOT EXISTS zoom_meeting_id TEXT;
235
+ ALTER TABLE bookings ADD COLUMN IF NOT EXISTS zoom_account_id TEXT;
236
+ `,
237
+ },
225
238
  ],
226
239
  { table: "calendar_migrations" },
227
240
  );
@@ -407,6 +407,10 @@ export interface Booking {
407
407
  googleEventId?: string;
408
408
  /** Token for cancel/reschedule link (only returned to the booker) */
409
409
  cancelToken?: string;
410
+ /** Zoom may have created the meeting even though its booking request failed. */
411
+ zoomNeedsReview?: boolean;
412
+ /** Cancellation requires a manual Zoom check because no provider ID was saved. */
413
+ zoomCancellationNeedsReview?: boolean;
410
414
  status: "confirmed" | "cancelled";
411
415
  createdAt: string;
412
416
  }
@@ -80,10 +80,16 @@ function desktopOsIcon(): typeof IconDeviceDesktop {
80
80
  return IconDeviceDesktop;
81
81
  }
82
82
 
83
+ const subscribeToDesktopPlatform = () => () => undefined;
84
+
83
85
  export function DesktopPlatformIcon(
84
86
  props: ComponentProps<typeof IconDeviceDesktop>,
85
87
  ) {
86
- const DesktopIcon = desktopOsIcon();
88
+ const DesktopIcon = useSyncExternalStore(
89
+ subscribeToDesktopPlatform,
90
+ desktopOsIcon,
91
+ () => IconDeviceDesktop,
92
+ );
87
93
  return <DesktopIcon {...props} />;
88
94
  }
89
95
 
@@ -198,11 +204,6 @@ export function CaptureInstallButton({
198
204
  );
199
205
  }
200
206
 
201
- /**
202
- * Compact recorder CTA for the web capture surface. Keep the trigger as plain
203
- * text so the action reads like a source choice; install destinations belong
204
- * in the menu rather than in a platform-specific icon treatment.
205
- */
206
207
  export function CaptureInstallMenu({
207
208
  children,
208
209
  className,
@@ -229,6 +230,7 @@ export function CaptureInstallMenu({
229
230
  attemptOpenDesktopApp(desktopHref);
230
231
  }}
231
232
  >
233
+ <DesktopPlatformIcon aria-hidden="true" className="size-4" />
232
234
  {children}
233
235
  </Button>
234
236
  );
@@ -243,6 +245,7 @@ export function CaptureInstallMenu({
243
245
  variant={variant}
244
246
  {...buttonProps}
245
247
  >
248
+ <DesktopPlatformIcon aria-hidden="true" className="size-4" />
246
249
  {children}
247
250
  <IconChevronDown className="size-3.5" />
248
251
  </Button>
@@ -451,6 +451,10 @@ export function ClipsCommandMenu({
451
451
  "The no-comments sidebar gives viewers a concise reason to try Clips and a clear path to sign up.",
452
452
  )
453
453
  .join(t("settings.changelogCommentSignup"))
454
+ .split(
455
+ "The empty comments state now explains how screen recordings help AI agents.",
456
+ )
457
+ .join(t("settings.changelogCommentsEmptyState"))
454
458
  .split(
455
459
  'Signed-in viewers who hit an unavailable, expired, or private share link now land in their library instead of the public marketing page when they choose "Go home."',
456
460
  )
@@ -359,10 +359,9 @@ const messages = {
359
359
  downloadForWindows: "Download for Windows",
360
360
  downloadForLinux: "Download for Linux",
361
361
  downloadDesktopApp: "Download desktop app",
362
- commentSignupTitle: "Give your AI agent the full picture",
363
- commentSignupContext: "Share transcript and timestamped frames in one link",
364
- commentSignupFeedback: "Turn recorded feedback into clear next steps",
365
- commentSignupDebug: "Capture console errors and failed requests",
362
+ commentSignupTitle: "Screen recordings your AI agent can see and hear",
363
+ commentSignupDescription:
364
+ "Clips is a free and open-source screen recorder for sharing bugs, feedback, and walkthroughs with AI agents.",
366
365
  agentEmptyTitle: "Join the conversation",
367
366
  agentEmptyDescription:
368
367
  "Create a free Clips account to comment, react, and ask about this clip.",
@@ -755,6 +754,8 @@ const messages = {
755
754
  changelogEmpty: "No updates yet.",
756
755
  changelogCommentSignup:
757
756
  "The no-comments sidebar gives viewers a concise reason to try Clips and a clear path to sign up.",
757
+ changelogCommentsEmptyState:
758
+ "The empty comments state now explains how screen recordings help AI agents.",
758
759
  changelogShareLink:
759
760
  'Signed-in viewers who hit an unavailable, expired, or private share link now land in their library instead of the public marketing page when they choose "Go home."',
760
761
  viewAllUpdates: "View all updates",
@@ -459,6 +459,10 @@ export default function SettingsIndexRoute() {
459
459
  "The no-comments sidebar gives viewers a concise reason to try Clips and a clear path to sign up.",
460
460
  )
461
461
  .join(t("settings.changelogCommentSignup"))
462
+ .split(
463
+ "The empty comments state now explains how screen recordings help AI agents.",
464
+ )
465
+ .join(t("settings.changelogCommentsEmptyState"))
462
466
  .split(
463
467
  'Signed-in viewers who hit an unavailable, expired, or private share link now land in their library instead of the public marketing page when they choose "Go home."',
464
468
  )
@@ -1869,12 +1869,10 @@ export default function ShareRoute() {
1869
1869
  sessionStatus !== "signing-out" &&
1870
1870
  comments.length === 0 ? (
1871
1871
  <PublicCommentsEmptyState
1872
- signInHref={signInHref}
1873
1872
  onSignUp={() => {
1874
1873
  fireShareCtaClick("signup");
1875
1874
  openCreateAccount("comment");
1876
1875
  }}
1877
- onSignIn={() => fireShareCtaClick("signin")}
1878
1876
  />
1879
1877
  ) : (
1880
1878
  <CommentsPanel
@@ -2054,45 +2052,23 @@ function formatRecordedOn(
2054
2052
  }).format(date);
2055
2053
  }
2056
2054
 
2057
- function PublicCommentsEmptyState({
2058
- signInHref,
2059
- onSignUp,
2060
- onSignIn,
2061
- }: {
2062
- signInHref: string;
2063
- onSignUp: () => void;
2064
- onSignIn: () => void;
2065
- }) {
2055
+ function PublicCommentsEmptyState({ onSignUp }: { onSignUp: () => void }) {
2066
2056
  const t = useT();
2067
2057
 
2068
2058
  return (
2069
2059
  <div className="mx-auto flex min-h-0 w-full max-w-sm flex-1 flex-col items-center justify-center gap-4 overflow-y-auto px-5 py-5 text-center">
2070
2060
  <div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
2071
- <IconDeviceDesktop aria-hidden="true" className="size-5" />
2061
+ <AgentNativeIcon aria-hidden="true" className="h-3.5 w-6" />
2072
2062
  </div>
2073
2063
  <h2 className="text-base font-semibold tracking-tight">
2074
2064
  {t("sharePage.commentSignupTitle")}
2075
2065
  </h2>
2076
2066
  <p className="max-w-xs text-sm leading-5 text-muted-foreground">
2077
- {t("sharePage.commentSignupContext")} ·{" "}
2078
- {t("sharePage.commentSignupFeedback")} ·{" "}
2079
- {t("sharePage.commentSignupDebug")}
2067
+ {t("sharePage.commentSignupDescription")}
2080
2068
  </p>
2081
- <div className="w-full space-y-2">
2082
- <Button type="button" className="w-full" onClick={onSignUp}>
2083
- {t("signInPrompt.createAccount")}
2084
- </Button>
2085
- <p className="text-center text-xs text-muted-foreground">
2086
- {t("sharePage.agentEmptySignInPrompt")}{" "}
2087
- <a
2088
- href={signInHref}
2089
- onClick={onSignIn}
2090
- className="font-medium text-foreground underline underline-offset-4 hover:no-underline"
2091
- >
2092
- {t("signInPrompt.signIn")}
2093
- </a>
2094
- </p>
2095
- </div>
2069
+ <Button type="button" className="w-full" onClick={onSignUp}>
2070
+ {t("signInPrompt.createAccount")}
2071
+ </Button>
2096
2072
  </div>
2097
2073
  );
2098
2074
  }