Skip to content
Draft
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 @@ -7,6 +7,7 @@ import type {
import {
useAppendConversationMessage,
useArchiveConversation,
useCancelConversationPendingMessages,
useConversationData,
type PendingArchiveConversationUpdate,
} from "./queries";
Expand Down Expand Up @@ -53,6 +54,8 @@ export function ConversationPage(props: {
const detail = useConversationData(conversationId);
const archive = useArchiveConversation(conversationId);
const appendMessage = useAppendConversationMessage(conversationId);
const cancelPendingMessages =
useCancelConversationPendingMessages(conversationId);
const feedConversation = conversations.find(
(item) => item.id === conversationId,
);
Expand Down Expand Up @@ -174,8 +177,13 @@ export function ConversationPage(props: {
) : null}
{detail.data ? (
<PendingMailboxStack
cancelError={Boolean(cancelPendingMessages.error)}
cancelPending={cancelPendingMessages.isPending}
conversation={detail.data}
messages={detail.pendingMessages}
onCancelQueue={() => {
cancelPendingMessages.mutate({});
}}
/>
) : null}
<ConversationComposer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { ConversationPendingMessage } from "@sentry/junior/api/schema";

import { cn } from "../styles";
import { Button } from "../components/Button";
import { ShimmerText } from "../components/ShimmerText";
import { Tooltip } from "../components/Tooltip";
import { formatMessageTimestamp, transcriptMessageActorLabel } from "../format";
Expand Down Expand Up @@ -140,15 +141,18 @@

/** Render accepted mailbox rows as a compact stack attached above the composer. */
export function PendingMailboxStack(props: {
cancelError?: boolean;
cancelPending?: boolean;
conversation: ConversationTranscript;
messages: readonly ConversationPendingMessage[];
onCancelQueue?: () => void;
}): ReactNode {
const rows = unresolvedPendingTranscriptMessages(
conversationTranscriptMessages(props.conversation),
props.messages,
);
if (rows.length === 0) return null;

Check warning on line 155 in packages/junior-dashboard/src/client/conversations/PendingMailboxStack.tsx

View check run for this annotation

@sentry/warden / warden: code-review

Failed cancellation error disappears when queued messages commit to history

If cancelling fails and the worker processes the pending messages before the next render, the component returns null and suppresses the error banner, leaving the user without feedback that they need to retry.
const countLabel =
rows.length === 1 ? "1 queued message" : `${rows.length} queued messages`;
const visibleRows =
Expand All @@ -156,16 +160,34 @@
? rows.slice(0, COLLAPSED_PENDING_ROW_COUNT)
: rows;
const collapsedCount = rows.length - visibleRows.length;
const showCancel = Boolean(props.onCancelQueue);

return (
<div
aria-label="Pending messages"
className="mx-2 overflow-hidden rounded-t-lg border border-b-0 border-white/[0.09] bg-cyan-300/[0.07] md:mx-3"
>
<div className="px-3 py-2 font-sans text-xs font-medium text-cyan-50/85 md:hidden">
{countLabel}
<div className="flex items-center justify-between gap-2 px-3 py-2 md:px-3.5">
<div className="min-w-0 font-sans text-xs font-medium text-cyan-50/85">
{countLabel}
</div>
{showCancel ? (
<Button
aria-label="Cancel queued messages"
className="h-7 shrink-0 border-white/10 bg-transparent px-2 text-xs font-medium text-cyan-50/85 hover:border-white/25 hover:bg-white/[0.06] hover:text-cyan-50"
disabled={props.cancelPending}
onClick={props.onCancelQueue}
>
{props.cancelPending ? "Cancelling…" : "Cancel queue"}
</Button>
) : null}
</div>
<div className="hidden md:block">
{props.cancelError ? (
<div className="border-t border-amber-300/15 px-3 py-1.5 font-sans text-xs text-amber-100/75 md:px-3.5">
Could not cancel queued messages. Try again.
</div>
) : null}
<div className="hidden border-t border-white/[0.06] md:block">
{visibleRows.map((message, index) => (
<PendingRow
conversation={props.conversation}
Expand Down
59 changes: 58 additions & 1 deletion packages/junior-dashboard/src/client/conversations/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ import type {
import {
acceptedConversationMessageSchema,
archiveConversationResponseSchema,
cancelConversationPendingMessagesResponseSchema,
conversationDetailReportSchema,
conversationEventPageSchema,
conversationPendingMessagesReportSchema,
} from "@sentry/junior/api/schema";

import { DashboardApiError, fetchDashboardJson, patch, post } from "../http";
import { DashboardApiError, del, fetchDashboardJson, patch, post } from "../http";
import {
buildConversationTranscript,
conversationHistoryBridgeCursor,
Expand Down Expand Up @@ -190,6 +191,62 @@ export function useAppendConversationMessage(conversationId: string) {
});
}

/** Cancel accepted human-facing mailbox rows for the open conversation. */
export function useCancelConversationPendingMessages(conversationId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (args?: { inboundMessageIds?: string[] }) =>
del(
cancelConversationPendingMessagesResponseSchema,
`/api/conversations/${encodeURIComponent(conversationId)}/pending-messages`,
args ?? {},
),
onMutate: async () => {
await queryClient.cancelQueries({
exact: true,
queryKey: conversationPendingMessagesQueryKey(conversationId),
});
const previousPending =
queryClient.getQueryData<ConversationPendingMessagesReport>(
conversationPendingMessagesQueryKey(conversationId),
);
if (previousPending) {
queryClient.setQueryData<ConversationPendingMessagesReport>(
conversationPendingMessagesQueryKey(conversationId),
{
...previousPending,
messages: [],
},
);
}
return { previousPending };
},
onError: (_error, _args, context) => {
if (context?.previousPending) {
queryClient.setQueryData(
conversationPendingMessagesQueryKey(conversationId),
context.previousPending,
);
}
},
onSettled: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["dashboard", "conversations"],
}),
queryClient.invalidateQueries({
exact: true,
queryKey: conversationDetailQueryKey(conversationId),
}),
queryClient.invalidateQueries({
exact: true,
queryKey: conversationPendingMessagesQueryKey(conversationId),
}),
]);
},
});
}

/** Archive or restore one conversation with an immediate reversible cache update. */
export function useArchiveConversation(
conversationId: string,
Expand Down
17 changes: 17 additions & 0 deletions packages/junior-dashboard/src/client/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ export async function deleteDashboardResource(path: string): Promise<void> {
if (!response.ok) throw new DashboardApiError(path, response.status);
}

/** Send one authenticated DELETE request with JSON body and validate its response. */
export async function del<T>(
schema: ZodType<T>,
path: string,
body: unknown = {},
): Promise<T> {
const response = await fetch(path, {
body: JSON.stringify(body),
credentials: "same-origin",
headers: { "content-type": "application/json" },
method: "DELETE",
});
if (response.status === 401) restartDashboardSignIn();
if (!response.ok) throw new DashboardApiError(path, response.status);
return schema.parse(await response.json());
}

/** Fetch one authenticated dashboard JSON resource and validate its response. */
export async function fetchDashboardJson<T>(
schema: ZodType<T>,
Expand Down
50 changes: 50 additions & 0 deletions packages/junior/src/api/conversations/cancel-pending-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { User } from "@sentry/junior-plugin-api";
import { getConversationStore, getDb } from "@/chat/db";
import { cancelHumanFacingPendingMessages } from "@/chat/task-execution/store";
import { throwApiError } from "../http";
import type {
CancelConversationPendingMessagesBody,
CancelConversationPendingMessagesResponse,
} from "../schema/conversation";
import { readConversationAccessFromSql } from "./access";

/** Cancel accepted human-facing mailbox rows for one conversation participant. */
export async function cancelConversationPendingMessagesForViewer(
viewer: User,
conversationId: string,
body: CancelConversationPendingMessagesBody = {},
): Promise<CancelConversationPendingMessagesResponse> {
const conversation = await getConversationStore().get({ conversationId });
if (!conversation) {
throwApiError(404, "Conversation not found.");
}

const access = await readConversationAccessFromSql(
getDb(),
[conversationId],
viewer,
);
if (!access.get(conversationId)?.isParticipant) {
throwApiError(
403,
"Only conversation participants can cancel queued messages.",
);
}

try {
const result = await cancelHumanFacingPendingMessages({
conversationId,
...(body.inboundMessageIds
? { inboundMessageIds: body.inboundMessageIds }
: {}),
conversationStore: getConversationStore(),
});
return {
cancelledCount: result.cancelledInboundMessageIds.length,
cancelledInboundMessageIds: result.cancelledInboundMessageIds,
conversationId,
};
} catch (error) {
throwApiError(500, "Unable to cancel queued messages.", error);
}
}
31 changes: 31 additions & 0 deletions packages/junior/src/api/conversations/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
acceptedConversationMessageSchema,
archiveConversationBodySchema,
archiveConversationResponseSchema,
cancelConversationPendingMessagesBodySchema,
cancelConversationPendingMessagesResponseSchema,
conversationDetailQuerySchema,
conversationDetailReportSchema,
conversationEventPageSchema,
Expand All @@ -27,6 +29,7 @@ import {
import { readConversationDetail } from "./detail";
import { readConversationEvents } from "./event-list";
import { readConversationFeed } from "./list";
import { cancelConversationPendingMessagesForViewer } from "./cancel-pending-messages";
import { requireConversationPendingMessages } from "./pending-messages";
import { readConversationStats } from "./stats";

Expand Down Expand Up @@ -166,6 +169,34 @@ export function createConversationRoutes(): Hono<JuniorApiEnv> {
},
);

app.delete(
"/:conversationId/pending-messages",
requireViewer,
validateRequest(
"param",
conversationParamsSchema,
"Invalid route parameters.",
),
validateRequest(
"json",
cancelConversationPendingMessagesBodySchema,
"Invalid request body.",
),
async (context) => {
const viewer = context.get("viewer");
const { conversationId } = context.req.valid("param");
const body = context.req.valid("json");
return jsonResponse(
cancelConversationPendingMessagesResponseSchema,
await cancelConversationPendingMessagesForViewer(
viewer,
conversationId,
body,
),
);
},
);

app.get(
"/:conversationId",
validateRequest(
Expand Down
4 changes: 4 additions & 0 deletions packages/junior/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export {
acceptedConversationMessageSchema,
archiveConversationBodySchema,
archiveConversationResponseSchema,
cancelConversationPendingMessagesBodySchema,
cancelConversationPendingMessagesResponseSchema,
conversationAuxiliaryCostsSchema,
conversationDetailQuerySchema,
conversationDetailReportSchema,
Expand All @@ -28,6 +30,8 @@ export type {
AcceptedConversationMessage,
ArchiveConversationBody,
ArchiveConversationResponse,
CancelConversationPendingMessagesBody,
CancelConversationPendingMessagesResponse,
ActorIdentity,
ConversationAuxiliaryCosts,
ConversationCost,
Expand Down
22 changes: 22 additions & 0 deletions packages/junior/src/api/schema/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,22 @@ export const conversationPendingMessagesReportSchema = z
})
.strict();

/** Optional filters for cancelling accepted mailbox rows. */
export const cancelConversationPendingMessagesBodySchema = z
.object({
inboundMessageIds: z.array(z.string().min(1)).min(1).optional(),
})
.strict();

/** Result of cancelling accepted human-facing mailbox rows. */
export const cancelConversationPendingMessagesResponseSchema = z
.object({
cancelledCount: z.number().int().nonnegative(),
cancelledInboundMessageIds: z.array(z.string().min(1)),
conversationId: z.string().min(1),
})
.strict();

export const conversationAuxiliaryCostsSchema = z
.object({
costUsd: z.number().finite().nonnegative(),
Expand Down Expand Up @@ -792,3 +808,9 @@ export type ConversationPendingMessage = z.infer<
export type ConversationPendingMessagesReport = z.infer<
typeof conversationPendingMessagesReportSchema
>;
export type CancelConversationPendingMessagesBody = z.infer<
typeof cancelConversationPendingMessagesBodySchema
>;
export type CancelConversationPendingMessagesResponse = z.infer<
typeof cancelConversationPendingMessagesResponseSchema
>;
Loading
Loading