@agent-native/core 0.131.2 → 0.131.3

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 (55) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +28 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/types.ts +6 -0
  5. package/corpus/core/src/action.ts +13 -0
  6. package/corpus/core/src/server/agent-capabilities.ts +43 -8
  7. package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +63 -1
  8. package/corpus/templates/analytics/AGENTS.md +5 -0
  9. package/corpus/templates/analytics/actions/hubspot-deals.ts +1 -0
  10. package/corpus/templates/analytics/actions/hubspot-records.ts +1 -0
  11. package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +2 -0
  12. package/corpus/templates/calendar/actions/get-event.ts +1 -0
  13. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +1 -0
  14. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +7 -3
  15. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +14 -19
  16. package/corpus/templates/calendar/app/hooks/use-events.ts +7 -0
  17. package/corpus/templates/calendar/app/lib/event-form-utils.ts +13 -0
  18. package/corpus/templates/calendar/app/pages/CalendarView.tsx +21 -14
  19. package/corpus/templates/calendar/changelog/2026-07-29-unnamed-events-now-use-a-title-placeholder.md +6 -0
  20. package/corpus/templates/calendar/server/lib/google-calendar.ts +2 -0
  21. package/corpus/templates/calendar/server/lib/ical-fetcher.ts +1 -0
  22. package/corpus/templates/calendar/shared/api.ts +2 -0
  23. package/corpus/templates/slides/.agents/skills/analytics-data-for-decks/SKILL.md +48 -0
  24. package/corpus/templates/slides/AGENTS.md +3 -0
  25. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +72 -8
  26. package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
  27. package/corpus/templates/slides/changelog/2026-07-29-google-slides-exports-can-connect-your-google-account-direct.md +6 -0
  28. package/corpus/templates/slides/server/handlers/google-docs-auth.ts +4 -2
  29. package/dist/a2a/types.d.ts +6 -0
  30. package/dist/a2a/types.d.ts.map +1 -1
  31. package/dist/a2a/types.js.map +1 -1
  32. package/dist/action.d.ts +13 -0
  33. package/dist/action.d.ts.map +1 -1
  34. package/dist/action.js.map +1 -1
  35. package/dist/collab/struct-routes.d.ts +1 -1
  36. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  37. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  38. package/dist/notifications/routes.d.ts +5 -5
  39. package/dist/observability/routes.d.ts +3 -3
  40. package/dist/progress/routes.d.ts +1 -1
  41. package/dist/resources/handlers.d.ts +1 -1
  42. package/dist/server/agent-capabilities.d.ts.map +1 -1
  43. package/dist/server/agent-capabilities.js +32 -3
  44. package/dist/server/agent-capabilities.js.map +1 -1
  45. package/dist/server/agent-chat/action-filters-a2a.d.ts +17 -1
  46. package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
  47. package/dist/server/agent-chat/action-filters-a2a.js +50 -1
  48. package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
  49. package/dist/server/realtime-token.d.ts +1 -1
  50. package/dist/server/transcribe-voice.d.ts +1 -1
  51. package/package.json +1 -1
  52. package/src/a2a/types.ts +6 -0
  53. package/src/action.ts +13 -0
  54. package/src/server/agent-capabilities.ts +43 -8
  55. package/src/server/agent-chat/action-filters-a2a.ts +63 -1
package/corpus/README.md CHANGED
@@ -30,4 +30,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
30
30
 
31
31
  - core files: 1642
32
32
  - toolkit files: 160
