From 0756e82cd4fa5272323fc28deb287873077f2e97 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:57:50 +0000 Subject: [PATCH] fix(dashboard): Refresh the details panel when conversation detail changes Co-Authored-By: Thomas Jiang <20501235+thomasjiangcy@users.noreply.github.com> --- .../ConversationArchiveNotices.tsx | 2 +- .../conversations/ConversationChildAgents.tsx | 3 +- .../conversations/ConversationHomeList.tsx | 2 +- .../client/conversations/ConversationPage.tsx | 4 +- .../conversations/ConversationSidebar.tsx | 2 +- .../conversations/ConversationWatches.tsx | 3 +- .../conversations/ConversationWorkspace.tsx | 4 +- .../conversations/conversationArchive.ts | 255 +++++++++++++++ .../conversations/conversationMemory.ts | 3 +- .../src/client/conversations/queries.ts | 297 +++--------------- .../src/client/review-desk/queries.ts | 3 +- .../junior-dashboard/tests/client-api.test.ts | 12 + 12 files changed, 330 insertions(+), 260 deletions(-) create mode 100644 packages/junior-dashboard/src/client/conversations/conversationArchive.ts diff --git a/packages/junior-dashboard/src/client/conversations/ConversationArchiveNotices.tsx b/packages/junior-dashboard/src/client/conversations/ConversationArchiveNotices.tsx index b9995a3748..2e3eabd6a7 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationArchiveNotices.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationArchiveNotices.tsx @@ -5,7 +5,7 @@ import { Notice, NoticeAction } from "../components/Notice"; import { cn } from "../styles"; import type { Conversation } from "../types"; import { conversationDisplayTitle } from "../format"; -import { useArchiveConversation } from "./queries"; +import { useArchiveConversation } from "./conversationArchive"; /** Show archive errors and the most recent reversible archive action. */ export function ConversationArchiveNotices(props: { diff --git a/packages/junior-dashboard/src/client/conversations/ConversationChildAgents.tsx b/packages/junior-dashboard/src/client/conversations/ConversationChildAgents.tsx index c716ff84a7..5399ad72a8 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationChildAgents.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationChildAgents.tsx @@ -24,6 +24,7 @@ import { formatUsageTotal, } from "../format"; import { fetchDashboardJson } from "../http"; +import { conversationSideReadQueryKey } from "./queries"; import { cn } from "../styles"; import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer"; @@ -56,7 +57,7 @@ export function useConversationChildAgents(conversationId: string) { `/api/conversations/${encodeURIComponent(conversationId)}/agents`, signal, ), - queryKey: ["conversation", conversationId, "agents"], + queryKey: conversationSideReadQueryKey(conversationId, "agents"), retry: false, }); } diff --git a/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx b/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx index fa5d23821e..54a6badf31 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx @@ -20,7 +20,7 @@ import { ConversationSidebarAnnotations } from "./ConversationMeta"; import { ConversationArchiveNotices } from "./ConversationArchiveNotices"; import { ConversationListStatusIcon } from "./ConversationListStatusIcon"; import { conversationPath } from "./conversationRoutes"; -import { useArchiveConversation } from "./queries"; +import { useArchiveConversation } from "./conversationArchive"; import { DisplayText } from "../display-text/DisplayText"; import { attentionConversationIds, diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index 07b3d8bc5b..0175391063 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -14,9 +14,9 @@ import type { import { useArchiveConversation, - useConversationData, type PendingArchiveConversationUpdate, -} from "./queries"; +} from "./conversationArchive"; +import { useConversationData } from "./queries"; import { usePinConversation } from "./usePinConversation"; import { conversationIsResponding } from "./transcript"; import { buildConversationMarkdown } from "../markdownExport"; diff --git a/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx b/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx index c7d463bd76..c413052466 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx @@ -11,7 +11,7 @@ import { } from "lucide-react"; import { Link } from "react-router"; -import { useArchiveConversation } from "./queries"; +import { useArchiveConversation } from "./conversationArchive"; import { usePinConversation } from "./usePinConversation"; import { conversationPath } from "./conversationRoutes"; import { diff --git a/packages/junior-dashboard/src/client/conversations/ConversationWatches.tsx b/packages/junior-dashboard/src/client/conversations/ConversationWatches.tsx index 4f412d2657..56efe43b4a 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationWatches.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationWatches.tsx @@ -24,6 +24,7 @@ import { import { DisplayText } from "../display-text/DisplayText"; import { formatTime } from "../format"; import { fetchDashboardJson } from "../http"; +import { conversationSideReadQueryKey } from "./queries"; import { Collapsible, CollapsibleContent, @@ -41,7 +42,7 @@ export function useConversationWatches(conversationId: string) { `/api/conversations/${encodeURIComponent(conversationId)}/watches`, signal, ), - queryKey: ["conversation", conversationId, "watches"], + queryKey: conversationSideReadQueryKey(conversationId, "watches"), retry: false, }); } diff --git a/packages/junior-dashboard/src/client/conversations/ConversationWorkspace.tsx b/packages/junior-dashboard/src/client/conversations/ConversationWorkspace.tsx index d7b944eece..3c2f1b68fa 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationWorkspace.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationWorkspace.tsx @@ -11,10 +11,10 @@ import { pageCount, pageItems, PagePagination } from "../components/Pagination"; import { ConversationHomeList } from "./ConversationHomeList"; import { ConversationComposer } from "./ConversationComposer"; import { - useCreateConversation, usePendingArchiveConversationUpdates, type PendingArchiveConversationUpdate, -} from "./queries"; +} from "./conversationArchive"; +import { useCreateConversation } from "./queries"; import { conversationPath, NEW_CONVERSATION_PATH } from "./conversationRoutes"; import { attentionConversationIds, diff --git a/packages/junior-dashboard/src/client/conversations/conversationArchive.ts b/packages/junior-dashboard/src/client/conversations/conversationArchive.ts new file mode 100644 index 0000000000..92e0039c10 --- /dev/null +++ b/packages/junior-dashboard/src/client/conversations/conversationArchive.ts @@ -0,0 +1,255 @@ +import { + useMutation, + useMutationState, + useQueryClient, +} from "@tanstack/react-query"; +import type { + ConversationDetailReport, + ConversationFeed, + ConversationSummaryReport, +} from "@sentry/junior/api/schema"; +import { archiveConversationResponseSchema } from "@sentry/junior/api/schema"; + +import { patch } from "../http"; +import { + conversationDetailQueryKey, + type ConversationSnapshot, +} from "./queries"; + +function archivedConversationQueryKey(conversationId: string) { + return ["dashboard", "archived-conversation", conversationId] as const; +} + +/** Read active/archived status from a dashboard conversations query key. */ +function conversationFeedStatus( + queryKey: readonly unknown[], +): "active" | "archived" | undefined { + const status = queryKey[queryKey.length - 1]; + return status === "active" || status === "archived" ? status : undefined; +} + +type ArchivedConversationSnapshot = { + conversation: ConversationSummaryReport; + feedQueryHashes: string[]; +}; + +const archiveConversationMutationKey = [ + "dashboard", + "archive-conversation", +] as const; + +type ArchiveConversationVariables = { + archived: boolean; + lastSeenAt: string; +}; + +type ArchiveConversationMutationContext = { + archivedQueryKey: ReturnType; + archivedSnapshot?: ArchivedConversationSnapshot; + detailQueryKey: ReturnType; + previousArchivedSnapshot?: ArchivedConversationSnapshot; + previousDetail?: ConversationDetailReport; + previousFeeds: Array<[readonly unknown[], ConversationFeed | undefined]>; +}; + +export type PendingArchiveConversationUpdate = { + archived: boolean; + conversation?: ConversationSummaryReport; + conversationId: string; +}; + +/** Read pending archive state so refetches cannot visually replace optimistic UI. */ +export function usePendingArchiveConversationUpdates() { + return useMutationState({ + filters: { + mutationKey: archiveConversationMutationKey, + status: "pending", + }, + select: (mutation) => { + const variables = mutation.state + .variables as ArchiveConversationVariables; + const context = mutation.state.context as + | ArchiveConversationMutationContext + | undefined; + const conversationId = mutation.options.mutationKey?.[2]; + return { + archived: variables.archived, + conversation: context?.archivedSnapshot?.conversation, + conversationId: + typeof conversationId === "string" ? conversationId : "", + } satisfies PendingArchiveConversationUpdate; + }, + }); +} + +/** Archive or restore one conversation with an immediate reversible cache update. */ +export function useArchiveConversation( + conversationId: string, + options?: { + onError?(): void; + onSuccess?(archivedAt: string | null): void; + }, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [...archiveConversationMutationKey, conversationId], + mutationFn: (args: ArchiveConversationVariables) => + patch( + archiveConversationResponseSchema, + `/api/conversations/${encodeURIComponent(conversationId)}/archive`, + args, + ), + onMutate: async (args) => { + const conversationQueries = { queryKey: ["dashboard", "conversations"] }; + const detailQueryKey = conversationDetailQueryKey(conversationId); + const archivedQueryKey = archivedConversationQueryKey(conversationId); + await Promise.all([ + queryClient.cancelQueries(conversationQueries), + queryClient.cancelQueries({ queryKey: detailQueryKey }), + ]); + const previousFeeds = + queryClient.getQueriesData(conversationQueries); + const previousDetail = + queryClient.getQueryData(detailQueryKey); + const previousArchivedSnapshot = + queryClient.getQueryData( + archivedQueryKey, + ); + const archivedAt = args.archived ? new Date().toISOString() : null; + // Match the API: archive also unpins. + const viewerMarks = args.archived + ? { archivedAt, pinnedAt: null } + : { archivedAt }; + // Snapshot from any loaded feed, including archived-only fixtures that + // were never archived through this client session. + const archivedSnapshot = + buildArchivedConversationSnapshot(previousFeeds, conversationId) ?? + previousArchivedSnapshot; + + previousFeeds.forEach(([queryKey, feed]) => { + if (!feed) return; + const conversationExists = feed.conversations.some( + (conversation) => conversation.conversationId === conversationId, + ); + const feedStatus = conversationFeedStatus(queryKey); + const shouldRestore = + !args.archived && + !conversationExists && + Boolean(archivedSnapshot) && + feedStatus !== "archived"; + const conversations = shouldRestore + ? [ + ...feed.conversations, + // The archive cleared the pin, so the restored row is unpinned. + { + ...archivedSnapshot!.conversation, + archivedAt: null, + pinnedAt: null, + }, + ].sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt)) + : feed.conversations.map((conversation) => + conversation.conversationId === conversationId + ? { ...conversation, ...viewerMarks } + : conversation, + ); + queryClient.setQueryData(queryKey, { + ...feed, + conversations, + }); + }); + queryClient.setQueryData( + detailQueryKey, + (detail) => + detail + ? { ...detail, ...viewerMarks, detailEtag: undefined } + : detail, + ); + if (archivedSnapshot) { + queryClient.setQueryData(archivedQueryKey, archivedSnapshot); + } + return { + archivedQueryKey, + archivedSnapshot, + detailQueryKey, + previousArchivedSnapshot, + previousDetail, + previousFeeds, + } satisfies ArchiveConversationMutationContext; + }, + onError: (_error, _args, context) => { + context?.previousFeeds.forEach(([queryKey, feed]) => { + queryClient.setQueryData(queryKey, feed); + }); + if (context) { + queryClient.setQueryData( + context.detailQueryKey, + context.previousDetail, + ); + if (context.previousArchivedSnapshot) { + queryClient.setQueryData( + context.archivedQueryKey, + context.previousArchivedSnapshot, + ); + } else { + queryClient.removeQueries({ + exact: true, + queryKey: context.archivedQueryKey, + }); + } + } + options?.onError?.(); + }, + onSuccess: (result, args) => { + queryClient.setQueryData( + conversationDetailQueryKey(conversationId), + (detail) => + detail + ? { + ...detail, + archivedAt: result.archivedAt, + detailEtag: undefined, + } + : detail, + ); + if (!args.archived) { + queryClient.removeQueries({ + exact: true, + queryKey: archivedConversationQueryKey(conversationId), + }); + } + options?.onSuccess?.(result.archivedAt); + }, + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["dashboard", "conversations"], + }), + queryClient.invalidateQueries({ + queryKey: ["dashboard", "locations"], + }), + queryClient.invalidateQueries({ queryKey: ["dashboard", "people"] }), + queryClient.invalidateQueries({ + exact: true, + queryKey: conversationDetailQueryKey(conversationId), + }), + ]); + }, + }); +} + +function buildArchivedConversationSnapshot( + feeds: Array<[readonly unknown[], ConversationFeed | undefined]>, + conversationId: string, +): ArchivedConversationSnapshot | undefined { + let conversation: ConversationSummaryReport | undefined; + const feedQueryHashes: string[] = []; + for (const [queryKey, feed] of feeds) { + const feedConversation = feed?.conversations.find( + (item) => item.conversationId === conversationId, + ); + if (!feedConversation) continue; + conversation ??= feedConversation; + feedQueryHashes.push(JSON.stringify(queryKey)); + } + return conversation ? { conversation, feedQueryHashes } : undefined; +} diff --git a/packages/junior-dashboard/src/client/conversations/conversationMemory.ts b/packages/junior-dashboard/src/client/conversations/conversationMemory.ts index 46988f773a..6ed2b8eae2 100644 --- a/packages/junior-dashboard/src/client/conversations/conversationMemory.ts +++ b/packages/junior-dashboard/src/client/conversations/conversationMemory.ts @@ -3,6 +3,7 @@ import { createContext, useContext } from "react"; import { z } from "zod"; import { fetchDashboardJson } from "../http"; +import { conversationSideReadQueryKey } from "./queries"; const conversationMemoryListSchema = z .object({ @@ -45,7 +46,7 @@ export function useConversationMemories(conversationId: string) { `/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`, signal, ), - queryKey: ["conversation", conversationId, "memories"], + queryKey: conversationSideReadQueryKey(conversationId, "memories"), retry: false, }); } diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index 4d0a74348a..e44c9bbd36 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -1,9 +1,9 @@ import { useDeferredValue, useEffect, useMemo, useRef } from "react"; import { + type QueryClient, queryOptions, useInfiniteQuery, useMutation, - useMutationState, useQuery, useQueryClient, } from "@tanstack/react-query"; @@ -12,13 +12,11 @@ import type { ConversationEventPage, ConversationFeed, ConversationPendingMessagesReport, - ConversationSummaryReport, InputImage, } from "@sentry/junior/api/schema"; import { acceptedConversationMessageSchema, webMessageId, - archiveConversationResponseSchema, cancelConversationPendingMessagesResponseSchema, conversationDetailReportSchema, conversationEventPageSchema, @@ -28,11 +26,11 @@ import { stopConversationTurnResponseSchema, } from "@sentry/junior/api/schema"; +import { REVIEW_ITEMS_QUERY_KEY } from "../review-desk/queries"; import { DashboardApiError, del, fetchDashboardJson, - patch, post, readDashboardResponse, } from "../http"; @@ -66,78 +64,13 @@ export function conversationDetailQueryKey(conversationId: string | undefined) { return ["conversation", conversationId, "detail"] as const; } -function archivedConversationQueryKey(conversationId: string) { - return ["dashboard", "archived-conversation", conversationId] as const; -} - -/** Read active/archived status from a dashboard conversations query key. */ -function conversationFeedStatus( - queryKey: readonly unknown[], -): "active" | "archived" | undefined { - const status = queryKey[queryKey.length - 1]; - return status === "active" || status === "archived" ? status : undefined; -} - -type ArchivedConversationSnapshot = { - conversation: ConversationSummaryReport; - feedQueryHashes: string[]; -}; - -const archiveConversationMutationKey = [ - "dashboard", - "archive-conversation", -] as const; - -type ArchiveConversationVariables = { - archived: boolean; - lastSeenAt: string; -}; - -type ArchiveConversationMutationContext = { - archivedQueryKey: ReturnType; - archivedSnapshot?: ArchivedConversationSnapshot; - detailQueryKey: ReturnType; - previousArchivedSnapshot?: ArchivedConversationSnapshot; - previousDetail?: ConversationDetailReport; - previousFeeds: Array<[readonly unknown[], ConversationFeed | undefined]>; -}; - -export type PendingArchiveConversationUpdate = { - archived: boolean; - conversation?: ConversationSummaryReport; - conversationId: string; -}; - -/** Read pending archive state so refetches cannot visually replace optimistic UI. */ -export function usePendingArchiveConversationUpdates() { - return useMutationState({ - filters: { - mutationKey: archiveConversationMutationKey, - status: "pending", - }, - select: (mutation) => { - const variables = mutation.state - .variables as ArchiveConversationVariables; - const context = mutation.state.context as - | ArchiveConversationMutationContext - | undefined; - const conversationId = mutation.options.mutationKey?.[2]; - return { - archived: variables.archived, - conversation: context?.archivedSnapshot?.conversation, - conversationId: - typeof conversationId === "string" ? conversationId : "", - } satisfies PendingArchiveConversationUpdate; - }, - }); -} - // Right after a send, Junior usually reads the message within a few seconds. // Poll fast for that window only, then fall back to the normal live interval. const FAST_POLL_INTERVAL_MS = 500; const FAST_POLL_WINDOW_MS = 6_000; -type ConversationSnapshot = ConversationDetailReport & { +/** The cached detail report with its mailbox and revalidation ETag. */ +export type ConversationSnapshot = ConversationDetailReport & { mailbox?: ConversationPendingMessagesReport; detailEtag?: string; }; @@ -153,6 +86,32 @@ async function readConversationSnapshot( return { ...detail, mailbox }; } +const CONVERSATION_SIDE_READS = ["agents", "memories", "watches"] as const; + +/** + * Key a read that shows beside the detail report, such as in the details + * panel. A detail change refreshes it. + */ +export function conversationSideReadQueryKey( + conversationId: string, + read: (typeof CONVERSATION_SIDE_READS)[number], +) { + return ["conversation", conversationId, read] as const; +} + +/** Refresh the side reads and Review items. Only mounted reads refetch. */ +function refreshConversationSideReads( + client: QueryClient, + conversationId: string, +) { + for (const read of CONVERSATION_SIDE_READS) { + void client.invalidateQueries({ + queryKey: conversationSideReadQueryKey(conversationId, read), + }); + } + void client.invalidateQueries({ queryKey: REVIEW_ITEMS_QUERY_KEY }); +} + /** Refresh history and mailbox as one visible snapshot, including idle input. */ export function conversationDetailQueryOptions( conversationId: string | undefined, @@ -165,14 +124,26 @@ export function conversationDetailQueryOptions( return queryOptions({ enabled: Boolean(conversationId), queryKey: conversationDetailQueryKey(conversationId), - queryFn: ({ signal, client }) => - readConversationSnapshot( + queryFn: async ({ signal, client }) => { + const previous = client.getQueryData( + conversationDetailQueryKey(conversationId), + ); + const next = await readConversationSnapshot( conversationId!, signal, - client.getQueryData( - conversationDetailQueryKey(conversationId), - ), - ), + previous, + ); + // Side reads do not poll. A new ETag means the Conversation changed, + // so the details panel may be stale too. + if ( + previous?.detailEtag && + next.detailEtag && + previous.detailEtag !== next.detailEtag + ) { + refreshConversationSideReads(client, conversationId!); + } + return next; + }, structuralSharing: (previous, next) => { if (!next || typeof next !== "object") return next; if (!previous || typeof previous !== "object") return next; @@ -510,178 +481,6 @@ export function useStopConversationTurn(conversationId: string) { }); } -/** Archive or restore one conversation with an immediate reversible cache update. */ -export function useArchiveConversation( - conversationId: string, - options?: { - onError?(): void; - onSuccess?(archivedAt: string | null): void; - }, -) { - const queryClient = useQueryClient(); - return useMutation({ - mutationKey: [...archiveConversationMutationKey, conversationId], - mutationFn: (args: ArchiveConversationVariables) => - patch( - archiveConversationResponseSchema, - `/api/conversations/${encodeURIComponent(conversationId)}/archive`, - args, - ), - onMutate: async (args) => { - const conversationQueries = { queryKey: ["dashboard", "conversations"] }; - const detailQueryKey = conversationDetailQueryKey(conversationId); - const archivedQueryKey = archivedConversationQueryKey(conversationId); - await Promise.all([ - queryClient.cancelQueries(conversationQueries), - queryClient.cancelQueries({ queryKey: detailQueryKey }), - ]); - const previousFeeds = - queryClient.getQueriesData(conversationQueries); - const previousDetail = - queryClient.getQueryData(detailQueryKey); - const previousArchivedSnapshot = - queryClient.getQueryData( - archivedQueryKey, - ); - const archivedAt = args.archived ? new Date().toISOString() : null; - // Match the API: archive also unpins. - const viewerMarks = args.archived - ? { archivedAt, pinnedAt: null } - : { archivedAt }; - // Snapshot from any loaded feed, including archived-only fixtures that - // were never archived through this client session. - const archivedSnapshot = - buildArchivedConversationSnapshot(previousFeeds, conversationId) ?? - previousArchivedSnapshot; - - previousFeeds.forEach(([queryKey, feed]) => { - if (!feed) return; - const conversationExists = feed.conversations.some( - (conversation) => conversation.conversationId === conversationId, - ); - const feedStatus = conversationFeedStatus(queryKey); - const shouldRestore = - !args.archived && - !conversationExists && - Boolean(archivedSnapshot) && - feedStatus !== "archived"; - const conversations = shouldRestore - ? [ - ...feed.conversations, - // The archive cleared the pin, so the restored row is unpinned. - { - ...archivedSnapshot!.conversation, - archivedAt: null, - pinnedAt: null, - }, - ].sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt)) - : feed.conversations.map((conversation) => - conversation.conversationId === conversationId - ? { ...conversation, ...viewerMarks } - : conversation, - ); - queryClient.setQueryData(queryKey, { - ...feed, - conversations, - }); - }); - queryClient.setQueryData( - detailQueryKey, - (detail) => - detail - ? { ...detail, ...viewerMarks, detailEtag: undefined } - : detail, - ); - if (archivedSnapshot) { - queryClient.setQueryData(archivedQueryKey, archivedSnapshot); - } - return { - archivedQueryKey, - archivedSnapshot, - detailQueryKey, - previousArchivedSnapshot, - previousDetail, - previousFeeds, - } satisfies ArchiveConversationMutationContext; - }, - onError: (_error, _args, context) => { - context?.previousFeeds.forEach(([queryKey, feed]) => { - queryClient.setQueryData(queryKey, feed); - }); - if (context) { - queryClient.setQueryData( - context.detailQueryKey, - context.previousDetail, - ); - if (context.previousArchivedSnapshot) { - queryClient.setQueryData( - context.archivedQueryKey, - context.previousArchivedSnapshot, - ); - } else { - queryClient.removeQueries({ - exact: true, - queryKey: context.archivedQueryKey, - }); - } - } - options?.onError?.(); - }, - onSuccess: (result, args) => { - queryClient.setQueryData( - conversationDetailQueryKey(conversationId), - (detail) => - detail - ? { - ...detail, - archivedAt: result.archivedAt, - detailEtag: undefined, - } - : detail, - ); - if (!args.archived) { - queryClient.removeQueries({ - exact: true, - queryKey: archivedConversationQueryKey(conversationId), - }); - } - options?.onSuccess?.(result.archivedAt); - }, - onSettled: async () => { - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ["dashboard", "conversations"], - }), - queryClient.invalidateQueries({ - queryKey: ["dashboard", "locations"], - }), - queryClient.invalidateQueries({ queryKey: ["dashboard", "people"] }), - queryClient.invalidateQueries({ - exact: true, - queryKey: conversationDetailQueryKey(conversationId), - }), - ]); - }, - }); -} - -function buildArchivedConversationSnapshot( - feeds: Array<[readonly unknown[], ConversationFeed | undefined]>, - conversationId: string, -): ArchivedConversationSnapshot | undefined { - let conversation: ConversationSummaryReport | undefined; - const feedQueryHashes: string[] = []; - for (const [queryKey, feed] of feeds) { - const feedConversation = feed?.conversations.find( - (item) => item.conversationId === conversationId, - ); - if (!feedConversation) continue; - conversation ??= feedConversation; - feedQueryHashes.push(JSON.stringify(queryKey)); - } - return conversation ? { conversation, feedQueryHashes } : undefined; -} - /** Fetch a bounded conversation snapshot and older pages on demand. */ export function useConversationData(conversationId: string | undefined) { const queryClient = useQueryClient(); diff --git a/packages/junior-dashboard/src/client/review-desk/queries.ts b/packages/junior-dashboard/src/client/review-desk/queries.ts index 0d326fccb9..a59086f9ae 100644 --- a/packages/junior-dashboard/src/client/review-desk/queries.ts +++ b/packages/junior-dashboard/src/client/review-desk/queries.ts @@ -20,7 +20,8 @@ import { import { conversationDetailTitle } from "../format"; import { fetchDashboardJson, post } from "../http"; -const REVIEW_ITEMS_QUERY_KEY = ["dashboard", "review-items"] as const; +/** Prefix of every Review item read, for refreshes after a change. */ +export const REVIEW_ITEMS_QUERY_KEY = ["dashboard", "review-items"] as const; const COUNT_REFRESH_MS = 60_000; /** diff --git a/packages/junior-dashboard/tests/client-api.test.ts b/packages/junior-dashboard/tests/client-api.test.ts index e297b40138..7c6cc1f3f1 100644 --- a/packages/junior-dashboard/tests/client-api.test.ts +++ b/packages/junior-dashboard/tests/client-api.test.ts @@ -15,9 +15,11 @@ import { dashboardVersionDrift } from "../src/client/components/VersionDriftBann import { DASHBOARD_VERSION_HEADER } from "../src/dashboard-version"; import { conversationDetailQueryOptions, + conversationSideReadQueryKey, readConversationData, readConversationEvents, } from "../src/client/conversations/queries"; +import { REVIEW_ITEMS_QUERY_KEY } from "../src/client/review-desk/queries"; const generatedAt = "2026-07-23T00:00:00.000Z"; @@ -133,10 +135,19 @@ describe("dashboard client API", () => { const client = new QueryClient(); const options = conversationDetailQueryOptions(ARCHIVED_CONVERSATION_ID); const detailPath = `/api/conversations/${encodeURIComponent(ARCHIVED_CONVERSATION_ID)}`; + // The details panel reads these beside the detail report. + const sideReads = [ + conversationSideReadQueryKey(ARCHIVED_CONVERSATION_ID, "agents"), + [...REVIEW_ITEMS_QUERY_KEY, "list", "conversationId=x"], + ]; + const sideReadsInvalidated = () => + sideReads.map((key) => client.getQueryState(key)?.isInvalidated); try { const first = await client.fetchQuery(options); + for (const key of sideReads) client.setQueryData(key, {}); const second = await client.fetchQuery(options); expect(second.events).toBe(first.events); + expect(sideReadsInvalidated()).toEqual([false, false]); expect(reads).toEqual([ [`${detailPath}/pending-messages`, 200], [detailPath, 200], @@ -150,6 +161,7 @@ describe("dashboard client API", () => { expect(changed.archivedAt).toBeUndefined(); expect(changed.detailEtag).not.toBe(first.detailEtag); expect(reads.at(-1)).toEqual([detailPath, 200]); + expect(sideReadsInvalidated()).toEqual([true, true]); // The detail endpoint must also reject a valid old validator after logout. signedIn = false;