@agent-native/core 0.172.1 → 0.172.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/templates/calendar/changelog/2026-08-24-overlapping-events-use-space-better.md +1 -0
- package/corpus/templates/clips/actions/lib/finalize-ended-meetings.ts +37 -0
- package/corpus/templates/clips/actions/request-transcript.ts +4 -0
- package/corpus/templates/clips/actions/save-browser-transcript.ts +4 -0
- package/corpus/templates/clips/desktop/src/app.tsx +436 -365
- package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +17 -2
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +21 -2
- package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +1 -0
- package/corpus/templates/clips/desktop/src/styles.css +17 -3
- package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +114 -20
- package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +130 -15
- package/corpus/templates/clips/desktop/src-tauri/src/permission_status.rs +3 -4
- package/corpus/templates/content/actions/_database-row-mutation.ts +22 -2
- package/dist/agent/production-agent.js +18 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/vite/client.js +1 -0
- package/package.json +1 -1
|
@@ -116,6 +116,9 @@ export function ReadinessPanel({
|
|
|
116
116
|
|
|
117
117
|
const [statuses, setStatuses] = useState<PermissionStatuses | null>(null);
|
|
118
118
|
const [checking, setChecking] = useState(false);
|
|
119
|
+
const [refreshingPane, setRefreshingPane] = useState<MacosPrivacyPane | null>(
|
|
120
|
+
null,
|
|
121
|
+
);
|
|
119
122
|
|
|
120
123
|
const checkStatuses = useCallback(async () => {
|
|
121
124
|
setChecking(true);
|
|
@@ -126,6 +129,18 @@ export function ReadinessPanel({
|
|
|
126
129
|
}
|
|
127
130
|
}, []);
|
|
128
131
|
|
|
132
|
+
const refreshPermissions = useCallback(
|
|
133
|
+
(pane: MacosPrivacyPane) => {
|
|
134
|
+
setRefreshingPane(null);
|
|
135
|
+
window.requestAnimationFrame(() => setRefreshingPane(pane));
|
|
136
|
+
window.setTimeout(() => {
|
|
137
|
+
setRefreshingPane((current) => (current === pane ? null : current));
|
|
138
|
+
}, 450);
|
|
139
|
+
void checkStatuses();
|
|
140
|
+
},
|
|
141
|
+
[checkStatuses],
|
|
142
|
+
);
|
|
143
|
+
|
|
129
144
|
const grantPermission = useCallback(
|
|
130
145
|
(pane: MacosPrivacyPane) =>
|
|
131
146
|
requestOrOpenPermission(pane, {
|
|
@@ -172,8 +187,8 @@ export function ReadinessPanel({
|
|
|
172
187
|
{mac ? (
|
|
173
188
|
<button
|
|
174
189
|
type="button"
|
|
175
|
-
className={`readiness-refresh ${
|
|
176
|
-
onClick={
|
|
190
|
+
className={`readiness-refresh ${refreshingPane === item.pane ? "readiness-refresh-rotating" : ""}`}
|
|
191
|
+
onClick={() => refreshPermissions(item.pane)}
|
|
177
192
|
disabled={checking}
|
|
178
193
|
aria-label="Recheck permissions"
|
|
179
194
|
title="Recheck"
|
|
@@ -306,8 +306,27 @@ export function useMeetingTranscription({
|
|
|
306
306
|
capturedUntil: string;
|
|
307
307
|
} | null;
|
|
308
308
|
} = { current: null };
|
|
309
|
+
let includeFromMeetingStart = payload.includeFromMeetingStart === true;
|
|
309
310
|
try {
|
|
310
|
-
if (
|
|
311
|
+
if (
|
|
312
|
+
!includeFromMeetingStart &&
|
|
313
|
+
payload.reason === "user" &&
|
|
314
|
+
payload.scheduledStart
|
|
315
|
+
) {
|
|
316
|
+
try {
|
|
317
|
+
const historyStatus = await invoke<{ available: boolean }>(
|
|
318
|
+
"rewind_meeting_history_status",
|
|
319
|
+
{ scheduledStart: payload.scheduledStart },
|
|
320
|
+
);
|
|
321
|
+
includeFromMeetingStart = historyStatus.available === true;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.warn(
|
|
324
|
+
"[clips-popover] Rewind meeting history status unavailable:",
|
|
325
|
+
error,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (includeFromMeetingStart) {
|
|
311
330
|
if (payload.reason !== "user" || !payload.scheduledStart) {
|
|
312
331
|
throw new Error(
|
|
313
332
|
"Include from meeting start is only available when you manually start a scheduled meeting.",
|
|
@@ -614,7 +633,7 @@ export function useMeetingTranscription({
|
|
|
614
633
|
// The local index may need a moment after the fragment fence. Anchor
|
|
615
634
|
// the live engine where it actually begins, not at the earlier click,
|
|
616
635
|
// so every stored segment remains on one honest meeting timeline.
|
|
617
|
-
if (
|
|
636
|
+
if (includeFromMeetingStart && payload.scheduledStart) {
|
|
618
637
|
session.liveTimelineOffsetMs = Math.max(
|
|
619
638
|
0,
|
|
620
639
|
Date.now() - Date.parse(payload.scheduledStart),
|
|
@@ -350,6 +350,7 @@ export function MeetingNotification() {
|
|
|
350
350
|
meetingId: data.meetingId,
|
|
351
351
|
joinUrl: data.joinUrl,
|
|
352
352
|
reason: "user",
|
|
353
|
+
scheduledStart: data.scheduledStart,
|
|
353
354
|
}).catch((err) => {
|
|
354
355
|
setPending(false);
|
|
355
356
|
setError((err as Error)?.message ?? "Could not start notes.");
|
|
@@ -1131,9 +1131,17 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
1131
1131
|
cursor: pointer;
|
|
1132
1132
|
border-radius: var(--radius-sm);
|
|
1133
1133
|
transition:
|
|
1134
|
+
opacity 120ms,
|
|
1134
1135
|
color 120ms,
|
|
1135
1136
|
background 120ms;
|
|
1136
1137
|
flex-shrink: 0;
|
|
1138
|
+
opacity: 0;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
.readiness-item:hover .readiness-refresh,
|
|
1142
|
+
.readiness-item:focus-within .readiness-refresh,
|
|
1143
|
+
.readiness-item .readiness-refresh-rotating {
|
|
1144
|
+
opacity: 1;
|
|
1137
1145
|
}
|
|
1138
1146
|
|
|
1139
1147
|
.readiness-refresh:hover {
|
|
@@ -1146,8 +1154,8 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
1146
1154
|
cursor: default;
|
|
1147
1155
|
}
|
|
1148
1156
|
|
|
1149
|
-
.readiness-refresh-
|
|
1150
|
-
animation: readiness-spin
|
|
1157
|
+
.readiness-refresh-rotating svg {
|
|
1158
|
+
animation: readiness-spin 420ms ease-out;
|
|
1151
1159
|
}
|
|
1152
1160
|
|
|
1153
1161
|
@keyframes readiness-spin {
|
|
@@ -1156,6 +1164,12 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
1156
1164
|
}
|
|
1157
1165
|
}
|
|
1158
1166
|
|
|
1167
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1168
|
+
.readiness-refresh-rotating svg {
|
|
1169
|
+
animation: none;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1159
1173
|
.readiness-empty {
|
|
1160
1174
|
padding: 8px;
|
|
1161
1175
|
color: var(--fg-muted);
|
|
@@ -1620,7 +1634,7 @@ body[data-clips-route="recording-pill"] #root {
|
|
|
1620
1634
|
flex-direction: column;
|
|
1621
1635
|
gap: 6px;
|
|
1622
1636
|
margin-bottom: 4px;
|
|
1623
|
-
max-height:
|
|
1637
|
+
max-height: 420px;
|
|
1624
1638
|
overflow-y: auto;
|
|
1625
1639
|
overflow-x: hidden;
|
|
1626
1640
|
overscroll-behavior: contain;
|
|
@@ -17,13 +17,16 @@ use tauri::{AppHandle, Emitter, Manager};
|
|
|
17
17
|
|
|
18
18
|
use crate::config::{feature_config, MeetingTranscriptionMode};
|
|
19
19
|
use crate::dlog;
|
|
20
|
-
use crate::meetings_watcher::
|
|
20
|
+
use crate::meetings_watcher::{
|
|
21
|
+
find_matching_calendar_meeting, parse_meetings, MeetingItem, MeetingsWatcherState,
|
|
22
|
+
CALENDAR_MATCH_WINDOW_MINUTES,
|
|
23
|
+
};
|
|
21
24
|
|
|
22
25
|
/// How often to sample the frontmost app.
|
|
23
|
-
const POLL_SECS: u64 =
|
|
26
|
+
const POLL_SECS: u64 = 2;
|
|
24
27
|
|
|
25
28
|
/// Require this many consecutive frontmost seconds before firing.
|
|
26
|
-
const DWELL_SECS: u64 =
|
|
29
|
+
const DWELL_SECS: u64 = 3;
|
|
27
30
|
|
|
28
31
|
/// After dismiss / fire, suppress re-prompts for this platform until the
|
|
29
32
|
/// cooldown elapses (or the VC app leaves front — see tick logic).
|
|
@@ -202,7 +205,7 @@ async fn tick_macos(
|
|
|
202
205
|
let front = crate::util::frontmost_bundle_id();
|
|
203
206
|
let matched = front.as_deref().and_then(match_vc_bundle);
|
|
204
207
|
|
|
205
|
-
let Some((platform,
|
|
208
|
+
let Some((platform, fallback_title)) = matched else {
|
|
206
209
|
// VC app left front — clear session dedupe so a later re-focus can
|
|
207
210
|
// fire again (cooldown still applies).
|
|
208
211
|
clear_session_if_left(app, None);
|
|
@@ -294,8 +297,8 @@ async fn tick_macos(
|
|
|
294
297
|
platform
|
|
295
298
|
);
|
|
296
299
|
|
|
297
|
-
let
|
|
298
|
-
Ok(
|
|
300
|
+
let meeting = match create_adhoc_meeting(app, client, platform).await {
|
|
301
|
+
Ok(meeting) => meeting,
|
|
299
302
|
Err(err) => {
|
|
300
303
|
// Allow retry on next dwell if create failed.
|
|
301
304
|
if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
|
|
@@ -315,22 +318,34 @@ async fn tick_macos(
|
|
|
315
318
|
|
|
316
319
|
if show_widget {
|
|
317
320
|
let app_clone = app.clone();
|
|
318
|
-
let id_clone =
|
|
319
|
-
let title_clone =
|
|
320
|
-
|
|
321
|
-
|
|
321
|
+
let id_clone = meeting.id.clone();
|
|
322
|
+
let title_clone = meeting
|
|
323
|
+
.title
|
|
324
|
+
.clone()
|
|
325
|
+
.unwrap_or_else(|| fallback_title.to_string());
|
|
326
|
+
let platform_clone = meeting
|
|
327
|
+
.platform
|
|
328
|
+
.clone()
|
|
329
|
+
.unwrap_or_else(|| platform.to_string());
|
|
330
|
+
let scheduled_start = meeting
|
|
331
|
+
.scheduled_start
|
|
332
|
+
.clone()
|
|
333
|
+
.or_else(|| Some(chrono::Utc::now().to_rfc3339()));
|
|
334
|
+
let scheduled_end = meeting.scheduled_end.clone();
|
|
335
|
+
let join_url = meeting.join_url.clone();
|
|
336
|
+
let source = meeting.source.clone().or_else(|| Some("adhoc".to_string()));
|
|
322
337
|
tauri::async_runtime::spawn(async move {
|
|
323
338
|
let _ = crate::notifications::notify_meeting_starting(
|
|
324
339
|
app_clone,
|
|
325
340
|
id_clone,
|
|
326
341
|
title_clone,
|
|
327
342
|
0,
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
343
|
+
join_url,
|
|
344
|
+
scheduled_start,
|
|
345
|
+
scheduled_end,
|
|
331
346
|
Some(platform_clone),
|
|
332
347
|
Some(auto_start),
|
|
333
|
-
|
|
348
|
+
source,
|
|
334
349
|
)
|
|
335
350
|
.await;
|
|
336
351
|
});
|
|
@@ -340,9 +355,10 @@ async fn tick_macos(
|
|
|
340
355
|
let _ = app.emit(
|
|
341
356
|
"meetings:start-transcription",
|
|
342
357
|
serde_json::json!({
|
|
343
|
-
"meetingId":
|
|
344
|
-
"joinUrl":
|
|
358
|
+
"meetingId": meeting.id,
|
|
359
|
+
"joinUrl": meeting.join_url,
|
|
345
360
|
"reason": "adhoc-auto",
|
|
361
|
+
"scheduledStart": meeting.scheduled_start,
|
|
346
362
|
}),
|
|
347
363
|
);
|
|
348
364
|
}
|
|
@@ -472,26 +488,49 @@ async fn create_adhoc_meeting(
|
|
|
472
488
|
app: &AppHandle,
|
|
473
489
|
client: &reqwest::Client,
|
|
474
490
|
platform: &str,
|
|
475
|
-
) -> Result<
|
|
491
|
+
) -> Result<MeetingItem, String> {
|
|
476
492
|
let session = app
|
|
477
493
|
.try_state::<MeetingsWatcherState>()
|
|
478
494
|
.map(|s| s.session_snapshot())
|
|
479
495
|
.unwrap_or_default();
|
|
480
|
-
let Some(server_url) = session.server_url else {
|
|
496
|
+
let Some(server_url) = session.server_url.as_deref() else {
|
|
481
497
|
return Err("no server_url for create-meeting".to_string());
|
|
482
498
|
};
|
|
483
499
|
|
|
500
|
+
// The native watcher knows which conferencing app is active, but not the
|
|
501
|
+
// calendar event title. Resolve the nearest joinable event first so a
|
|
502
|
+
// calendar-backed meeting keeps its title, URL, and scheduled span. A
|
|
503
|
+
// disconnected calendar only loses that enrichment and still gets an
|
|
504
|
+
// adhoc meeting below.
|
|
505
|
+
match find_calendar_meeting(app, client, server_url, &session, platform).await {
|
|
506
|
+
Ok(Some(meeting)) => {
|
|
507
|
+
dlog!(
|
|
508
|
+
"[clips-tray] adhoc matched calendar meeting {} for {}",
|
|
509
|
+
meeting.id,
|
|
510
|
+
platform
|
|
511
|
+
);
|
|
512
|
+
return Ok(meeting);
|
|
513
|
+
}
|
|
514
|
+
Ok(None) => {}
|
|
515
|
+
Err(error) => dlog!(
|
|
516
|
+
"[clips-tray] adhoc calendar title lookup skipped for {}: {}",
|
|
517
|
+
platform,
|
|
518
|
+
error
|
|
519
|
+
),
|
|
520
|
+
}
|
|
521
|
+
|
|
484
522
|
let url = format!("{}/_agent-native/actions/create-meeting", server_url);
|
|
485
523
|
let row_title = if platform == "zoom" {
|
|
486
524
|
"Zoom meeting"
|
|
487
525
|
} else {
|
|
488
526
|
"Teams meeting"
|
|
489
527
|
};
|
|
528
|
+
let scheduled_start = chrono::Utc::now().to_rfc3339();
|
|
490
529
|
let body = serde_json::json!({
|
|
491
530
|
"title": row_title,
|
|
492
531
|
"platform": platform,
|
|
493
532
|
"source": "adhoc",
|
|
494
|
-
"scheduledStart":
|
|
533
|
+
"scheduledStart": scheduled_start,
|
|
495
534
|
});
|
|
496
535
|
|
|
497
536
|
let mut req = client
|
|
@@ -524,14 +563,69 @@ async fn create_adhoc_meeting(
|
|
|
524
563
|
));
|
|
525
564
|
}
|
|
526
565
|
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
|
527
|
-
extract_meeting_id(&body).ok_or_else(|| {
|
|
566
|
+
let id = extract_meeting_id(&body).ok_or_else(|| {
|
|
528
567
|
format!(
|
|
529
568
|
"create-meeting response missing meeting id: {}",
|
|
530
569
|
body.to_string().chars().take(200).collect::<String>()
|
|
531
570
|
)
|
|
571
|
+
})?;
|
|
572
|
+
Ok(MeetingItem {
|
|
573
|
+
id,
|
|
574
|
+
title: Some(row_title.to_string()),
|
|
575
|
+
scheduled_start: Some(scheduled_start),
|
|
576
|
+
scheduled_end: None,
|
|
577
|
+
join_url: None,
|
|
578
|
+
platform: Some(platform.to_string()),
|
|
579
|
+
source: Some("adhoc".to_string()),
|
|
532
580
|
})
|
|
533
581
|
}
|
|
534
582
|
|
|
583
|
+
async fn find_calendar_meeting(
|
|
584
|
+
app: &AppHandle,
|
|
585
|
+
client: &reqwest::Client,
|
|
586
|
+
server_url: &str,
|
|
587
|
+
session: &crate::meetings_watcher::MeetingsSessionSnapshot,
|
|
588
|
+
platform: &str,
|
|
589
|
+
) -> Result<Option<MeetingItem>, String> {
|
|
590
|
+
let url = format!("{server_url}/_agent-native/actions/list-meetings");
|
|
591
|
+
let upcoming_within_min = CALENDAR_MATCH_WINDOW_MINUTES.to_string();
|
|
592
|
+
let mut req = client.get(url).query(&[
|
|
593
|
+
("view", "upcoming"),
|
|
594
|
+
("limit", "20"),
|
|
595
|
+
("upcomingWithinMin", upcoming_within_min.as_str()),
|
|
596
|
+
("includeStartedWithinMin", "15"),
|
|
597
|
+
("excludePersonalSoloEvents", "true"),
|
|
598
|
+
("excludeDeclinedEvents", "true"),
|
|
599
|
+
]);
|
|
600
|
+
req = req.header("X-Request-Source", "clips-desktop");
|
|
601
|
+
if let Some(cookie) = session.session_cookie.as_deref() {
|
|
602
|
+
req = req.header("Cookie", cookie);
|
|
603
|
+
}
|
|
604
|
+
if let Some(token) = session.auth_token.as_deref() {
|
|
605
|
+
req = req.bearer_auth(token);
|
|
606
|
+
}
|
|
607
|
+
let response = req
|
|
608
|
+
.send()
|
|
609
|
+
.await
|
|
610
|
+
.map_err(|error| format!("list-meetings fetch: {error}"))?;
|
|
611
|
+
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
|
612
|
+
let _ = app.emit("meetings:auth-needed", serde_json::json!({}));
|
|
613
|
+
return Err("list-meetings http 401".to_string());
|
|
614
|
+
}
|
|
615
|
+
if !response.status().is_success() {
|
|
616
|
+
return Err(format!("list-meetings http {}", response.status()));
|
|
617
|
+
}
|
|
618
|
+
let body: serde_json::Value = response
|
|
619
|
+
.json()
|
|
620
|
+
.await
|
|
621
|
+
.map_err(|error| format!("list-meetings response: {error}"))?;
|
|
622
|
+
Ok(find_matching_calendar_meeting(
|
|
623
|
+
&parse_meetings(&body),
|
|
624
|
+
platform,
|
|
625
|
+
chrono::Utc::now(),
|
|
626
|
+
))
|
|
627
|
+
}
|
|
628
|
+
|
|
535
629
|
fn extract_meeting_id(body: &serde_json::Value) -> Option<String> {
|
|
536
630
|
// Framework wraps action returns as `{ result: { meeting, created } }`.
|
|
537
631
|
let meeting = body
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//! Background poller for upcoming meetings.
|
|
2
2
|
//!
|
|
3
|
-
//! Runs as a tokio task spawned from `lib.rs::run` setup. Every
|
|
3
|
+
//! Runs as a tokio task spawned from `lib.rs::run` setup. Every 10s it calls
|
|
4
4
|
//! the backend's `list-meetings` action for the next handful of live Google
|
|
5
5
|
//! Calendar meetings. For any meeting in the Granola-style reminder window
|
|
6
6
|
//! (1 minute before start through 5 minutes after) that we haven't already
|
|
@@ -134,21 +134,24 @@ impl MeetingsWatcherState {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
137
|
-
struct MeetingItem {
|
|
138
|
-
id: String,
|
|
139
|
-
title: Option<String>,
|
|
137
|
+
pub(crate) struct MeetingItem {
|
|
138
|
+
pub(crate) id: String,
|
|
139
|
+
pub(crate) title: Option<String>,
|
|
140
140
|
#[serde(default, alias = "scheduledStart")]
|
|
141
|
-
scheduled_start: Option<String>,
|
|
141
|
+
pub(crate) scheduled_start: Option<String>,
|
|
142
142
|
#[serde(default, alias = "scheduledEnd")]
|
|
143
|
-
scheduled_end: Option<String>,
|
|
143
|
+
pub(crate) scheduled_end: Option<String>,
|
|
144
144
|
#[serde(default, alias = "joinUrl")]
|
|
145
|
-
join_url: Option<String>,
|
|
145
|
+
pub(crate) join_url: Option<String>,
|
|
146
146
|
#[serde(default)]
|
|
147
|
-
platform: Option<String>,
|
|
147
|
+
pub(crate) platform: Option<String>,
|
|
148
148
|
#[serde(default)]
|
|
149
|
-
source: Option<String>,
|
|
149
|
+
pub(crate) source: Option<String>,
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
pub(crate) const CALENDAR_MATCH_WINDOW_MINUTES: i64 = 15;
|
|
153
|
+
const CALENDAR_MATCH_AMBIGUITY_MARGIN_SECS: i64 = 60;
|
|
154
|
+
|
|
152
155
|
#[derive(Debug, Deserialize)]
|
|
153
156
|
struct ListMeetingsResponse {
|
|
154
157
|
#[serde(default)]
|
|
@@ -245,7 +248,7 @@ pub fn spawn_watcher(app: AppHandle) {
|
|
|
245
248
|
}
|
|
246
249
|
|
|
247
250
|
async fn run_watcher(app: AppHandle) {
|
|
248
|
-
let mut interval = tokio::time::interval(Duration::from_secs(
|
|
251
|
+
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
|
249
252
|
// Skip the first tick — gives the frontend time to push us a server URL.
|
|
250
253
|
interval.tick().await;
|
|
251
254
|
let client = match reqwest::Client::builder()
|
|
@@ -316,7 +319,7 @@ async fn tick_once(app: &AppHandle, client: &reqwest::Client) -> Result<(), Stri
|
|
|
316
319
|
let status = resp.status();
|
|
317
320
|
if status == reqwest::StatusCode::UNAUTHORIZED {
|
|
318
321
|
// Tell the renderer to re-push a fresh cookie or surface a
|
|
319
|
-
// re-login prompt. We keep silently retrying every
|
|
322
|
+
// re-login prompt. We keep silently retrying every 10s - once
|
|
320
323
|
// the renderer pushes a new cookie via
|
|
321
324
|
// `meetings_watcher_set_session` we'll succeed on the next tick.
|
|
322
325
|
let _ = app.emit("meetings:auth-needed", serde_json::json!({}));
|
|
@@ -454,8 +457,47 @@ fn is_calendar_reminder_candidate(meeting: &MeetingItem) -> bool {
|
|
|
454
457
|
meeting.source.as_deref() != Some("adhoc")
|
|
455
458
|
}
|
|
456
459
|
|
|
457
|
-
|
|
458
|
-
|
|
460
|
+
pub(crate) fn find_matching_calendar_meeting(
|
|
461
|
+
meetings: &[MeetingItem],
|
|
462
|
+
platform: &str,
|
|
463
|
+
started_at: chrono::DateTime<chrono::Utc>,
|
|
464
|
+
) -> Option<MeetingItem> {
|
|
465
|
+
let mut candidates: Vec<_> = meetings
|
|
466
|
+
.iter()
|
|
467
|
+
.filter_map(|meeting| {
|
|
468
|
+
if !is_calendar_reminder_candidate(meeting)
|
|
469
|
+
|| meeting
|
|
470
|
+
.platform
|
|
471
|
+
.as_deref()
|
|
472
|
+
.is_none_or(|value| !value.eq_ignore_ascii_case(platform))
|
|
473
|
+
|| meeting.join_url.as_deref().is_none_or(str::is_empty)
|
|
474
|
+
{
|
|
475
|
+
return None;
|
|
476
|
+
}
|
|
477
|
+
let scheduled_start = meeting
|
|
478
|
+
.scheduled_start
|
|
479
|
+
.as_deref()
|
|
480
|
+
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())?
|
|
481
|
+
.with_timezone(&chrono::Utc);
|
|
482
|
+
let distance = (scheduled_start - started_at).num_seconds().abs();
|
|
483
|
+
(distance <= CALENDAR_MATCH_WINDOW_MINUTES * 60).then(|| (distance, meeting))
|
|
484
|
+
})
|
|
485
|
+
.collect();
|
|
486
|
+
candidates.sort_by_key(|(distance, _)| *distance);
|
|
487
|
+
let Some((distance, meeting)) = candidates.first() else {
|
|
488
|
+
return None;
|
|
489
|
+
};
|
|
490
|
+
if candidates.get(1).is_some_and(|(next_distance, _)| {
|
|
491
|
+
next_distance - distance <= CALENDAR_MATCH_AMBIGUITY_MARGIN_SECS
|
|
492
|
+
}) {
|
|
493
|
+
return None;
|
|
494
|
+
}
|
|
495
|
+
Some((*meeting).clone())
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
pub(crate) fn parse_meetings(body: &serde_json::Value) -> Vec<MeetingItem> {
|
|
499
|
+
let payload = body.get("result").unwrap_or(body);
|
|
500
|
+
if let Ok(parsed) = serde_json::from_value::<ListMeetingsResponse>(payload.clone()) {
|
|
459
501
|
if let Some(v) = parsed.upcoming {
|
|
460
502
|
return v;
|
|
461
503
|
}
|
|
@@ -466,7 +508,7 @@ fn parse_meetings(body: &serde_json::Value) -> Vec<MeetingItem> {
|
|
|
466
508
|
return v;
|
|
467
509
|
}
|
|
468
510
|
}
|
|
469
|
-
if let Ok(arr) = serde_json::from_value::<Vec<MeetingItem>>(
|
|
511
|
+
if let Ok(arr) = serde_json::from_value::<Vec<MeetingItem>>(payload.clone()) {
|
|
470
512
|
return arr;
|
|
471
513
|
}
|
|
472
514
|
Vec::new()
|
|
@@ -474,7 +516,9 @@ fn parse_meetings(body: &serde_json::Value) -> Vec<MeetingItem> {
|
|
|
474
516
|
|
|
475
517
|
#[cfg(test)]
|
|
476
518
|
mod tests {
|
|
477
|
-
use
|
|
519
|
+
use chrono::{TimeZone, Utc};
|
|
520
|
+
|
|
521
|
+
use super::{find_matching_calendar_meeting, is_calendar_reminder_candidate, parse_meetings};
|
|
478
522
|
|
|
479
523
|
#[test]
|
|
480
524
|
fn excludes_adhoc_meetings_from_calendar_reminders() {
|
|
@@ -512,4 +556,75 @@ mod tests {
|
|
|
512
556
|
assert_eq!(meetings.len(), 1);
|
|
513
557
|
assert!(is_calendar_reminder_candidate(&meetings[0]));
|
|
514
558
|
}
|
|
559
|
+
|
|
560
|
+
#[test]
|
|
561
|
+
fn finds_the_nearest_joinable_calendar_meeting_for_adhoc_detection() {
|
|
562
|
+
let meetings = parse_meetings(&serde_json::json!({
|
|
563
|
+
"result": {
|
|
564
|
+
"meetings": [
|
|
565
|
+
{
|
|
566
|
+
"id": "too-far",
|
|
567
|
+
"title": "Later Zoom",
|
|
568
|
+
"scheduledStart": "2026-07-22T19:40:00Z",
|
|
569
|
+
"joinUrl": "https://zoom.us/j/2",
|
|
570
|
+
"platform": "zoom",
|
|
571
|
+
"source": "calendar"
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
"id": "nearest",
|
|
575
|
+
"title": "Product sync",
|
|
576
|
+
"scheduledStart": "2026-07-22T19:11:00Z",
|
|
577
|
+
"joinUrl": "https://zoom.us/j/1",
|
|
578
|
+
"platform": "zoom",
|
|
579
|
+
"source": "calendar"
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
"id": "adhoc",
|
|
583
|
+
"title": "Zoom meeting",
|
|
584
|
+
"scheduledStart": "2026-07-22T19:10:00Z",
|
|
585
|
+
"joinUrl": "https://zoom.us/j/3",
|
|
586
|
+
"platform": "zoom",
|
|
587
|
+
"source": "adhoc"
|
|
588
|
+
}
|
|
589
|
+
]
|
|
590
|
+
}
|
|
591
|
+
}));
|
|
592
|
+
|
|
593
|
+
let started_at = Utc.with_ymd_and_hms(2026, 7, 22, 19, 10, 0).unwrap();
|
|
594
|
+
let matched = find_matching_calendar_meeting(&meetings, "zoom", started_at);
|
|
595
|
+
|
|
596
|
+
assert_eq!(
|
|
597
|
+
matched.as_ref().map(|meeting| meeting.id.as_str()),
|
|
598
|
+
Some("nearest")
|
|
599
|
+
);
|
|
600
|
+
assert_eq!(
|
|
601
|
+
matched.and_then(|meeting| meeting.title),
|
|
602
|
+
Some("Product sync".to_string())
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
#[test]
|
|
607
|
+
fn avoids_ambiguous_same_platform_calendar_matches() {
|
|
608
|
+
let meetings = parse_meetings(&serde_json::json!({
|
|
609
|
+
"meetings": [
|
|
610
|
+
{
|
|
611
|
+
"id": "first",
|
|
612
|
+
"scheduledStart": "2026-07-22T19:09:00Z",
|
|
613
|
+
"joinUrl": "https://zoom.us/j/1",
|
|
614
|
+
"platform": "zoom",
|
|
615
|
+
"source": "calendar"
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
"id": "second",
|
|
619
|
+
"scheduledStart": "2026-07-22T19:11:00Z",
|
|
620
|
+
"joinUrl": "https://zoom.us/j/2",
|
|
621
|
+
"platform": "zoom",
|
|
622
|
+
"source": "calendar"
|
|
623
|
+
}
|
|
624
|
+
]
|
|
625
|
+
}));
|
|
626
|
+
|
|
627
|
+
let started_at = Utc.with_ymd_and_hms(2026, 7, 22, 19, 10, 0).unwrap();
|
|
628
|
+
assert!(find_matching_calendar_meeting(&meetings, "zoom", started_at).is_none());
|
|
629
|
+
}
|
|
515
630
|
}
|
|
@@ -44,9 +44,8 @@ mod macos {
|
|
|
44
44
|
|
|
45
45
|
extern "C" {
|
|
46
46
|
fn CGPreflightScreenCaptureAccess() -> bool;
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
fn IOHIDCheckAccess(request_type: u32) -> i32;
|
|
47
|
+
// This is the preflight for the CGEventTap used by the Fn listener.
|
|
48
|
+
fn CGPreflightListenEventAccess() -> bool;
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
pub fn check_all() -> PermissionStatuses {
|
|
@@ -85,6 +84,6 @@ mod macos {
|
|
|
85
84
|
}
|
|
86
85
|
|
|
87
86
|
fn check_input_monitoring() -> bool {
|
|
88
|
-
unsafe {
|
|
87
|
+
unsafe { CGPreflightListenEventAccess() }
|
|
89
88
|
}
|
|
90
89
|
}
|
|
@@ -870,6 +870,26 @@ async function withMutationLocks<T>(
|
|
|
870
870
|
);
|
|
871
871
|
}
|
|
872
872
|
|
|
873
|
+
export function nextPosition(max: unknown): number {
|
|
874
|
+
const raw = max ?? -1;
|
|
875
|
+
const value =
|
|
876
|
+
typeof raw === "number"
|
|
877
|
+
? raw
|
|
878
|
+
: typeof raw === "string" && raw.trim() !== ""
|
|
879
|
+
? Number(raw)
|
|
880
|
+
: Number.NaN;
|
|
881
|
+
const next = value + 1;
|
|
882
|
+
if (
|
|
883
|
+
!Number.isSafeInteger(value) ||
|
|
884
|
+
value < -1 ||
|
|
885
|
+
!Number.isSafeInteger(next) ||
|
|
886
|
+
next > 2_147_483_647
|
|
887
|
+
) {
|
|
888
|
+
throw new Error("Database position is outside the supported range.");
|
|
889
|
+
}
|
|
890
|
+
return next;
|
|
891
|
+
}
|
|
892
|
+
|
|
873
893
|
async function createInsideTransaction(
|
|
874
894
|
tx: Db,
|
|
875
895
|
context: MutationContext,
|
|
@@ -913,7 +933,7 @@ async function createInsideTransaction(
|
|
|
913
933
|
title: args.title?.trim() ?? "",
|
|
914
934
|
content: "",
|
|
915
935
|
icon: null,
|
|
916
|
-
position: (maxDoc?.max
|
|
936
|
+
position: nextPosition(maxDoc?.max),
|
|
917
937
|
isFavorite: 0,
|
|
918
938
|
hideFromSearch: context.databaseDocument.hideFromSearch ?? 0,
|
|
919
939
|
visibility: context.databaseDocument.visibility ?? "private",
|
|
@@ -926,7 +946,7 @@ async function createInsideTransaction(
|
|
|
926
946
|
orgId: context.database.orgId,
|
|
927
947
|
databaseId: context.database.id,
|
|
928
948
|
documentId,
|
|
929
|
-
position: (maxItem?.max
|
|
949
|
+
position: nextPosition(maxItem?.max),
|
|
930
950
|
createdAt: now,
|
|
931
951
|
updatedAt: now,
|
|
932
952
|
});
|
|
@@ -4431,6 +4431,12 @@ export async function runAgentLoop(opts) {
|
|
|
4431
4431
|
completedSideEffect: false,
|
|
4432
4432
|
});
|
|
4433
4433
|
recordToolResult(result, false);
|
|
4434
|
+
// Control is genuinely handed to the user here — the result above
|
|
4435
|
+
// tells the model "the turn is paused". `requestedActionStop` alone
|
|
4436
|
+
// does not stop anything until after the tool loop drains, so
|
|
4437
|
+
// without this the *rest* of this assistant message still executes
|
|
4438
|
+
// while the human is still looking at the approval card.
|
|
4439
|
+
turnYieldedToUser = true;
|
|
4434
4440
|
requestedActionStop ??= {
|
|
4435
4441
|
message: `Waiting for your approval to run ${toolCall.name}.`,
|
|
4436
4442
|
errorCode: "needs-approval",
|
|
@@ -5029,6 +5035,16 @@ export async function runAgentLoop(opts) {
|
|
|
5029
5035
|
// call and break the model's intended sequencing.
|
|
5030
5036
|
if (!entry)
|
|
5031
5037
|
return null;
|
|
5038
|
+
// A call that can pause the turn must not share a batch. `flushParallelBatch`
|
|
5039
|
+
// dispatches through `Promise.all`, so a gated call and its siblings all
|
|
5040
|
+
// start before the gate is reached, and `turnYieldedToUser` would then be
|
|
5041
|
+
// set too late to stop a sibling that already ran. Serializing here is what
|
|
5042
|
+
// makes that flag mean anything for the calls after it. `needsApproval` may
|
|
5043
|
+
// be an async predicate, so this cannot resolve whether approval is truly
|
|
5044
|
+
// required — declaring it at all is enough to serialize.
|
|
5045
|
+
if (entry.needsApproval !== undefined || entry.endsTurn === true) {
|
|
5046
|
+
return null;
|
|
5047
|
+
}
|
|
5032
5048
|
if (entry.readOnly === true)
|
|
5033
5049
|
return "read";
|
|
5034
5050
|
if (entry.parallelSafe === true)
|
|
@@ -5056,7 +5072,8 @@ export async function runAgentLoop(opts) {
|
|
|
5056
5072
|
// as not executed rather than running them: a second `ask-question` would
|
|
5057
5073
|
// overwrite the first one's card before anyone could answer it.
|
|
5058
5074
|
const skipToolCallAfterYield = (toolCall) => {
|
|
5059
|
-
const result = `Not executed: ${toolCall.name} was called after an action that
|
|
5075
|
+
const result = `Not executed: ${toolCall.name} was called after an action that paused the turn ` +
|
|
5076
|
+
`(an action that ends the turn, or one waiting on the user's approval). ` +
|
|
5060
5077
|
`The turn is paused for the user's answer — call it again on a later turn if still needed.`;
|
|
5061
5078
|
send({
|
|
5062
5079
|
type: "tool_start",
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
|
|
14
14
|
*/
|
|
15
15
|
export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
16
|
-
error: string;
|
|
17
16
|
ok?: undefined;
|
|
17
|
+
error: string;
|
|
18
18
|
} | {
|
|
19
19
|
error?: undefined;
|
|
20
20
|
ok: boolean;
|