33
- - template files: 7176
33
+ - template files: 7179
@@ -1,5 +1,33 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.131.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 5b67dea: Advertise A2A action input schemas in capability details.
8
+ - 5b67dea: Stop apps from handing each other raw queries to execute. An action whose input
9
+ is a program the receiver runs — `sql`, `code`, `script`, `expression` — is no
10
+ longer invocable by a sibling app over A2A.
11
+
12
+ The app that owns the data owns its schema, data dictionary, reference
13
+ dashboards, and dialect quirks. A calling app has none of that, so passing SQL
14
+ across apps makes every caller reimplement the owner's schema knowledge from
15
+ guesses, and each copy silently rots the next time the owner's shape changes.
16
+ Callers ask the owning app a question, or call a shaped action that takes
17
+ semantic parameters; the owner forms the query. `publicAgent.allowRawQueryInput`
18
+ opts a specific action out when that is genuinely the right call.
19
+
20
+ `query` is deliberately not treated as a raw query field: across the templates
21
+ it is natural-language search text (Brain's `search-everything`,
22
+ `search-knowledge`), and blocking it would break the ask-don't-instruct calls
23
+ this rule exists to encourage. MCP and in-app tool surfaces are unchanged — this
24
+ narrows cross-app invocation only.
25
+
26
+ Agent cards also now publish each advertised skill's `inputSchema`, and
27
+ `describe-workspace-apps` renders it as `input: { field*: type }`. Advertising an
28
+ action without its parameters is what led callers to invoke it with `{}` and fail
29
+ on a required field.
30
+
3
31
  ## 0.131.2
4
32
 
5
33
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.131.2",
3
+ "version": "0.131.3",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -85,6 +85,12 @@ export interface AgentSkill {
85
85
  requiresAuth?: boolean;
86
86
  isConsequential?: boolean;
87
87
  publicAgent?: PublicAgentActionConfig;
88
+ /**
89
+ * JSON Schema for the action's `input`. Advertising a skill without it tells a
90
+ * caller the action exists but not how to call it, so callers invoke with `{}`
91
+ * and get a required-property error back.
92
+ */
93
+ inputSchema?: Record<string, unknown>;
88
94
  }
89
95
 
