diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 82ee23ee273..d488a479beb 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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"], diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index fdb49071805..a418ff61982 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -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 @@ -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, @@ -232,6 +231,7 @@ export function AppShell() { useLiveHomeFeedActions( identityQuery.data?.pubkey, refetchHomeFeedFromLiveSignal, + liveHomeChannelIds, ); const { refetch: refetchChannels } = channelsQuery; const channelsErrorMessage = diff --git a/desktop/src/app/useLiveHomeFeedActions.ts b/desktop/src/app/useLiveHomeFeedActions.ts index fa1905ac8ce..fa845844257 100644 --- a/desktop/src/app/useLiveHomeFeedActions.ts +++ b/desktop/src/app/useLiveHomeFeedActions.ts @@ -12,7 +12,17 @@ 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; @@ -20,8 +30,14 @@ 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(); }); @@ -39,6 +55,8 @@ export function useLiveHomeFeedActions( if (!normalizedPubkey) { return; } + const subscribedChannelIds = + channelIdsKey.length > 0 ? channelIdsKey.split(",") : []; let isCancelled = false; let disposers: Array<() => Promise> = []; @@ -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], @@ -146,5 +197,5 @@ export function useLiveHomeFeedActions( disposers = []; disposeAll(currentDisposers); }; - }, [pubkey]); + }, [channelIdsKey, pubkey]); } diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index d6444bff7e1..75385f5a0a8 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -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; @@ -666,9 +667,8 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] { const summaries = new Map< string, - { anchorAt: number; agentPubkeys: Set } + { anchorAt: number; channelId: string; agentPubkeys: Set } >(); - for (const [agentKey, agentTurns] of activeTurnsByAgent) { if (agentTurns.size === 0) continue; const offset = clockOffsetByAgent.get(agentKey) ?? 0; @@ -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; @@ -713,9 +713,8 @@ export function getActiveTurnsByConversation(): ActiveConversationTurnSummary[] const summaries = new Map< string, - { anchorAt: number; agentPubkeys: Set } + { anchorAt: number; channelId: string; agentPubkeys: Set } >(); - for (const [agentKey, agentTurns] of activeTurnsByAgent) { if (agentTurns.size === 0) continue; const offset = clockOffsetByAgent.get(agentKey) ?? 0; @@ -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; @@ -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, diff --git a/desktop/src/features/agents/needsYouStore.test.mjs b/desktop/src/features/agents/needsYouStore.test.mjs index 76f48a5ecbb..8b98a9c239d 100644 --- a/desktop/src/features/agents/needsYouStore.test.mjs +++ b/desktop/src/features/agents/needsYouStore.test.mjs @@ -4,6 +4,7 @@ import { beforeEach, describe, it } from "node:test"; import { getNeedsYouForChannels, getNeedsYouForConversation, + getNeedsYouForAll, getNeedsYouForChannel, ingestUserInputRequest, ingestApprovalRequest, @@ -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 })); diff --git a/desktop/src/features/agents/needsYouStore.ts b/desktop/src/features/agents/needsYouStore.ts index 40f09d77b27..3f2bae59e9d 100644 --- a/desktop/src/features/agents/needsYouStore.ts +++ b/desktop/src/features/agents/needsYouStore.ts @@ -32,6 +32,8 @@ const channelCache = new Map(); const conversationCache = new Map(); const channelsCache = new Map(); const EMPTY_REQUESTS: NeedsYouRequest[] = []; +let allCache: NeedsYouRequest[] | null = null; +let allCacheGeneration = -1; let expiryTimer: ReturnType | null = null; function notify() { @@ -39,6 +41,8 @@ function notify() { channelCache.clear(); conversationCache.clear(); channelsCache.clear(); + allCache = null; + allCacheGeneration = -1; for (const listener of listeners) listener(); } @@ -304,6 +308,8 @@ export function getNeedsYouForConversation( channelCache.clear(); conversationCache.clear(); channelsCache.clear(); + allCache = null; + allCacheGeneration = -1; scheduleExpiry(); } const cached = conversationCache.get(conversationId); @@ -324,6 +330,8 @@ export function getNeedsYouForChannel( channelCache.clear(); conversationCache.clear(); channelsCache.clear(); + allCache = null; + allCacheGeneration = -1; scheduleExpiry(); } const cached = channelCache.get(channelId); @@ -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); @@ -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(); } diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 0f6d401932a..c6cab4be674 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -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 { @@ -71,6 +72,7 @@ function resetCommunityState({ resetAgentWorkingSignal(); resetNeedsYouStore(); resetChannelAgentPresenceCache(); + resetMissionInboxCache(); if (isTauri() && isMacPlatform()) { void clearTrayAgentActivity(); } diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index e34fa0c200f..3ac6e075a9d 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -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, @@ -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. */ diff --git a/desktop/src/features/home/lib/missionInbox.test.mjs b/desktop/src/features/home/lib/missionInbox.test.mjs new file mode 100644 index 00000000000..09d745011d4 --- /dev/null +++ b/desktop/src/features/home/lib/missionInbox.test.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveMissionInboxSections, + getMissionInboxEventTarget, +} from "./missionInbox.ts"; + +function item(conversationId, channelId, createdAt, overrides = {}) { + return { + conversationId, + channelLabel: overrides.channelLabel ?? channelId, + id: overrides.id ?? `${conversationId}-event`, + item: { + channelId, + createdAt, + pubkey: overrides.agentPubkey ?? "agent-1", + tags: [], + }, + latestActivityAt: createdAt, + preview: overrides.preview ?? "live text", + subject: overrides.subject ?? `Thread ${conversationId}`, + senderLabel: overrides.senderLabel ?? "Agent One", + ...overrides, + }; +} + +const channels = [ + { id: "channel-a", name: "alpha" }, + { id: "channel-b", name: "beta" }, +]; + +test("blocked conversations win over working", () => { + const sections = deriveMissionInboxSections({ + channels, + inboxItems: [item("conversation-1", "channel-a", 100)], + needsYou: [ + { + conversationId: "conversation-1", + channelId: "channel-a", + agentPubkey: "agent-1", + createdAt: 200, + id: "approval-1", + }, + ], + activeTurns: [ + { + conversationId: "conversation-1", + channelId: "channel-a", + agentPubkeys: ["agent-1"], + anchorAt: 300, + }, + ], + outcomes: [], + acknowledgedConversationIds: new Set(), + }); + + assert.equal(sections.needsYou[0].conversationId, "conversation-1"); + assert.equal(sections.working.length, 0); +}); + +test("read-state acknowledgement removes ready-to-review rows", () => { + const input = { + channels, + inboxItems: [item("conversation-2", "channel-b", 100)], + needsYou: [], + activeTurns: [], + outcomes: [ + [ + "conversation-2", + { + outcome: "completed", + channelId: "channel-b", + agentPubkey: "agent-2", + endedAt: 200, + }, + ], + ], + }; + + assert.equal( + deriveMissionInboxSections({ + ...input, + acknowledgedConversationIds: new Set(), + }).readyToReview.length, + 1, + ); + assert.equal( + deriveMissionInboxSections({ + ...input, + acknowledgedConversationIds: new Set(["conversation-2"]), + }).readyToReview.length, + 0, + ); +}); + +test("needs-you rows order newest request first", () => { + const sections = deriveMissionInboxSections({ + channels, + inboxItems: [item("old", "channel-a", 100), item("new", "channel-b", 101)], + needsYou: [ + { + conversationId: "old", + channelId: "channel-a", + agentPubkey: "a", + createdAt: 10, + id: "old-request", + }, + { + conversationId: "new", + channelId: "channel-b", + agentPubkey: "b", + createdAt: 20, + id: "new-request", + }, + ], + activeTurns: [], + outcomes: [], + acknowledgedConversationIds: new Set(), + }); + + assert.deepEqual( + sections.needsYou.map((row) => row.conversationId), + ["new", "old"], + ); +}); + +test("needs-you combines approval and user-input families by conversation", () => { + const sections = deriveMissionInboxSections({ + channels, + inboxItems: [ + item("approval-conversation", "channel-a", 100), + item("input-conversation", "channel-b", 100), + ], + needsYou: [ + { + conversationId: "approval-conversation", + channelId: "channel-a", + agentPubkey: "agent-a", + createdAt: 200, + id: "approval-request", + }, + { + conversationId: "input-conversation", + channelId: "channel-b", + agentPubkey: "agent-b", + createdAt: 300, + id: "user-input-request", + }, + ], + activeTurns: [], + outcomes: [], + acknowledgedConversationIds: new Set(), + }); + + assert.deepEqual( + sections.needsYou.map((row) => [row.channelId, row.conversationId]), + [ + ["channel-b", "input-conversation"], + ["channel-a", "approval-conversation"], + ], + ); +}); + +test("same inputs return a reference-stable snapshot", () => { + const input = { + channels, + inboxItems: [item("conversation-3", "channel-a", 100)], + needsYou: [], + activeTurns: [], + outcomes: [], + acknowledgedConversationIds: new Set(), + }; + + assert.strictEqual( + deriveMissionInboxSections(input), + deriveMissionInboxSections(input), + ); +}); + +test("mission rows use real roots and never promote conversation UUIDs to event ids", () => { + const root = "a".repeat(64); + const [needsYouRow] = deriveMissionInboxSections({ + channels, + inboxItems: [], + needsYou: [ + { + conversationId: "conversation-1", + channelId: "channel-a", + rootEventId: root, + agentPubkey: "agent-1", + createdAt: 200, + id: "request-1", + }, + ], + activeTurns: [], + outcomes: [], + acknowledgedConversationIds: new Set(), + }).needsYou; + + assert.deepEqual(getMissionInboxEventTarget(needsYouRow), { + messageId: root, + threadRootId: root, + }); + assert.equal( + getMissionInboxEventTarget({ ...needsYouRow, rootEventId: null }), + null, + ); +}); diff --git a/desktop/src/features/home/lib/missionInbox.ts b/desktop/src/features/home/lib/missionInbox.ts new file mode 100644 index 00000000000..f1c7107badf --- /dev/null +++ b/desktop/src/features/home/lib/missionInbox.ts @@ -0,0 +1,277 @@ +import * as React from "react"; + +import type { NeedsYouRequest } from "@/features/agents/needsYouStore"; +import { + getNeedsYouForAll, + subscribeNeedsYou, +} from "@/features/agents/needsYouStore"; +import { + getActiveTurnsByConversation, + getActiveTurnsGeneration, + subscribeActiveAgentTurns, + type ActiveConversationTurnSummary, +} from "@/features/agents/activeAgentTurnsStore"; +import { + walkConversationOutcomes, + type ConversationOutcomeEntry, +} from "@/features/agents/conversationOutcomeLedger"; +import type { InboxItem } from "@/features/home/lib/inbox"; +import type { Channel } from "@/shared/api/types"; +import { getThreadReference } from "@/features/messages/lib/threading"; + +export type MissionInboxState = "needsYou" | "readyToReview" | "working"; + +export type MissionInboxRow = { + conversationId: string; + channelId: string; + threadTitle: string; + agentPubkey: string; + state: MissionInboxState; + phaseOrHeadline: string; + age: number; + inboxItem: InboxItem | null; + rootEventId: string | null; +}; + +export type MissionInboxSections = { + needsYou: MissionInboxRow[]; + readyToReview: MissionInboxRow[]; + working: MissionInboxRow[]; +}; + +type MissionInboxInput = { + channels: readonly Pick[]; + inboxItems: readonly InboxItem[]; + needsYou: readonly NeedsYouRequest[]; + activeTurns: readonly ActiveConversationTurnSummary[]; + outcomes: readonly (readonly [string, ConversationOutcomeEntry])[]; + acknowledgedConversationIds: ReadonlySet; + now?: number; +}; + +const EMPTY_SECTIONS: MissionInboxSections = { + needsYou: [], + readyToReview: [], + working: [], +}; +let lastKey = ""; +let lastSections = EMPTY_SECTIONS; +let outcomeCacheGeneration = -1; +let outcomeCache: [string, ConversationOutcomeEntry][] = []; + +function latestRequest(requests: readonly NeedsYouRequest[]) { + return requests.reduce( + (latest, request) => + latest === null || request.createdAt > latest.createdAt + ? request + : latest, + null, + ); +} + +function rowFor({ + conversationId, + channelId, + state, + agentPubkey, + age, + phaseOrHeadline, + inboxItem, + rootEventId, + channelName, +}: { + conversationId: string; + channelId: string; + state: MissionInboxState; + agentPubkey: string; + age: number; + phaseOrHeadline: string; + inboxItem: InboxItem | null; + rootEventId: string | null; + channelName: string; +}): MissionInboxRow { + return { + age, + agentPubkey, + channelId, + conversationId, + inboxItem, + rootEventId, + phaseOrHeadline, + state, + threadTitle: + inboxItem?.subject || + (channelName + ? `Conversation in #${channelName}` + : `Conversation ${conversationId.slice(0, 8)}`), + }; +} + +/** Derive the three mission-control sections from the shared agent stores. */ +export function deriveMissionInboxSections( + input: MissionInboxInput, +): MissionInboxSections { + const now = input.now ?? Date.now(); + const channelIds = new Set(input.channels.map((channel) => channel.id)); + const channelNames = new Map( + input.channels.map((channel) => [channel.id, channel.name]), + ); + const itemByConversation = new Map( + input.inboxItems.map((item) => [item.conversationId, item]), + ); + const requestsByConversation = new Map(); + for (const request of input.needsYou) { + if (!channelIds.has(request.channelId)) continue; + const requests = requestsByConversation.get(request.conversationId) ?? []; + requests.push(request); + requestsByConversation.set(request.conversationId, requests); + } + + const needsYou = [...requestsByConversation.entries()] + .map(([conversationId, requests]) => { + const request = latestRequest(requests); + const item = itemByConversation.get(conversationId) ?? null; + return rowFor({ + age: now - (request?.createdAt ?? 0), + agentPubkey: request?.agentPubkey ?? item?.item.pubkey ?? "", + channelId: request?.channelId ?? item?.item.channelId ?? "", + channelName: + channelNames.get(request?.channelId ?? item?.item.channelId ?? "") ?? + "", + conversationId, + inboxItem: item, + rootEventId: + request?.rootEventId ?? + getThreadReference(item?.item.tags ?? []).rootId ?? + null, + phaseOrHeadline: item?.preview || "Waiting for your approval", + state: "needsYou", + }); + }) + .sort((left, right) => left.age - right.age); + + const blocked = new Set(needsYou.map((row) => row.conversationId)); + const readyToReview: MissionInboxRow[] = []; + for (const [conversationId, entry] of input.outcomes) { + if ( + entry.outcome !== "completed" || + blocked.has(conversationId) || + input.acknowledgedConversationIds.has(conversationId) + ) { + continue; + } + const item = itemByConversation.get(conversationId) ?? null; + if (!channelIds.has(entry.channelId)) continue; + readyToReview.push( + rowFor({ + age: now - entry.endedAt, + agentPubkey: entry.agentPubkey || item?.item.pubkey || "", + channelId: entry.channelId, + channelName: channelNames.get(entry.channelId) ?? "", + conversationId, + inboxItem: item, + rootEventId: getThreadReference(item?.item.tags ?? []).rootId ?? null, + phaseOrHeadline: item?.preview || "Completed successfully", + state: "readyToReview", + }), + ); + } + readyToReview.sort((left, right) => left.age - right.age); + + const working = input.activeTurns + .filter( + (turn) => + !blocked.has(turn.conversationId) && channelIds.has(turn.channelId), + ) + .map((turn) => { + const item = itemByConversation.get(turn.conversationId) ?? null; + return rowFor({ + age: now - turn.anchorAt, + agentPubkey: turn.agentPubkeys[0] ?? item?.item.pubkey ?? "", + channelId: turn.channelId, + channelName: channelNames.get(turn.channelId) ?? "", + conversationId: turn.conversationId, + inboxItem: item, + rootEventId: getThreadReference(item?.item.tags ?? []).rootId ?? null, + phaseOrHeadline: item?.preview || "Agent is working", + state: "working", + }); + }) + .sort((left, right) => left.age - right.age); + + const key = JSON.stringify({ + acknowledged: [...input.acknowledgedConversationIds].sort(), + active: input.activeTurns, + items: input.inboxItems.map((item) => [ + item.conversationId, + item.id, + item.latestActivityAt, + ]), + needs: input.needsYou.map((request) => [ + request.id, + request.conversationId, + request.createdAt, + ]), + outcomes: input.outcomes, + now, + }); + if (key === lastKey) return lastSections; + lastKey = key; + lastSections = { needsYou, readyToReview, working }; + return lastSections; +} + +export function getMissionInboxEventTarget(row: MissionInboxRow) { + const messageId = row.inboxItem?.id ?? row.rootEventId; + if (!messageId || !/^[0-9a-f]{64}$/i.test(messageId)) return null; + const itemRootId = row.inboxItem + ? getThreadReference(row.inboxItem.item.tags).rootId + : null; + return { messageId, threadRootId: itemRootId ?? row.rootEventId }; +} + +export function getMissionInboxOutcomes(): [ + string, + ConversationOutcomeEntry, +][] { + const generation = getActiveTurnsGeneration(); + if (generation === outcomeCacheGeneration) return outcomeCache; + const outcomes: [string, ConversationOutcomeEntry][] = []; + walkConversationOutcomes((conversationId, entry) => + outcomes.push([conversationId, entry]), + ); + outcomeCacheGeneration = generation; + outcomeCache = outcomes; + return outcomeCache; +} + +export function useMissionInboxNeedsYou() { + return React.useSyncExternalStore( + subscribeNeedsYou, + getNeedsYouForAll, + getNeedsYouForAll, + ); +} + +export function useMissionInboxOutcomes() { + return React.useSyncExternalStore( + subscribeActiveAgentTurns, + getMissionInboxOutcomes, + getMissionInboxOutcomes, + ); +} + +export function useMissionInboxActiveTurns() { + return React.useSyncExternalStore( + subscribeActiveAgentTurns, + getActiveTurnsByConversation, + getActiveTurnsByConversation, + ); +} + +export function resetMissionInboxCache() { + lastKey = ""; + lastSections = EMPTY_SECTIONS; + outcomeCacheGeneration = -1; + outcomeCache = []; +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index e415f8d3a86..8638fd1f58e 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -26,6 +26,11 @@ import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState"; import { useHomeInboxAutoSelection } from "@/features/home/useHomeInboxAutoSelection"; import { useHomeInboxContextMessages } from "@/features/home/useHomeInboxContextMessages"; import { useHomePersonalInbox } from "@/features/home/useHomePersonalInbox"; +import { + getMissionInboxEventTarget, + type MissionInboxRow, +} from "@/features/home/lib/missionInbox"; +import { useMissionInboxSections } from "@/features/home/useMissionInboxSections"; import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; import { type ProfilePanelTab, @@ -396,6 +401,12 @@ export function HomeView({ undoDoneLocal: undoDone, undoUnreadLocal: undoUnread, }); + const missionSections = useMissionInboxSections({ + channels, + effectiveDoneSet, + feed, + inboxItems, + }); // Resolve selection before filtering so unread-only can retain its active row. const selectedItemFromAll = React.useMemo( () => @@ -702,6 +713,40 @@ export function HomeView({ handleUserSelectItem(null); setSelectedReminderId(reminderId); }} + missionSections={missionSections} + missionSelectedConversationId={selectedConversationId} + onOpenMissionChannel={(row: MissionInboxRow) => { + const target = getMissionInboxEventTarget(row); + if (target) { + onOpenContext( + row.channelId, + target.messageId, + target.threadRootId, + ); + return; + } + void goChannel(row.channelId); + }} + onSelectMission={(row: MissionInboxRow) => { + if (!row.inboxItem) { + const target = getMissionInboxEventTarget(row); + if (target) { + onOpenContext( + row.channelId, + target.messageId, + target.threadRootId, + ); + } else { + void goChannel(row.channelId); + } + return; + } + setUnreadBoundary(null); + setSelectedDraftKey(null); + setSelectedReminderId(null); + setAutoSelectedEventId(row.inboxItem.id); + markItemRead(row.inboxItem.id); + }} onUnreadOnlyChange={setUnreadOnly} reminderPubkey={currentPubkey} reminders={pendingReminders} diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index fa214dc730e..18dd2b2c157 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -8,7 +8,12 @@ import { type InboxTypeLabel, } from "@/features/home/lib/inbox"; import { buildInboxListRows } from "@/features/home/lib/inboxListRows"; +import type { + MissionInboxRow, + MissionInboxSections, +} from "@/features/home/lib/missionInbox"; import { InboxFilterMenu } from "@/features/home/ui/InboxFilterMenu"; +import { MissionInboxSectionsView } from "@/features/home/ui/MissionInboxSections"; import { DraftsPanel, type DraftViewItem, @@ -188,6 +193,10 @@ type InboxListPaneProps = { onSelect: (itemId: string) => void; onSelectDraft: (draftKey: string) => void; onSelectReminder: (reminderId: string) => void; + missionSections?: MissionInboxSections; + onSelectMission?: (row: MissionInboxRow) => void; + onOpenMissionChannel?: (row: MissionInboxRow) => void; + missionSelectedConversationId?: string | null; onUnreadOnlyChange: (checked: boolean) => void; selectedConversationId: string | null; selectedDraftKey: string | null; @@ -225,6 +234,10 @@ export function InboxListPane({ reminders, selectedReminderId, unreadOnly, + missionSections, + onSelectMission, + onOpenMissionChannel, + missionSelectedConversationId, }: InboxListPaneProps) { const isReminders = filter === "reminders"; const isDrafts = filter === "drafts"; @@ -608,6 +621,14 @@ export function InboxListPane({ data-testid="home-inbox-list" ref={scrollRef} > + {filter === "all" && missionSections ? ( + undefined)} + onSelect={onSelectMission ?? (() => undefined)} + sections={missionSections} + selectedConversationId={missionSelectedConversationId} + /> + ) : null} {visibleInboxRows.length > 0 ? ( ; + if (state === "readyToReview") + return ; + return ; +} + +function MissionRow({ + row, + onSelect, + onOpenChannel, + selected, +}: { + row: MissionInboxRow; + onSelect: (row: MissionInboxRow) => void; + onOpenChannel: (row: MissionInboxRow) => void; + selected: boolean; +}) { + return ( +
+ + +
+ ); +} + +export function MissionInboxSectionsView({ + sections, + onOpenChannel, + onSelect, + selectedConversationId, +}: { + sections: MissionInboxSections; + onOpenChannel: (row: MissionInboxRow) => void; + onSelect: (row: MissionInboxRow) => void; + selectedConversationId?: string | null; +}) { + const [workingOpen, setWorkingOpen] = React.useState(false); + const groups = [ + { key: "needsYou", label: "Cần bạn", rows: sections.needsYou }, + { key: "readyToReview", label: "Xong", rows: sections.readyToReview }, + ] as const; + return ( +
+ {groups.map((group) => ( +
+
+

+ {group.label} +

+ + {group.rows.length} + +
+ {group.rows.length > 0 ? ( + group.rows.map((row) => ( + + )) + ) : ( +

+ {group.key === "needsYou" + ? "Nothing needs you — safe to close" + : "Nothing ready to review"} +

+ )} +
+ ))} +
+ + {workingOpen + ? sections.working.map((row) => ( + + )) + : null} +
+
+ ); +} diff --git a/desktop/src/features/home/useMissionInboxSections.ts b/desktop/src/features/home/useMissionInboxSections.ts new file mode 100644 index 00000000000..57ac350fa7b --- /dev/null +++ b/desktop/src/features/home/useMissionInboxSections.ts @@ -0,0 +1,85 @@ +import * as React from "react"; + +import { + ingestApprovalRequestFeedItem, + ingestUserInputRequest, +} from "@/features/agents/needsYouStore"; +import { deriveAgentConversationIdOrNull } from "@/features/agents/conversationId"; +import { + deriveUserInputRootEventId, + parseUserInputRequest, +} from "@/features/channels/lib/userInput"; +import { + deriveMissionInboxSections, + useMissionInboxActiveTurns, + useMissionInboxNeedsYou, + useMissionInboxOutcomes, + type MissionInboxSections, +} from "@/features/home/lib/missionInbox"; +import { + relayEventFromFeedItem, + type InboxItem, +} from "@/features/home/lib/inbox"; +import type { Channel, HomeFeedResponse } from "@/shared/api/types"; + +type UseMissionInboxSectionsInput = { + channels?: readonly Pick[]; + effectiveDoneSet: ReadonlySet; + feed?: HomeFeedResponse; + inboxItems: readonly InboxItem[]; +}; + +export function useMissionInboxSections({ + channels, + effectiveDoneSet, + feed, + inboxItems, +}: UseMissionInboxSectionsInput): MissionInboxSections { + React.useEffect(() => { + for (const item of feed?.feed.needsAction ?? []) { + if (item.kind === 46010) { + ingestApprovalRequestFeedItem(item); + continue; + } + const event = relayEventFromFeedItem(item); + const request = parseUserInputRequest(event); + if (!request) continue; + const rootEventId = deriveUserInputRootEventId(event); + const channelId = request.channel_id || item.channelId; + const conversationId = deriveAgentConversationIdOrNull( + channelId, + rootEventId, + ); + if (!channelId || !conversationId) continue; + ingestUserInputRequest({ + id: item.id, + channelId, + rootEventId, + conversationId, + agentPubkey: item.pubkey, + createdAt: item.createdAt * 1_000, + }); + } + }, [feed?.feed.needsAction]); + + const needsYou = useMissionInboxNeedsYou(); + const activeTurns = useMissionInboxActiveTurns(); + const outcomes = useMissionInboxOutcomes(); + + return React.useMemo( + () => + deriveMissionInboxSections({ + acknowledgedConversationIds: new Set( + inboxItems + .filter((item) => effectiveDoneSet.has(item.id)) + .map((item) => item.conversationId), + ), + activeTurns, + channels: channels ?? [], + inboxItems, + needsYou, + outcomes, + }), + [activeTurns, channels, effectiveDoneSet, inboxItems, needsYou, outcomes], + ); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 0af1b37e754..ad1c9bbab7f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -31,6 +31,7 @@ import { } from "@/shared/api/customEmoji"; import { KIND_AGENT_USER_INPUT_REQUESTED, + KIND_AGENT_USER_INPUT_ANSWER, KIND_AGENT_USER_INPUT_RESOLVED, KIND_AGENT_OBSERVER_FRAME, KIND_CHANNEL_THREAD_SUMMARY, @@ -1134,6 +1135,11 @@ declare global { content: string; pubkey?: string; }) => RelayEvent; + __BUZZ_E2E_EMIT_MOCK_USER_INPUT_ANSWER__?: (input: { + channelName: string; + requestEventId: string; + content?: string; + }) => RelayEvent; __BUZZ_E2E_EMIT_MOCK_USER_INPUT_RESOLVED__?: (input: { channelName: string; requestEventId: string; @@ -10216,6 +10222,27 @@ export function maybeInstallE2eTauriMocks() { emitMockLiveEvent(channel.id, event); return event; }; + window.__BUZZ_E2E_EMIT_MOCK_USER_INPUT_ANSWER__ = ({ + channelName, + requestEventId, + content = "{}", + }) => { + const channel = mockChannels.find( + (candidate) => candidate.name === channelName, + ); + if (!channel) throw new Error(`Mock channel ${channelName} not found.`); + const event = createMockEvent( + KIND_AGENT_USER_INPUT_ANSWER, + content, + [ + ["h", channel.id], + ["e", requestEventId], + ], + DEFAULT_MOCK_IDENTITY.pubkey, + ); + emitMockLiveEvent(channel.id, event); + return event; + }; window.__BUZZ_E2E_EMIT_MOCK_USER_INPUT_RESOLVED__ = ({ channelName, requestEventId, diff --git a/desktop/tests/e2e/mission-inbox.spec.ts b/desktop/tests/e2e/mission-inbox.spec.ts new file mode 100644 index 00000000000..409808c9c17 --- /dev/null +++ b/desktop/tests/e2e/mission-inbox.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/mission-inbox"; +const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const ROOT_ID = "1".repeat(64); +const REQUEST_ID = ROOT_ID; +const CONVERSATION_ID = "2096b1ca-3834-7197-6a2a-bc5b580e07e6"; + +const MOCK_PUBKEY = "deadbeef".repeat(8); + +test.describe("mission inbox", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test("ingests a live 46040 request, falls back to its channel, and resolves it", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("home-inbox-list")).toBeVisible({ + timeout: 10_000, + }); + + await expect + .poll(() => + page.evaluate( + ({ channelName }) => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName, + kind: 46040, + }) ?? false, + { channelName: "general" }, + ), + ) + .toBe(true); + await page.evaluate( + ({ channelId, id, pubkey }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_USER_INPUT__; + if (!emit) throw new Error("Mock user-input helper is unavailable."); + emit({ + channelName: "general", + requestId: id, + pubkey, + content: JSON.stringify({ + channel_id: channelId, + engine: "codex", + message: "Approval is waiting for you", + questions: [], + request_id: id, + session_id: "mission-session", + turn_id: "mission-turn", + }), + }); + }, + { channelId: CHANNEL_ID, id: REQUEST_ID, pubkey: MOCK_PUBKEY }, + ); + + const sections = page.getByTestId("mission-inbox-sections"); + await expect(sections).toBeVisible(); + await expect( + page.getByTestId("mission-inbox-section-needsYou"), + ).toContainText("Cần bạn"); + await expect( + page.getByTestId("mission-inbox-section-readyToReview"), + ).toContainText("Xong"); + await expect( + page.getByTestId("mission-inbox-section-working"), + ).toContainText("Đang bay"); + await expect( + page.getByTestId(`mission-inbox-row-${CONVERSATION_ID}`), + ).toBeVisible(); + + await waitForAnimations(page); + await sections.screenshot({ path: `${SHOTS}/01-sections.png` }); + + const urlBefore = page.url(); + await page.getByTestId(`mission-inbox-row-${CONVERSATION_ID}`).click(); + await expect.poll(() => page.url()).not.toBe(urlBefore); + await expect(page).toHaveURL(new RegExp(`/channels/${CHANNEL_ID}`)); + + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/02-channel-fallback.png` }); + + await page.evaluate( + ({ requestEventId }) => + window.__BUZZ_E2E_EMIT_MOCK_USER_INPUT_ANSWER__?.({ + channelName: "general", + requestEventId, + }), + { requestEventId: REQUEST_ID }, + ); + await expect( + page.getByTestId(`mission-inbox-row-${CONVERSATION_ID}`), + ).toHaveCount(0); + }); +});