From 51474dff749bb9320a4ff1ae3ce39085b8a5678e Mon Sep 17 00:00:00 2001 From: oscarlehuu Date: Fri, 7 Aug 2026 16:03:19 +0930 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Mission=20Inbox=20=E2=80=94=20?= =?UTF-8?q?C=E1=BA=A7n=20b=E1=BA=A1n=20/=20=C4=90ang=20bay=20/=20Xong=20wi?= =?UTF-8?q?th=20in-place=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home's inbox now leads with three mission sections derived from the stores that already know the answer: Cần bạn (pending approvals and user-input requests from needsYouStore — both 46010 and 46040 families), Đang bay (active agent turns, now carrying channel ids), and Xong (recent conversation outcomes). Sections are pure derivation (missionInbox.ts) over a new reference-stable getNeedsYouForAll snapshot (generation-invalidated, cleared on every prune path), mapped to existing InboxItems by conversation id — no rival store, reset on community switch. Clicking a row opens the conversation in place through Home's existing detail surface; rows without a feed item fall back to the channel thread context using the request's real 64-hex root event id — never a conversation UUID posing as an event id, and never a silent no-op. The '→ channel' affordance uses the same id discipline. Home now ingests 46040 user-input requests live (per-channel subscriptions using the same filter as useChannelUserInput, wired through useLiveHomeFeedActions) and resolves them on 46041 answers and 46042 resolutions, so Cần bạn clears without visiting the channel. The subscription effect keys on a joined channel-id string, immune to array-identity churn from callers. Store note: this branch originally carried its own 46040 extension routing user input through the approval lifecycle; it was dropped in favor of #80's canonical separate-family store when that merged. Three grok-4.5 review rounds: r1 flagged the store conflation and identity gaps; r2 (post-unification) found production-impossible feed shapes, silent clicks, and UUID-as-event-id navigation; r3 verified the live ingest/resolve path and click table, with the final subscription-stability fix applied by the orchestrator. Fixes #81 Signed-off-by: oscarlehuu --- desktop/playwright.config.ts | 1 + desktop/src/app/AppShell.tsx | 14 +- desktop/src/app/useLiveHomeFeedActions.ts | 53 +++- .../features/agents/activeAgentTurnsStore.ts | 12 +- .../features/agents/needsYouStore.test.mjs | 21 ++ desktop/src/features/agents/needsYouStore.ts | 27 ++ .../features/communities/useCommunityInit.ts | 2 + desktop/src/features/home/lib/inbox.ts | 9 +- .../features/home/lib/missionInbox.test.mjs | 209 +++++++++++++ desktop/src/features/home/lib/missionInbox.ts | 277 ++++++++++++++++++ desktop/src/features/home/ui/HomeView.tsx | 45 +++ .../src/features/home/ui/InboxListPane.tsx | 21 ++ .../features/home/ui/MissionInboxSections.tsx | 169 +++++++++++ .../features/home/useMissionInboxSections.ts | 85 ++++++ desktop/src/testing/e2eBridge.ts | 27 ++ desktop/tests/e2e/mission-inbox.spec.ts | 98 +++++++ 16 files changed, 1055 insertions(+), 15 deletions(-) create mode 100644 desktop/src/features/home/lib/missionInbox.test.mjs create mode 100644 desktop/src/features/home/lib/missionInbox.ts create mode 100644 desktop/src/features/home/ui/MissionInboxSections.tsx create mode 100644 desktop/src/features/home/useMissionInboxSections.ts create mode 100644 desktop/tests/e2e/mission-inbox.spec.ts 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); + }); +});