90
96
  export interface AgentCapabilities {
@@ -200,6 +200,19 @@ export interface PublicAgentActionConfig {
200
200
  isConsequential?: boolean;
201
201
  title?: string;
202
202
  description?: string;
203
+ /**
204
+ * Allow a sibling app to invoke this action over A2A even though its input is
205
+ * a free-form query or program (`sql`, `query`, `code`, `script`,
206
+ * `expression`).
207
+ *
208
+ * Off by default and rarely correct. The app that owns the data owns its
209
+ * schema, data dictionary, and reference queries; a caller has none of that,
210
+ * so passing raw SQL across apps makes every caller reimplement the owner's
211
+ * schema knowledge and silently break when it changes. Prefer a semantic
212
+ * action the owner implements, or let the caller ask in natural language and
213
+ * form the query yourself.
214
+ */
215
+ allowRawQueryInput?: boolean;
203
216
  }
204
217
 
205
218
  export type ActionPlanModeEffect = "read" | "write" | "unknown";
@@ -136,6 +136,38 @@ export function formatCapabilitySummary(
136
136
  .join("\n");
137
137
  }
138
138
 
139
+ /**
140
+ * Render a skill's `input` contract as `input: { field*: type, other?: type }`,
141
+ * marking required fields with `*`. Returns undefined when the skill publishes
142
+ * no schema, so callers fall back to the description alone.
143
+ */
144
+ function formatSkillInput(skill: AgentSkill): string | undefined {
145
+ const schema = skill.inputSchema as
146
+ | {
147
+ properties?: Record<string, { type?: unknown }>;
148
+ required?: unknown;
149
+ }
150
+ | undefined;
151
+ const properties = schema?.properties;
152
+ if (!properties || typeof properties !== "object") return undefined;
153
+ const names = Object.keys(properties);
154
+ if (names.length === 0) return " — input: {} (no fields)";
155
+
156
+ const required = new Set(
157
+ Array.isArray(schema?.required)
158
+ ? schema.required.filter(
159
+ (name): name is string => typeof name === "string",
160
+ )
161
+ : [],
162
+ );
163
+ const fields = names.map((name) => {
164
+ const type = properties[name]?.type;
165
+ const rendered = typeof type === "string" ? type : "any";
166
+ return `${name}${required.has(name) ? "*" : "?"}: ${rendered}`;
167
+ });
168
+ return ` — input: { ${fields.join(", ")} } (* = required)`;
169
+ }
170
+
139
171
  export function formatCapabilityDetail(
140
172
  peer: PeerCapabilities,
141
173
  callHint: (appId: string) => string,
@@ -151,14 +183,17 @@ export function formatCapabilityDetail(
151
183
 
152
184
  const shown = peer.skills.slice(0, MAX_SKILLS_IN_DETAIL);
153
185
  const rest = peer.skills.length - shown.length;
154
- const lines = shown.map(
155
- (skill) =>
156
- `- ${skill.id}${skill.readOnly ? "" : " (mutating)"}: ${
157
- skill.description
158
- ? truncate(skill.description, MAX_DESCRIPTION_CHARS)
159
- : skill.name || "(no description)"
160
- }`,
161
- );
186
+ const lines = shown.map((skill) => {
187
+ const summary = skill.description
188
+ ? truncate(skill.description, MAX_DESCRIPTION_CHARS)
189
+ : skill.name || "(no description)";
190
+ // Naming an action without its parameters is what makes a caller invoke it
191
+ // with `{}` and fail on a required field, which reads as the sibling being
192
+ // broken rather than as a malformed call.
193
+ return `- ${skill.id}${skill.readOnly ? "" : " (mutating)"}: ${summary}${
194
+ formatSkillInput(skill) ?? ""
195
+ }`;
196
+ });
162
197
 
163
198
  return [
164
199
  header,
@@ -65,10 +65,49 @@ export function filterPublicAgentActions(
65
65
  );
66
66
  }
67
67
 
68
+ /**
69
+ * Input fields that unambiguously carry a program the receiver executes.
70
+ *
71
+ * Deliberately excludes `query`: across the templates that field is nearly
72
+ * always natural-language search text (Brain's `search-everything`,
73
+ * `search-knowledge`), and blocking it would break exactly the ask-don't-instruct
74
+ * calls this rule exists to encourage.
75
+ */
76
+ const RAW_QUERY_INPUT_FIELDS = new Set(["sql", "code", "script", "expression"]);
77
+
78
+ /**
79
+ * True when an action takes a free-form query or program as input — raw SQL, a
80
+ * code body, an arbitrary expression.
81
+ *
82
+ * Such an action must not be sibling-invocable over A2A. The app that owns the
83
+ * data owns its schema, data dictionary, skills, and reference queries; a
84
+ * calling app has none of that, so letting it pass SQL means every caller
85
+ * reimplements the owner's schema knowledge badly and silently rots when the
86
+ * owner's shape changes. Callers ask the owning app a question; the owner
87
+ * forms the query. Set `publicAgent.allowRawQueryInput` to opt a specific
88
+ * action out of this rule.
89
+ */
90
+ export function hasRawQueryInput(entry: ActionEntry): boolean {
91
+ const parameters = entry.tool?.parameters as
92
+ | { properties?: Record<string, { type?: unknown }> }
93
+ | undefined;
94
+ const properties = parameters?.properties;
95
+ if (!properties || typeof properties !== "object") return false;
96
+ return Object.entries(properties).some(([field, schema]) => {
97
+ const type = schema?.type;
98
+ const acceptsString =
99
+ type === "string" ||
100
+ type === undefined ||
101
+ (Array.isArray(type) && type.includes("string"));
102
+ return RAW_QUERY_INPUT_FIELDS.has(field.toLowerCase()) && acceptsString;
103
+ });
104
+ }
105
+
68
106
  /**
69
107
  * Direct A2A action calls share the authenticated external-agent policy with
70
108
  * MCP, but remain stricter: only explicitly exposed, authenticated read-only
71
- * actions can skip the receiver's model loop.
109
+ * actions can skip the receiver's model loop, and never one that takes a raw
110
+ * query the caller wrote.
72
111
  */
73
112
  export function filterDirectA2AActions(
74
113
  actions: Record<string, ActionEntry>,
@@ -86,8 +125,11 @@ export function filterDirectA2AActions(
86
125
  (autoReads &&
87
126
  isAuthenticatedReadAction(entry) &&
88
127
  !isAutoReadExcludedActionName(name));
128
+ const rawQueryAllowed =
129
+ !hasRawQueryInput(entry) || exposure?.allowRawQueryInput === true;
89
130
  return (
90
131
  selected &&
132
+ rawQueryAllowed &&
91
133
  !denied.has(name) &&
92
134
  entry.agentTool !== false &&
93
135
  entry.readOnly === true &&
@@ -108,6 +150,7 @@ export function buildPublicAgentA2ASkills(
108
150
  name: string;
109
151
  description: string;
110
152
  publicAgent: ActionEntry["publicAgent"];
153
+ inputSchema?: Record<string, unknown>;
111
154
  }> {
112
155
  return Object.entries(filterPublicAgentActions(actions)).map(
113
156
  ([name, entry]) => ({
@@ -115,6 +158,14 @@ export function buildPublicAgentA2ASkills(
115
158
  name,
116
159
  description: entry.tool.description,
117
160
  publicAgent: entry.publicAgent,
161
+ ...(entry.tool.parameters
162
+ ? {
163
+ inputSchema: entry.tool.parameters as unknown as Record<
164
+ string,
165
+ unknown
166
+ >,
167
+ }
168
+ : {}),
118
169
  }),
119
170
  );
120
171
  }
@@ -135,6 +186,7 @@ export function buildAuthenticatedAgentA2ASkills(
135
186
  name: string;
136
187
  description: string;
137
188
  publicAgent: ActionEntry["publicAgent"];
189
+ inputSchema?: Record<string, unknown>;
138
190
  }> {
139
191
  return Object.entries(filterDirectA2AActions(actions, options)).map(
140
192
  ([name, entry]) => ({
@@ -142,6 +194,16 @@ export function buildAuthenticatedAgentA2ASkills(
142
194
  name,
143
195
  description: entry.tool.description,
144
196
  publicAgent: entry.publicAgent,
197
+ // Naming an action without its parameters is what makes a caller invoke
198
+ // it with `{}` and fail on a required field.
199
+ ...(entry.tool.parameters
200
+ ? {
201
+ inputSchema: entry.tool.parameters as unknown as Record<
202
+ string,
203
+ unknown
204
+ >,
205
+ }
206
+ : {}),
145
207
  }),
146
208
  );
147
209
  }
@@ -42,6 +42,11 @@ change the numbers.
42
42
 
43
43
  ## Core Rules
44
44
 
45
+ - A sibling app asking over A2A sends a question or shaped input, never SQL. It
46
+ lacks this app's schema, data dictionary, dashboards, and dialect knowledge,
47
+ so raw-query actions (`sql`, `code`, `script`, `expression`) are not
48
+ sibling-invocable. For recurring numbers, add a shaped read action with
49
+ semantic parameters and let this app own the query.
45
50
  - Data integrity first. Never invent numbers, dimensions, filters, or source
46
51
  semantics; only present values you actually retrieved, and state uncertainty.
47
52
  - Every analytical answer carries audit context: source(s), time window,
@@ -493,6 +493,7 @@ export default defineAction({
493
493
  .describe("Optional HubSpot pagination cursor for query results."),
494
494
  }),
495
495
  http: { method: "GET" },
496
+ publicAgent: { expose: true, readOnly: true, requiresAuth: true },
496
497
  run: async ({
497
498
  properties,
498
499
  owner,
@@ -48,6 +48,7 @@ export default defineAction({
48
48
  .describe("Optional HubSpot pagination cursor from a previous result."),
49
49
  }),
50
50
  http: { method: "GET" },
51
+ publicAgent: { expose: true, readOnly: true, requiresAuth: true },
51
52
  run: async ({ objectType, query, properties, limit, after }) => {
52
53
  const result = await searchHubSpotObjects({
53
54
  objectType,
@@ -9,6 +9,8 @@ export const ANALYTICS_CONNECTOR_CATALOG = [
9
9
  "account-deep-dive",
10
10
  "gong-calls",
11
11
  "gong-native-insights",
12
+ "hubspot-deals",
13
+ "hubspot-records",
12
14
  "list-session-recordings",
13
15
  "get-session-replay-summary",
14
16
  "get-session-replay-timeline",
@@ -66,6 +66,7 @@ export default defineAction({
66
66
  const calEvent: CalendarEvent = {
67
67
  id: `google-${evt.id}`,
68
68
  title: evt.summary || "Untitled",
69
+ titleIsGenerated: !evt.summary,
69
70
  description: evt.description || "",
70
71
  start: evt.start?.dateTime || evt.start?.date || "",
71
72
  end: evt.end?.dateTime || evt.end?.date || "",
@@ -757,6 +757,7 @@ export function CreateEventPopover({
757
757
 
758
758
  const payload: Parameters<typeof createEvent.mutate>[0] = {
759
759
  title: title.trim() || undefined,
760
+ titleIsGenerated: !title.trim(),
760
761
  description: isOutOfOffice ? "" : description,
761
762
  start: startValue,
762
763
  end: endValue,
@@ -34,6 +34,7 @@ import {
34
34
  } from "@/components/ui/tooltip";
35
35
  import { useUpdateEvent } from "@/hooks/use-events";
36
36
  import { useViewPreferences } from "@/hooks/use-view-preferences";
37
+ import { getEditableEventTitle } from "@/lib/event-form-utils";
37
38
  import { isOutOfOfficeEvent } from "@/lib/out-of-office";
38
39
  import { cn } from "@/lib/utils";
39
40
  import {
@@ -338,7 +339,10 @@ export function EventDetailPanel({
338
339
  if (e.key === "Enter") {
339
340
  e.preventDefault();
340
341
  const trimmed = editingTitle.trim();
341
- if (trimmed && trimmed !== event.title) {
342
+ if (
343
+ trimmed &&
344
+ trimmed !== getEditableEventTitle(event)
345
+ ) {
342
346
  onTitleSave?.(event.id, trimmed, event.accountEmail);
343
347
  }
344
348
  setIsEditingTitle(false);
@@ -350,7 +354,7 @@ export function EventDetailPanel({
350
354
  }}
351
355
  onBlur={() => {
352
356
  const trimmed = editingTitle.trim();
353
- if (trimmed && trimmed !== event.title) {
357
+ if (trimmed && trimmed !== getEditableEventTitle(event)) {
354
358
  onTitleSave?.(event.id, trimmed, event.accountEmail);
355
359
  }
356
360
  setIsEditingTitle(false);
@@ -366,7 +370,7 @@ export function EventDetailPanel({
366
370
  )}
367
371
  onClick={() => {
368
372
  if (isWorkingLocation) return;
369
- setEditingTitle(event.title);
373
+ setEditingTitle(getEditableEventTitle(event));
370
374
  setIsEditingTitle(true);
371
375
  }}
372
376
  >
@@ -83,6 +83,7 @@ import {
83
83
  buildReminderPayload,
84
84
  dateTimeInTimezoneToIso,
85
85
  formatReminderText,
86
+ getEditableEventTitle,
86
87
  getEventEndValidationMessage,
87
88
  getLocalTimezone,
88
89
  getRecurrencePreset,
@@ -536,7 +537,7 @@ export function EventDetailPopover({
536
537
  const isMobile = useIsMobile();
537
538
  const [open, setOpen] = useState(defaultOpen);
538
539
  const [editingTitle, setEditingTitle] = useState(
539
- defaultOpen ? event.title : "",
540
+ defaultOpen ? getEditableEventTitle(event) : "",
540
541
  );
541
542
  const [isEditingTitle, setIsEditingTitle] = useState(defaultOpen);
542
543
  const isNewEventRef = useRef(defaultOpen);
@@ -675,12 +676,12 @@ export function EventDetailPopover({
675
676
  setIsEditingTitle(true);
676
677
  isNewEventRef.current = true;
677
678
  setEditingTitle((current) => {
678
- const hasDraft = current.trim().length > 0 && current !== "(No title)";
679
- if (hasDraft && current !== event.title) return current;
680
- return event.title === "(No title)" ? "" : event.title;
679
+ const eventTitle = getEditableEventTitle(event);
680
+ if (current.trim() && current !== eventTitle) return current;
681
+ return eventTitle;
681
682
  });
682
683
  }
683
- }, [defaultOpen, event.title]);
684
+ }, [defaultOpen, event.title, event.titleIsGenerated]);
684
685
 
685
686
  // Focus title input when editing starts
686
687
  useEffect(() => {
@@ -1326,10 +1327,8 @@ export function EventDetailPopover({
1326
1327
 
1327
1328
  const handleCreateDraft = useCallback(() => {
1328
1329
  if (!onDraftCreate) return;
1329
- const updates =
1330
- isEditingTitle && editingTitle.trim()
1331
- ? { title: editingTitle.trim() }
1332
- : undefined;
1330
+ const title = editingTitle.trim();
1331
+ const updates = isEditingTitle && title ? { title } : undefined;
1333
1332
  if (updates) {
1334
1333
  onTitleSave?.(event.id, updates.title, event.accountEmail);
1335
1334
  setIsEditingTitle(false);
@@ -1370,7 +1369,7 @@ export function EventDetailPopover({
1370
1369
  let savedPendingChange = false;
1371
1370
  // Popover is closing — handle saves
1372
1371
  if (isEditingTitle) {
1373
- if (trimmedTitle && trimmedTitle !== "(No title)") {
1372
+ if (trimmedTitle) {
1374
1373
  onTitleSave?.(event.id, trimmedTitle, event.accountEmail);
1375
1374
  isNewEventRef.current = false;
1376
1375
  savedPendingChange = true;
@@ -1393,7 +1392,7 @@ export function EventDetailPopover({
1393
1392
  if (
1394
1393
  isNewEventRef.current &&
1395
1394
  !savedPendingChange &&
1396
- (!trimmedTitle || trimmedTitle === "(No title)") &&
1395
+ !trimmedTitle &&
1397
1396
  onDismissNew
1398
1397
  ) {
1399
1398
  onDismissNew(event.id, event.accountEmail);
@@ -1538,7 +1537,7 @@ export function EventDetailPopover({
1538
1537
  if (e.key === "Enter") {
1539
1538
  e.preventDefault();
1540
1539
  const trimmed = editingTitle.trim();
1541
- if (trimmed && trimmed !== "(No title)") {
1540
+ if (trimmed) {
1542
1541
  onTitleSave?.(event.id, trimmed, event.accountEmail);
1543
1542
  isNewEventRef.current = false;
1544
1543
  }
@@ -1548,7 +1547,7 @@ export function EventDetailPopover({
1548
1547
  if (isNewEventRef.current && onDismissNew) {
1549
1548
  handleOpenChange(false);
1550
1549
  } else {
1551
- setEditingTitle(event.title);
1550
+ setEditingTitle(getEditableEventTitle(event));
1552
1551
  setIsEditingTitle(false);
1553
1552
  }
1554
1553
  } else if (
@@ -1564,11 +1563,7 @@ export function EventDetailPopover({
1564
1563
  }}
1565
1564
  onBlur={() => {
1566
1565
  const trimmed = editingTitle.trim();
1567
- if (
1568
- trimmed &&
1569
- trimmed !== "(No title)" &&
1570
- trimmed !== event.title
1571
- ) {
1566
+ if (trimmed && trimmed !== getEditableEventTitle(event)) {
1572
1567
  onTitleSave?.(event.id, trimmed, event.accountEmail);
1573
1568
  isNewEventRef.current = false;
1574
1569
  }
@@ -1582,7 +1577,7 @@ export function EventDetailPopover({
1582
1577
  className={`mb-4 -mx-0.5 rounded px-0.5 text-lg font-semibold leading-tight text-foreground ${!isOverlay && !isWorkingLocation ? "cursor-text hover:bg-muted/50" : ""}`}
1583
1578
  onClick={() => {
1584
1579
  if (isOverlay || isWorkingLocation) return;
1585
- setEditingTitle(event.title);
1580
+ setEditingTitle(getEditableEventTitle(event));
1586
1581
  setIsEditingTitle(true);
1587
1582
  }}
1588
1583
  >
@@ -103,6 +103,12 @@ function getEventQueryKey(id: string) {
103
103
  return ["action", "get-event", { id }] as const;
104
104
  }
105
105
 
106
+ export function getOptimisticTitleIsGenerated(
107
+ input: Pick<CreateEventInput, "title" | "titleIsGenerated">,
108
+ ): boolean {
109
+ return input.titleIsGenerated ?? !input.title?.trim();
110
+ }
111
+
106
112
  function buildOptimisticCalendarEvent(
107
113
  newData: CreateEventInput,
108
114
  optimisticId: string,
@@ -130,6 +136,7 @@ function buildOptimisticCalendarEvent(
130
136
  title:
131
137
  newData.title?.trim() ||
132
138
  (newData.eventType === "outOfOffice" ? "Out of office" : ""),
139
+ titleIsGenerated: getOptimisticTitleIsGenerated(newData),
133
140
  start,
134
141
  end,
135
142
  startTimeZone: newData.startTimeZone,
@@ -34,6 +34,19 @@ export const REMINDER_PRESETS = [
34
34
  ] as const;
35
35
 
36
36
  export const MAX_EVENT_ATTACHMENTS = 25;
37
+ export const UNNAMED_EVENT_TITLE = "(No title)";
38
+
39
+ export function getEditableEventTitle(
40
+ event: Pick<CalendarEvent, "title" | "titleIsGenerated">,
41
+ ): string {
42
+ return event.titleIsGenerated ? "" : event.title;
43
+ }
44
+
45
+ export function buildEventTitleUpdate(
46
+ title: string,
47
+ ): Pick<CalendarEvent, "title" | "titleIsGenerated"> {
48
+ return { title: title.trim(), titleIsGenerated: false };
49
+ }
37
50
 
38
51
  const DAY_CODES = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] as const;
39
52
  const DAY_CODE_BY_LABEL: Record<string, (typeof DAY_CODES)[number]> = {
@@ -86,8 +86,11 @@ import { useViewPreferences } from "@/hooks/use-view-preferences";
86
86
  import { resolveEventAccountEmail } from "@/lib/event-account-selection";
87
87
  import { getGoogleEventColorHex } from "@/lib/event-colors";
88
88
  import {
89
+ buildEventTitleUpdate,
89
90
  dateTimeInTimezoneToIso,
91
+ getEditableEventTitle,
90
92
  getLocalTimezone,
93
+ UNNAMED_EVENT_TITLE,
91
94
  } from "@/lib/event-form-utils";
92
95
  import { buildDeleteEventMutationInput } from "@/lib/event-mutation-inputs";
93
96
  import { getLocationSuggestions } from "@/lib/location-suggestions";
@@ -204,11 +207,15 @@ function draftToCalendarEvent(
204
207
  fallbackDate: Date,
205
208
  ): CalendarEvent {
206
209
  const { start, end } = draftRange(draft, fallbackDate);
210
+ const editableTitle = draft.title?.trim() ?? "";
207
211
  return {
208
212
  id: calendarDraftEventId(draft.id),
209
213
  title:
210
- draft.title?.trim() ||
211
- (draft.eventType === "outOfOffice" ? "Out of office" : "(No title)"),
214
+ editableTitle ||
215
+ (draft.eventType === "outOfOffice"
216
+ ? "Out of office"
217
+ : UNNAMED_EVENT_TITLE),
218
+ titleIsGenerated: !editableTitle,
212
219
  description: draft.description ?? "",
213
220
  start: start.toISOString(),
214
221
  end: end.toISOString(),
@@ -650,14 +657,10 @@ export default function CalendarView() {
650
657
  setEventDraft(draft);
651
658
  persistCalendarDraft(draft);
652
659
  }
653
- const trimmedTitle = draft.title?.trim();
660
+ const editableTitle = draft.title?.trim() ?? "";
654
661
  const eventType = draft.eventType ?? "default";
655
662
  const title =
656
- trimmedTitle && trimmedTitle !== "(No title)"
657
- ? trimmedTitle
658
- : eventType === "outOfOffice"
659
- ? "Out of office"
660
- : "";
663
+ editableTitle || (eventType === "outOfOffice" ? "Out of office" : "");
661
664
  if (!title && !isSlotDraftId(draftId)) {
662
665
  committingDraftIdsRef.current.delete(draftId);
663
666
  toast.error(t("calendarView.addTitleBeforeCreate"));
@@ -703,6 +706,7 @@ export default function CalendarView() {
703
706
  const payload: Parameters<typeof createEvent.mutate>[0] = {
704
707
  _tempId: eventId,
705
708
  title,
709
+ titleIsGenerated: !editableTitle,
706
710
  description:
707
711
  eventType === "outOfOffice" ? "" : (draft.description ?? ""),
708
712
  start: semanticFullDay ? draft.start! : start.toISOString(),
@@ -1359,8 +1363,9 @@ export default function CalendarView() {
1359
1363
  const handleQuickEditSave = useCallback(
1360
1364
  async (eventId: string, title: string, accountEmail?: string) => {
1361
1365
  setQuickEditEventId(null);
1366
+ const trimmedTitle = title.trim();
1362
1367
  if (calendarDraftIdFromEventId(eventId)) {
1363
- updateDraftEvent(eventId, { title: title.trim() });
1368
+ updateDraftEvent(eventId, { title: trimmedTitle });
1364
1369
  return;
1365
1370
  }
1366
1371
  setQuickEditTempIds((current) => {
@@ -1368,9 +1373,9 @@ export default function CalendarView() {
1368
1373
  const { [eventId]: _removed, ...next } = current;
1369
1374
  return next;
1370
1375
  });
1371
- if (title.trim() && title.trim() !== "(No title)") {
1376
+ if (trimmedTitle) {
1372
1377
  const event = events.find((e) => e.id === eventId);
1373
- const updates = { title: title.trim() };
1378
+ const updates = buildEventTitleUpdate(trimmedTitle);
1374
1379
  const guestNotification = event
1375
1380
  ? await promptGuestNotification({
1376
1381
  event,
@@ -1392,12 +1397,14 @@ export default function CalendarView() {
1392
1397
 
1393
1398
  const handleTitleSave = useCallback(
1394
1399
  async (eventId: string, title: string, accountEmail?: string) => {
1400
+ const trimmedTitle = title.trim();
1401
+ if (!trimmedTitle) return;
1395
1402
  if (calendarDraftIdFromEventId(eventId)) {
1396
- updateDraftEvent(eventId, { title });
1403
+ updateDraftEvent(eventId, { title: trimmedTitle });
1397
1404
  return;
1398
1405
  }
1399
1406
  const event = events.find((e) => e.id === eventId);
1400
- const updates = { title };
1407
+ const updates = buildEventTitleUpdate(trimmedTitle);
1401
1408
  const guestNotification = event
1402
1409
  ? await promptGuestNotification({
1403
1410
  event,
@@ -1430,7 +1437,7 @@ export default function CalendarView() {
1430
1437
  });
1431
1438
  // Delete the event if title was never set
1432
1439
  const ev = events.find((e) => e.id === eventId);
1433
- if (!ev || ev.title === "(No title)") {
1440
+ if (!ev || !getEditableEventTitle(ev).trim()) {
1434
1441
  deleteEvent.mutate(
1435
1442
  buildDeleteEventMutationInput(
1436
1443
  {
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-29
4
+ ---
5
+
6
+ Unnamed events now show an empty title field with an “Add title” placeholder when you edit them.
@@ -881,6 +881,7 @@ export async function listEvents(
881
881
  return {
882
882
  id: `google-${event.id}`,
883
883
  title: event.summary || "Untitled",
884
+ titleIsGenerated: !event.summary,
884
885
  description: event.description || "",
885
886
  start: event.start?.dateTime || event.start?.date || "",
886
887
  end: event.end?.dateTime || event.end?.date || "",
@@ -1153,6 +1154,7 @@ export async function getEvent(
1153
1154
  return {
1154
1155
  id: `google-${event.id}`,
1155
1156
  title: event.summary || "Untitled",
1157
+ titleIsGenerated: !event.summary,
1156
1158
  description: event.description || "",
1157
1159
  start: event.start?.dateTime || event.start?.date || "",
1158
1160
  end: event.end?.dateTime || event.end?.date || "",
@@ -327,6 +327,7 @@ export async function fetchICalEvents(
327
327
  .map((e) => ({
328
328
  id: `ical-${feedId}-${e.uid}`,
329
329
  title: e.summary || "(No title)",
330
+ titleIsGenerated: !e.summary,
330
331
  description: e.description || "",
331
332
  start: e.start,
332
333
  end: e.end,
@@ -1,6 +1,8 @@
1
1
  export interface CalendarEvent {
2
2
  id: string;
3
3
  title: string;
4
+ /** Client-facing provenance for a display label synthesized from an absent title. */
5
+ titleIsGenerated?: boolean;
4
6
  description: string;
5
7
  start: string; // ISO 8601
6
8
  end: string; // ISO 8601