@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +28 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/types.ts +6 -0
- package/corpus/core/src/action.ts +13 -0
- package/corpus/core/src/server/agent-capabilities.ts +43 -8
- package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +63 -1
- package/corpus/templates/analytics/AGENTS.md +5 -0
- package/corpus/templates/analytics/actions/hubspot-deals.ts +1 -0
- package/corpus/templates/analytics/actions/hubspot-records.ts +1 -0
- package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +2 -0
- package/corpus/templates/calendar/actions/get-event.ts +1 -0
- package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +1 -0
- package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +7 -3
- package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +14 -19
- package/corpus/templates/calendar/app/hooks/use-events.ts +7 -0
- package/corpus/templates/calendar/app/lib/event-form-utils.ts +13 -0
- package/corpus/templates/calendar/app/pages/CalendarView.tsx +21 -14
- package/corpus/templates/calendar/changelog/2026-07-29-unnamed-events-now-use-a-title-placeholder.md +6 -0
- package/corpus/templates/calendar/server/lib/google-calendar.ts +2 -0
- package/corpus/templates/calendar/server/lib/ical-fetcher.ts +1 -0
- package/corpus/templates/calendar/shared/api.ts +2 -0
- package/corpus/templates/slides/.agents/skills/analytics-data-for-decks/SKILL.md +48 -0
- package/corpus/templates/slides/AGENTS.md +3 -0
- package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +72 -8
- package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
- package/corpus/templates/slides/changelog/2026-07-29-google-slides-exports-can-connect-your-google-account-direct.md +6 -0
- package/corpus/templates/slides/server/handlers/google-docs-auth.ts +4 -2
- package/dist/a2a/types.d.ts +6 -0
- package/dist/a2a/types.d.ts.map +1 -1
- package/dist/a2a/types.js.map +1 -1
- package/dist/action.d.ts +13 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
- package/dist/notifications/routes.d.ts +5 -5
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-capabilities.d.ts.map +1 -1
- package/dist/server/agent-capabilities.js +32 -3
- package/dist/server/agent-capabilities.js.map +1 -1
- package/dist/server/agent-chat/action-filters-a2a.d.ts +17 -1
- package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
- package/dist/server/agent-chat/action-filters-a2a.js +50 -1
- package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
- package/src/a2a/types.ts +6 -0
- package/src/action.ts +13 -0
- package/src/server/agent-capabilities.ts +43 -8
- package/src/server/agent-chat/action-filters-a2a.ts +63 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -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
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.131.
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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,
|
|
@@ -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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
679
|
-
if (
|
|
680
|
-
return
|
|
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
|
|
1330
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
211
|
-
(draft.eventType === "outOfOffice"
|
|
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
|
|
660
|
+
const editableTitle = draft.title?.trim() ?? "";
|
|
654
661
|
const eventType = draft.eventType ?? "default";
|
|
655
662
|
const title =
|
|
656
|
-
|
|
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:
|
|
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 (
|
|
1376
|
+
if (trimmedTitle) {
|
|
1372
1377
|
const event = events.find((e) => e.id === eventId);
|
|
1373
|
-
const updates =
|
|
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 =
|
|
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.
|
|
1440
|
+
if (!ev || !getEditableEventTitle(ev).trim()) {
|
|
1434
1441
|
deleteEvent.mutate(
|
|
1435
1442
|
buildDeleteEventMutationInput(
|
|
1436
1443
|
{
|
|
@@ -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 || "",
|