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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof archivedConversationQueryKey>;
archivedSnapshot?: ArchivedConversationSnapshot;
detailQueryKey: ReturnType<typeof conversationDetailQueryKey>;
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<ConversationFeed>(conversationQueries);
const previousDetail =
queryClient.getQueryData<ConversationDetailReport>(detailQueryKey);
const previousArchivedSnapshot =
queryClient.getQueryData<ArchivedConversationSnapshot>(
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<ConversationFeed>(queryKey, {
...feed,
conversations,
});
});
queryClient.setQueryData<ConversationSnapshot>(
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<ConversationSnapshot>(
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
});
}
Expand Down
Loading
Loading