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 @@ -155,6 +155,7 @@ export default defineConfig({
"**/huddle-transcription.spec.ts",
"**/agent-numeric-tuning.spec.ts",
"**/needs-restart-screenshots.spec.ts",
"**/mission-inbox.spec.ts",
],
use: {
...devices["Desktop Chrome"],
Expand Down
14 changes: 7 additions & 7 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,8 @@ export function AppShell() {
useAgentsDataRefresh();
// Chunk F: auto-restart drifted idle agents (per-agent opt-out, default ON).
useAutoRestartPolicy();
// Owner-global observer ingestion: receives + decrypts agent observer
// frames and keeps derived active-turn liveness in sync app-wide, so no
// individual screen/panel has to mount its own bridge for ingestion.
// Intentionally mounted without a `startupReady`/identity guard: before
// `currentPubkey` resolves the hook ingests managed agents only, and
// relay-owned agents join automatically once identity arrives. Adding a
// guard here would drop managed-agent coverage during startup.
// Owner-global observer ingestion; it must stay mounted app-wide so managed
// and relay-owned agents are covered during startup.
useAgentObserverIngestion();
// Kind 24200 is relay-ephemeral, so reconciliation runs eagerly (not
// deferred): seeds kind 24200 for fresh identities, no-ops for explicit
Expand Down Expand Up @@ -221,6 +216,10 @@ export function AppShell() {
const feedItemState = useFeedItemState(identityQuery.data?.pubkey);
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
const liveHomeChannelIds = React.useMemo(
() => (channelsQuery.data ?? []).map((c) => c.id),
[channelsQuery.data],
);
useReminderNotifications(
identityQuery.data?.pubkey,
notificationSettings.settings,
Expand All @@ -232,6 +231,7 @@ export function AppShell() {
useLiveHomeFeedActions(
identityQuery.data?.pubkey,
refetchHomeFeedFromLiveSignal,
liveHomeChannelIds,
);
const { refetch: refetchChannels } = channelsQuery;
const channelsErrorMessage =
Expand Down
53 changes: 52 additions & 1 deletion desktop/src/app/useLiveHomeFeedActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,32 @@ import {
import {
ingestApprovalRequestEvent,
resolveApprovalRequestEvent,
ingestUserInputRequest,
resolveUserInputRequest,
} from "@/features/agents/needsYouStore";
import {
deriveUserInputRootEventId,
getAnswerRequestId,
getResolvedRequestId,
parseUserInputRequest,
} from "@/features/channels/lib/userInput";
import { deriveAgentConversationIdOrNull } from "@/features/agents/conversationId";
import { buildChannelUserInputFilter } from "@/shared/api/relayChannelFilters";

const LIVE_HOME_FEED_RETRY_BASE_MS = 1_000;
const LIVE_HOME_FEED_RETRY_MAX_MS = 30_000;

export function useLiveHomeFeedActions(
pubkey: string | undefined,
onHomeFeedEvent: () => void,
channelIds: readonly string[] = [],
) {
const queryClient = useQueryClient();
// Joined-string key: an unstable array identity from a caller can never
// thrash the subscription lifecycle — only a real membership change
// re-subscribes. The effect re-derives the array from this key.
const channelIdsKey = channelIds.join(",");

const handleLiveHomeFeedEvent = React.useEffectEvent(() => {
onHomeFeedEvent();
});
Expand All @@ -39,6 +55,8 @@ export function useLiveHomeFeedActions(
if (!normalizedPubkey) {
return;
}
const subscribedChannelIds =
channelIdsKey.length > 0 ? channelIdsKey.split(",") : [];

let isCancelled = false;
let disposers: Array<() => Promise<void>> = [];
Expand Down Expand Up @@ -66,7 +84,40 @@ export function useLiveHomeFeedActions(
return;
}

const userInputSubscriptions = subscribedChannelIds.map((channelId) =>
relayClient.subscribeLive(
buildChannelUserInputFilter(channelId, 50, since),
(event) => {
const request = parseUserInputRequest(event);
if (request) {
const resolvedChannelId = request.channel_id || channelId;
const rootEventId = deriveUserInputRootEventId(event);
const conversationId = deriveAgentConversationIdOrNull(
resolvedChannelId,
rootEventId,
);
if (conversationId) {
ingestUserInputRequest({
id: event.id,
channelId: resolvedChannelId,
rootEventId,
conversationId,
agentPubkey: event.pubkey,
createdAt: event.created_at * 1_000,
});
}
} else {
const requestId =
getAnswerRequestId(event) ?? getResolvedRequestId(event);
if (requestId) resolveUserInputRequest(requestId);
}
handleLiveHomeFeedEvent();
},
),
);

void Promise.allSettled([
...userInputSubscriptions,
relayClient.subscribeLive(
{
kinds: [KIND_APPROVAL_REQUEST],
Expand Down Expand Up @@ -146,5 +197,5 @@ export function useLiveHomeFeedActions(
disposers = [];
disposeAll(currentDisposers);
};
}, [pubkey]);
}, [channelIdsKey, pubkey]);
}
12 changes: 6 additions & 6 deletions desktop/src/features/agents/activeAgentTurnsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export type ActiveChannelTurnSummary = {

/** One conversation/thread with active agent work, aggregated across agents. */
export type ActiveConversationTurnSummary = {
channelId: string;
conversationId: string;
anchorAt: number;
agentCount: number;
Expand Down Expand Up @@ -666,9 +667,8 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] {

const summaries = new Map<
string,
{ anchorAt: number; agentPubkeys: Set<string> }
{ anchorAt: number; channelId: string; agentPubkeys: Set<string> }
>();

for (const [agentKey, agentTurns] of activeTurnsByAgent) {
if (agentTurns.size === 0) continue;
const offset = clockOffsetByAgent.get(agentKey) ?? 0;
Expand All @@ -679,11 +679,11 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] {
if (!summary) {
summaries.set(turn.channelId, {
anchorAt,
channelId: turn.channelId,
agentPubkeys: new Set([agentKey]),
});
continue;
}

summary.agentPubkeys.add(agentKey);
if (anchorAt < summary.anchorAt) {
summary.anchorAt = anchorAt;
Expand Down Expand Up @@ -713,9 +713,8 @@ export function getActiveTurnsByConversation(): ActiveConversationTurnSummary[]

const summaries = new Map<
string,
{ anchorAt: number; agentPubkeys: Set<string> }
{ anchorAt: number; channelId: string; agentPubkeys: Set<string> }
>();

for (const [agentKey, agentTurns] of activeTurnsByAgent) {
if (agentTurns.size === 0) continue;
const offset = clockOffsetByAgent.get(agentKey) ?? 0;
Expand All @@ -728,11 +727,11 @@ export function getActiveTurnsByConversation(): ActiveConversationTurnSummary[]
if (!summary) {
summaries.set(conversationId, {
anchorAt,
channelId: turn.channelId,
agentPubkeys: new Set([agentKey]),
});
continue;
}

summary.agentPubkeys.add(agentKey);
if (anchorAt < summary.anchorAt) {
summary.anchorAt = anchorAt;
Expand All @@ -742,6 +741,7 @@ export function getActiveTurnsByConversation(): ActiveConversationTurnSummary[]

const result = [...summaries.entries()]
.map(([conversationId, summary]) => ({
channelId: summary.channelId,
conversationId,
anchorAt: summary.anchorAt,
agentCount: summary.agentPubkeys.size,
Expand Down
21 changes: 21 additions & 0 deletions desktop/src/features/agents/needsYouStore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { beforeEach, describe, it } from "node:test";
import {
getNeedsYouForChannels,
getNeedsYouForConversation,
getNeedsYouForAll,
getNeedsYouForChannel,
ingestUserInputRequest,
ingestApprovalRequest,
Expand Down Expand Up @@ -210,6 +211,26 @@ describe("needsYouStore", () => {
);
});

it("returns one stable all-channel snapshot for both request families", () => {
ingestApprovalRequest(request({ id: "approval-all" }));
ingestUserInputRequest({
id: "user-input-all",
channelId: CHANNEL,
rootEventId: ROOT,
conversationId: "conversation-user-input",
agentPubkey: AGENT,
createdAt: Date.now() + 1,
});
const first = getNeedsYouForAll();
assert.deepEqual(
first.map((entry) => entry.id),
["approval-all", "user-input-all"],
);
assert.strictEqual(first, getNeedsYouForAll());
resolveUserInputRequest("user-input-all");
assert.notStrictEqual(first, getNeedsYouForAll());
});

it("expires stale requests without notifying during a snapshot read", () => {
const now = Date.now();
ingestApprovalRequest(request({ createdAt: now - 1_000 }));
Expand Down
27 changes: 27 additions & 0 deletions desktop/src/features/agents/needsYouStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,17 @@ const channelCache = new Map<string, NeedsYouRequest[]>();
const conversationCache = new Map<string, NeedsYouRequest[]>();
const channelsCache = new Map<string, NeedsYouRequest[]>();
const EMPTY_REQUESTS: NeedsYouRequest[] = [];
let allCache: NeedsYouRequest[] | null = null;
let allCacheGeneration = -1;
let expiryTimer: ReturnType<typeof globalThis.setTimeout> | null = null;

function notify() {
generation += 1;
channelCache.clear();
conversationCache.clear();
channelsCache.clear();
allCache = null;
allCacheGeneration = -1;
for (const listener of listeners) listener();
}

Expand Down Expand Up @@ -304,6 +308,8 @@ export function getNeedsYouForConversation(
channelCache.clear();
conversationCache.clear();
channelsCache.clear();
allCache = null;
allCacheGeneration = -1;
scheduleExpiry();
}
const cached = conversationCache.get(conversationId);
Expand All @@ -324,6 +330,8 @@ export function getNeedsYouForChannel(
channelCache.clear();
conversationCache.clear();
channelsCache.clear();
allCache = null;
allCacheGeneration = -1;
scheduleExpiry();
}
const cached = channelCache.get(channelId);
Expand All @@ -335,6 +343,23 @@ export function getNeedsYouForChannel(
return result;
}

/** Return every pending request as one reference-stable snapshot. */
export function getNeedsYouForAll(now = Date.now()): NeedsYouRequest[] {
if (prune(now)) {
channelCache.clear();
conversationCache.clear();
allCache = null;
allCacheGeneration = -1;
scheduleExpiry();
}
if (allCache && allCacheGeneration === generation) return allCache;
allCache = [...requests.values(), ...userInputRequests.values()].sort(
(a, b) => a.createdAt - b.createdAt,
);
allCacheGeneration = generation;
return allCache;
}

export function subscribeNeedsYou(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
Expand All @@ -356,6 +381,8 @@ export function resetNeedsYouStore() {
channelCache.clear();
conversationCache.clear();
channelsCache.clear();
allCache = null;
allCacheGeneration = -1;
generation += 1;
for (const listener of listeners) listener();
}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal";
import { resetNeedsYouStore } from "@/features/agents/needsYouStore";
import { resetChannelAgentPresenceCache } from "@/features/agents/channelAgentPresence";
import { resetMissionInboxCache } from "@/features/home/lib/missionInbox";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetThreadAgentActivityHeadlineCaches } from "@/features/messages/ui/conversationActivityHeadline";
import {
Expand Down Expand Up @@ -71,6 +72,7 @@ function resetCommunityState({
resetAgentWorkingSignal();
resetNeedsYouStore();
resetChannelAgentPresenceCache();
resetMissionInboxCache();
if (isTauri() && isMacPlatform()) {
void clearTrayAgentActivity();
}
Expand Down
9 changes: 8 additions & 1 deletion desktop/src/features/home/lib/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getProjectInboxReference,
isProjectInboxItem,
} from "@/features/home/lib/projectInbox";
import { deriveAgentConversationIdOrNull } from "@/features/agents/conversationId";
import type { TimelineReaction } from "@/features/messages/types";
import type {
Channel,
Expand Down Expand Up @@ -416,7 +417,13 @@ export function getInboxConversationId(
}

const thread = getThreadReference(tags);
return thread.rootId ?? thread.parentId ?? eventId;
const rootEventId = thread.rootId ?? thread.parentId ?? eventId;
if (kind === 46010 || kind === 46040) {
return (
deriveAgentConversationIdOrNull(channelId, rootEventId) ?? rootEventId
);
}
return rootEventId;
}

/** Returns the stable conversation identity for a complete Inbox feed item. */
Expand Down
Loading
Loading