Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default defineConfig({
"**/sidebar-relay-card.spec.ts",
"**/tokens.spec.ts",
"**/persona-env-vars.spec.ts",
"**/persona-sync.spec.ts",
"**/mesh-compute.spec.ts",
],
use: {
Expand Down
5 changes: 3 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ const overrides = new Map([
// (reconcile_inbound_tombstone), the two apply_inbound_* fns, the
// event_d_tag/parse_deletion_coordinate helpers, and the preserve/overwrite +
// secret-injection + tombstone test coverage. Load-bearing feature growth,
// queued to split with the list.
["src-tauri/src/commands/personas.rs", 1271],
// queued to split with the list. The two `agents-data-changed` emits (live
// UI refresh on inbound reconcile + tombstone) add the latest growth.
["src-tauri/src/commands/personas.rs", 1279],
["src-tauri/src/managed_agents/persona_card.rs", 1050],
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir, plus the
Expand Down
10 changes: 9 additions & 1 deletion desktop/src-tauri/src/commands/personas.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use tauri::{AppHandle, State};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;

use super::export_util::save_json_with_dialog;
Expand Down Expand Up @@ -442,6 +442,10 @@ pub fn reconcile_inbound_persona_event(
}
try_regenerate_nest(&app);

// Signal the live UI to refetch agents data — inbound relay events otherwise
// land on disk silently, leaving the Agents tab stale until restart.
let _ = app.emit("agents-data-changed", ());

Ok(())
}

Expand Down Expand Up @@ -539,6 +543,10 @@ fn reconcile_inbound_tombstone(
}
try_regenerate_nest(app);

// Refresh the live UI on inbound deletion — a removal is as user-visible as
// an upsert and the Agents tab must drop the tombstoned record without restart.
let _ = app.emit("agents-data-changed", ());

Ok(())
}

Expand Down
2 changes: 2 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { setDesktopAppBadge } from "@/features/notifications/lib/desktop";
import { PreventSleepProvider } from "@/features/agents/usePreventSleep";
import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh";
import { usePersonaSync } from "@/features/agents/lib/usePersonaSync";
import {
usePresenceSession,
Expand Down Expand Up @@ -139,6 +140,7 @@ export function AppShell() {
identityQuery.data?.pubkey,
);
usePersonaSync(identityQuery.data?.pubkey);
useAgentsDataRefresh();
const profileQuery = useProfileQuery();
const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
useRelayAutoHeal();
Expand Down
45 changes: 45 additions & 0 deletions desktop/src/features/agents/lib/useAgentsDataRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { listen } from "@tauri-apps/api/event";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";

import {
managedAgentsQueryKey,
personasQueryKey,
relayAgentsQueryKey,
teamsQueryKey,
} from "@/features/agents/hooks";

// Trailing-coalesce window: a backfill burst (up to 500 inbound events fed
// one-by-one through reconcile) fires one `agents-data-changed` per event.
// Collapsing them into a single invalidate after the burst settles keeps the
// refetch off React Query's implicit in-flight dedup and avoids redundant
// disk-read IPC.
const COALESCE_MS = 200;

// Invalidate the live Agents-tab queries when the backend signals that inbound
// relay events changed the on-disk agents data. Mounted once at the app root
// with empty deps — invalidation is global and has no reason to be
// pubkey-scoped, so it must NOT live inside the pubkey-keyed `usePersonaSync`
// (re-registering per identity switch would leak a listener each time).
export function useAgentsDataRefresh(): void {
const queryClient = useQueryClient();

useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined;

const unlisten = listen("agents-data-changed", () => {
if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => {
void queryClient.invalidateQueries({ queryKey: personasQueryKey });
void queryClient.invalidateQueries({ queryKey: teamsQueryKey });
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
void queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey });
}, COALESCE_MS);
});

return () => {
if (timer !== undefined) clearTimeout(timer);
void unlisten.then((fn) => fn());
};
}, [queryClient]);
}
78 changes: 78 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1616,6 +1616,8 @@ function resetMockMesh() {
}
let mockPersonas: RawPersona[] = [];
let mockTeams: RawTeam[] = [];
// Listeners registered via the mock __TAURI_INTERNALS__.listen — keyed by event name.
const tauriEventListeners = new Map<string, Set<() => void>>();
const defaultMockRelayAgents: RawRelayAgent[] = [
{
pubkey: ALICE_PUBKEY,
Expand Down Expand Up @@ -6644,6 +6646,61 @@ export function maybeInstallE2eTauriMocks() {
return handleDeletePersona(
payload as Parameters<typeof handleDeletePersona>[0],
);
case "reconcile_inbound_persona_event": {
const nostrEvent = JSON.parse(
(payload as { eventJson: string }).eventJson,
) as {
kind: number;
tags: string[][];
content: string;
created_at: number;
};
if (nostrEvent.kind === 30175) {
// Persona upsert — parse content and upsert into mockPersonas by d-tag
const dTag = nostrEvent.tags.find((t) => t[0] === "d")?.[1];
if (dTag) {
const content = JSON.parse(nostrEvent.content) as {
display_name?: string;
system_prompt?: string;
};
const now = new Date().toISOString();
const existing = mockPersonas.find((p) => p.id === dTag);
if (existing) {
existing.display_name =
content.display_name ?? existing.display_name;
existing.system_prompt =
content.system_prompt ?? existing.system_prompt;
existing.updated_at = now;
} else {
mockPersonas.push({
id: dTag,
display_name: content.display_name ?? dTag,
avatar_url: null,
system_prompt: content.system_prompt ?? "",
is_builtin: false,
is_active: true,
env_vars: {},
created_at: now,
updated_at: now,
});
}
}
} else if (nostrEvent.kind === 5) {
// Tombstone — extract d-tag from a-tag "30175:<pubkey>:<d_tag>" and remove
const aTagValue = nostrEvent.tags.find((t) => t[0] === "a")?.[1];
if (aTagValue) {
const dTag = aTagValue.split(":")[2];
if (dTag) {
mockPersonas = mockPersonas.filter((p) => p.id !== dTag);
}
}
}
// Mirror the real Rust backend: emit "agents-data-changed" after reconcile.
for (const cb of tauriEventListeners.get("agents-data-changed") ?? []) {
cb();
}
return undefined;
}
case "set_persona_active":
return handleSetPersonaActive(
payload as Parameters<typeof handleSetPersonaActive>[0],
Expand Down Expand Up @@ -7028,5 +7085,26 @@ export function maybeInstallE2eTauriMocks() {
handleMockCommand(command, payload ?? null);
mockIPC(handleMockCommand);

// Wire up __TAURI_INTERNALS__.listen so tests can subscribe to backend-emitted
// events (e.g. "agents-data-changed"). mockIPC already ensures __TAURI_INTERNALS__
// exists; we just add the listen property without clobbering invoke.
(
window as unknown as {
__TAURI_INTERNALS__: {
listen?: (event: string, cb: () => void) => Promise<() => void>;
};
}
).__TAURI_INTERNALS__.listen = async (event: string, cb: () => void) => {
let listeners = tauriEventListeners.get(event);
if (!listeners) {
listeners = new Set();
tauriEventListeners.set(event, listeners);
}
listeners.add(cb);
return () => {
tauriEventListeners.get(event)?.delete(cb);
};
};

installed = true;
}
Loading
Loading