@sentry/junior-dashboard 0.276.0 → 0.277.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +168 -33
- package/dist/client.js +88 -83
- package/dist/index.d.ts +1 -1
- package/dist/index.js +163 -31
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/api/schema.ts +20 -2
- package/src/app.ts +80 -12
- package/src/auth.ts +5 -1
- package/src/client/components/FormNotice.tsx +11 -14
- package/src/client/components/ProfileMenu.tsx +5 -6
- package/src/client/conversations/AutoArchiveToggle.tsx +35 -0
- package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
- package/src/client/conversations/ConversationChildAgents.tsx +2 -1
- package/src/client/conversations/ConversationDetails.tsx +6 -3
- package/src/client/conversations/ConversationHeader.tsx +9 -0
- package/src/client/conversations/ConversationHeaderActions.tsx +23 -0
- package/src/client/conversations/ConversationHomeList.tsx +1 -1
- package/src/client/conversations/ConversationMemories.tsx +64 -67
- package/src/client/conversations/ConversationPage.tsx +21 -8
- package/src/client/conversations/ConversationSidebar.tsx +80 -24
- package/src/client/conversations/ConversationWatches.tsx +15 -21
- package/src/client/conversations/ConversationWorkspace.tsx +4 -4
- package/src/client/conversations/DetailsList.tsx +8 -14
- package/src/client/conversations/MemoryIcons.tsx +23 -0
- package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
- package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
- package/src/client/conversations/autoArchiveQueries.ts +53 -0
- package/src/client/conversations/conversationArchive.ts +255 -0
- package/src/client/conversations/conversationMemory.ts +2 -1
- package/src/client/conversations/conversationSections.ts +24 -18
- package/src/client/conversations/queries.ts +48 -238
- package/src/client/conversations/usePinConversation.ts +88 -0
- package/src/client/display-text/DisplayText.tsx +7 -2
- package/src/client/display-text/codeBlocks.ts +92 -0
- package/src/client/format.ts +3 -67
- package/src/client/pages/code/RepositoryTabs.tsx +2 -2
- package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
- package/src/client/pages/settings/SettingsPage.tsx +2 -0
- package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
- package/src/client/review-desk/queries.ts +2 -1
- package/src/client/types.ts +1 -0
- package/src/conversation-cache.ts +1 -1
- package/src/mock-reporting/fixtures.ts +103 -27
- package/src/mock-reporting/routes.ts +47 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useDeferredValue, useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import {
|
|
3
|
+
type QueryClient,
|
|
3
4
|
queryOptions,
|
|
4
5
|
useInfiniteQuery,
|
|
5
6
|
useMutation,
|
|
6
|
-
useMutationState,
|
|
7
7
|
useQuery,
|
|
8
8
|
useQueryClient,
|
|
9
9
|
} from "@tanstack/react-query";
|
|
@@ -12,13 +12,11 @@ import type {
|
|
|
12
12
|
ConversationEventPage,
|
|
13
13
|
ConversationFeed,
|
|
14
14
|
ConversationPendingMessagesReport,
|
|
15
|
-
ConversationSummaryReport,
|
|
16
15
|
InputImage,
|
|
17
16
|
} from "@sentry/junior/api/schema";
|
|
18
17
|
import {
|
|
19
18
|
acceptedConversationMessageSchema,
|
|
20
19
|
webMessageId,
|
|
21
|
-
archiveConversationResponseSchema,
|
|
22
20
|
cancelConversationPendingMessagesResponseSchema,
|
|
23
21
|
conversationDetailReportSchema,
|
|
24
22
|
conversationEventPageSchema,
|
|
@@ -28,11 +26,11 @@ import {
|
|
|
28
26
|
stopConversationTurnResponseSchema,
|
|
29
27
|
} from "@sentry/junior/api/schema";
|
|
30
28
|
|
|
29
|
+
import { REVIEW_ITEMS_QUERY_KEY } from "../review-desk/queries";
|
|
31
30
|
import {
|
|
32
31
|
DashboardApiError,
|
|
33
32
|
del,
|
|
34
33
|
fetchDashboardJson,
|
|
35
|
-
patch,
|
|
36
34
|
post,
|
|
37
35
|
readDashboardResponse,
|
|
38
36
|
} from "../http";
|
|
@@ -66,78 +64,13 @@ export function conversationDetailQueryKey(conversationId: string | undefined) {
|
|
|
66
64
|
return ["conversation", conversationId, "detail"] as const;
|
|
67
65
|
}
|
|
68
66
|
|
|
69
|
-
function archivedConversationQueryKey(conversationId: string) {
|
|
70
|
-
return ["dashboard", "archived-conversation", conversationId] as const;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Read active/archived status from a dashboard conversations query key. */
|
|
74
|
-
function conversationFeedStatus(
|
|
75
|
-
queryKey: readonly unknown[],
|
|
76
|
-
): "active" | "archived" | undefined {
|
|
77
|
-
const status = queryKey[queryKey.length - 1];
|
|
78
|
-
return status === "active" || status === "archived" ? status : undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
type ArchivedConversationSnapshot = {
|
|
82
|
-
conversation: ConversationSummaryReport;
|
|
83
|
-
feedQueryHashes: string[];
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const archiveConversationMutationKey = [
|
|
87
|
-
"dashboard",
|
|
88
|
-
"archive-conversation",
|
|
89
|
-
] as const;
|
|
90
|
-
|
|
91
|
-
type ArchiveConversationVariables = {
|
|
92
|
-
archived: boolean;
|
|
93
|
-
lastSeenAt: string;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
type ArchiveConversationMutationContext = {
|
|
97
|
-
archivedQueryKey: ReturnType<typeof archivedConversationQueryKey>;
|
|
98
|
-
archivedSnapshot?: ArchivedConversationSnapshot;
|
|
99
|
-
detailQueryKey: ReturnType<typeof conversationDetailQueryKey>;
|
|
100
|
-
previousArchivedSnapshot?: ArchivedConversationSnapshot;
|
|
101
|
-
previousDetail?: ConversationDetailReport;
|
|
102
|
-
previousFeeds: Array<[readonly unknown[], ConversationFeed | undefined]>;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
export type PendingArchiveConversationUpdate = {
|
|
106
|
-
archived: boolean;
|
|
107
|
-
conversation?: ConversationSummaryReport;
|
|
108
|
-
conversationId: string;
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/** Read pending archive state so refetches cannot visually replace optimistic UI. */
|
|
112
|
-
export function usePendingArchiveConversationUpdates() {
|
|
113
|
-
return useMutationState({
|
|
114
|
-
filters: {
|
|
115
|
-
mutationKey: archiveConversationMutationKey,
|
|
116
|
-
status: "pending",
|
|
117
|
-
},
|
|
118
|
-
select: (mutation) => {
|
|
119
|
-
const variables = mutation.state
|
|
120
|
-
.variables as ArchiveConversationVariables;
|
|
121
|
-
const context = mutation.state.context as
|
|
122
|
-
| ArchiveConversationMutationContext
|
|
123
|
-
| undefined;
|
|
124
|
-
const conversationId = mutation.options.mutationKey?.[2];
|
|
125
|
-
return {
|
|
126
|
-
archived: variables.archived,
|
|
127
|
-
conversation: context?.archivedSnapshot?.conversation,
|
|
128
|
-
conversationId:
|
|
129
|
-
typeof conversationId === "string" ? conversationId : "",
|
|
130
|
-
} satisfies PendingArchiveConversationUpdate;
|
|
131
|
-
},
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
67
|
// Right after a send, Junior usually reads the message within a few seconds.
|
|
136
68
|
// Poll fast for that window only, then fall back to the normal live interval.
|
|
137
69
|
const FAST_POLL_INTERVAL_MS = 500;
|
|
138
70
|
const FAST_POLL_WINDOW_MS = 6_000;
|
|
139
71
|
|
|
140
|
-
|
|
72
|
+
/** The cached detail report with its mailbox and revalidation ETag. */
|
|
73
|
+
export type ConversationSnapshot = ConversationDetailReport & {
|
|
141
74
|
mailbox?: ConversationPendingMessagesReport;
|
|
142
75
|
detailEtag?: string;
|
|
143
76
|
};
|
|
@@ -153,6 +86,32 @@ async function readConversationSnapshot(
|
|
|
153
86
|
return { ...detail, mailbox };
|
|
154
87
|
}
|
|
155
88
|
|
|
89
|
+
const CONVERSATION_SIDE_READS = ["agents", "memories", "watches"] as const;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Key a read that shows beside the detail report, such as in the details
|
|
93
|
+
* panel. A detail change refreshes it.
|
|
94
|
+
*/
|
|
95
|
+
export function conversationSideReadQueryKey(
|
|
96
|
+
conversationId: string,
|
|
97
|
+
read: (typeof CONVERSATION_SIDE_READS)[number],
|
|
98
|
+
) {
|
|
99
|
+
return ["conversation", conversationId, read] as const;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Refresh the side reads and Review items. Only mounted reads refetch. */
|
|
103
|
+
function refreshConversationSideReads(
|
|
104
|
+
client: QueryClient,
|
|
105
|
+
conversationId: string,
|
|
106
|
+
) {
|
|
107
|
+
for (const read of CONVERSATION_SIDE_READS) {
|
|
108
|
+
void client.invalidateQueries({
|
|
109
|
+
queryKey: conversationSideReadQueryKey(conversationId, read),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
void client.invalidateQueries({ queryKey: REVIEW_ITEMS_QUERY_KEY });
|
|
113
|
+
}
|
|
114
|
+
|
|
156
115
|
/** Refresh history and mailbox as one visible snapshot, including idle input. */
|
|
157
116
|
export function conversationDetailQueryOptions(
|
|
158
117
|
conversationId: string | undefined,
|
|
@@ -165,14 +124,26 @@ export function conversationDetailQueryOptions(
|
|
|
165
124
|
return queryOptions({
|
|
166
125
|
enabled: Boolean(conversationId),
|
|
167
126
|
queryKey: conversationDetailQueryKey(conversationId),
|
|
168
|
-
queryFn: ({ signal, client }) =>
|
|
169
|
-
|
|
127
|
+
queryFn: async ({ signal, client }) => {
|
|
128
|
+
const previous = client.getQueryData<ConversationSnapshot>(
|
|
129
|
+
conversationDetailQueryKey(conversationId),
|
|
130
|
+
);
|
|
131
|
+
const next = await readConversationSnapshot(
|
|
170
132
|
conversationId!,
|
|
171
133
|
signal,
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
134
|
+
previous,
|
|
135
|
+
);
|
|
136
|
+
// Side reads do not poll. A new ETag means the Conversation changed,
|
|
137
|
+
// so the details panel may be stale too.
|
|
138
|
+
if (
|
|
139
|
+
previous?.detailEtag &&
|
|
140
|
+
next.detailEtag &&
|
|
141
|
+
previous.detailEtag !== next.detailEtag
|
|
142
|
+
) {
|
|
143
|
+
refreshConversationSideReads(client, conversationId!);
|
|
144
|
+
}
|
|
145
|
+
return next;
|
|
146
|
+
},
|
|
176
147
|
structuralSharing: (previous, next) => {
|
|
177
148
|
if (!next || typeof next !== "object") return next;
|
|
178
149
|
if (!previous || typeof previous !== "object") return next;
|
|
@@ -510,167 +481,6 @@ export function useStopConversationTurn(conversationId: string) {
|
|
|
510
481
|
});
|
|
511
482
|
}
|
|
512
483
|
|
|
513
|
-
/** Archive or restore one conversation with an immediate reversible cache update. */
|
|
514
|
-
export function useArchiveConversation(
|
|
515
|
-
conversationId: string,
|
|
516
|
-
options?: {
|
|
517
|
-
onError?(): void;
|
|
518
|
-
onSuccess?(archivedAt: string | null): void;
|
|
519
|
-
},
|
|
520
|
-
) {
|
|
521
|
-
const queryClient = useQueryClient();
|
|
522
|
-
return useMutation({
|
|
523
|
-
mutationKey: [...archiveConversationMutationKey, conversationId],
|
|
524
|
-
mutationFn: (args: ArchiveConversationVariables) =>
|
|
525
|
-
patch(
|
|
526
|
-
archiveConversationResponseSchema,
|
|
527
|
-
`/api/conversations/${encodeURIComponent(conversationId)}/archive`,
|
|
528
|
-
args,
|
|
529
|
-
),
|
|
530
|
-
onMutate: async (args) => {
|
|
531
|
-
const conversationQueries = { queryKey: ["dashboard", "conversations"] };
|
|
532
|
-
const detailQueryKey = conversationDetailQueryKey(conversationId);
|
|
533
|
-
const archivedQueryKey = archivedConversationQueryKey(conversationId);
|
|
534
|
-
await Promise.all([
|
|
535
|
-
queryClient.cancelQueries(conversationQueries),
|
|
536
|
-
queryClient.cancelQueries({ queryKey: detailQueryKey }),
|
|
537
|
-
]);
|
|
538
|
-
const previousFeeds =
|
|
539
|
-
queryClient.getQueriesData<ConversationFeed>(conversationQueries);
|
|
540
|
-
const previousDetail =
|
|
541
|
-
queryClient.getQueryData<ConversationDetailReport>(detailQueryKey);
|
|
542
|
-
const previousArchivedSnapshot =
|
|
543
|
-
queryClient.getQueryData<ArchivedConversationSnapshot>(
|
|
544
|
-
archivedQueryKey,
|
|
545
|
-
);
|
|
546
|
-
const archivedAt = args.archived ? new Date().toISOString() : null;
|
|
547
|
-
// Snapshot from any loaded feed, including archived-only fixtures that
|
|
548
|
-
// were never archived through this client session.
|
|
549
|
-
const archivedSnapshot =
|
|
550
|
-
buildArchivedConversationSnapshot(previousFeeds, conversationId) ??
|
|
551
|
-
previousArchivedSnapshot;
|
|
552
|
-
|
|
553
|
-
previousFeeds.forEach(([queryKey, feed]) => {
|
|
554
|
-
if (!feed) return;
|
|
555
|
-
const conversationExists = feed.conversations.some(
|
|
556
|
-
(conversation) => conversation.conversationId === conversationId,
|
|
557
|
-
);
|
|
558
|
-
const feedStatus = conversationFeedStatus(queryKey);
|
|
559
|
-
const shouldRestore =
|
|
560
|
-
!args.archived &&
|
|
561
|
-
!conversationExists &&
|
|
562
|
-
Boolean(archivedSnapshot) &&
|
|
563
|
-
feedStatus !== "archived";
|
|
564
|
-
const conversations = shouldRestore
|
|
565
|
-
? [
|
|
566
|
-
...feed.conversations,
|
|
567
|
-
{ ...archivedSnapshot!.conversation, archivedAt: null },
|
|
568
|
-
].sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
|
|
569
|
-
: feed.conversations.map((conversation) =>
|
|
570
|
-
conversation.conversationId === conversationId
|
|
571
|
-
? { ...conversation, archivedAt }
|
|
572
|
-
: conversation,
|
|
573
|
-
);
|
|
574
|
-
queryClient.setQueryData<ConversationFeed>(queryKey, {
|
|
575
|
-
...feed,
|
|
576
|
-
conversations,
|
|
577
|
-
});
|
|
578
|
-
});
|
|
579
|
-
queryClient.setQueryData<ConversationSnapshot>(
|
|
580
|
-
detailQueryKey,
|
|
581
|
-
(detail) =>
|
|
582
|
-
detail ? { ...detail, archivedAt, detailEtag: undefined } : detail,
|
|
583
|
-
);
|
|
584
|
-
if (archivedSnapshot) {
|
|
585
|
-
queryClient.setQueryData(archivedQueryKey, archivedSnapshot);
|
|
586
|
-
}
|
|
587
|
-
return {
|
|
588
|
-
archivedQueryKey,
|
|
589
|
-
archivedSnapshot,
|
|
590
|
-
detailQueryKey,
|
|
591
|
-
previousArchivedSnapshot,
|
|
592
|
-
previousDetail,
|
|
593
|
-
previousFeeds,
|
|
594
|
-
} satisfies ArchiveConversationMutationContext;
|
|
595
|
-
},
|
|
596
|
-
onError: (_error, _args, context) => {
|
|
597
|
-
context?.previousFeeds.forEach(([queryKey, feed]) => {
|
|
598
|
-
queryClient.setQueryData(queryKey, feed);
|
|
599
|
-
});
|
|
600
|
-
if (context) {
|
|
601
|
-
queryClient.setQueryData(
|
|
602
|
-
context.detailQueryKey,
|
|
603
|
-
context.previousDetail,
|
|
604
|
-
);
|
|
605
|
-
if (context.previousArchivedSnapshot) {
|
|
606
|
-
queryClient.setQueryData(
|
|
607
|
-
context.archivedQueryKey,
|
|
608
|
-
context.previousArchivedSnapshot,
|
|
609
|
-
);
|
|
610
|
-
} else {
|
|
611
|
-
queryClient.removeQueries({
|
|
612
|
-
exact: true,
|
|
613
|
-
queryKey: context.archivedQueryKey,
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
options?.onError?.();
|
|
618
|
-
},
|
|
619
|
-
onSuccess: (result, args) => {
|
|
620
|
-
queryClient.setQueryData<ConversationSnapshot>(
|
|
621
|
-
conversationDetailQueryKey(conversationId),
|
|
622
|
-
(detail) =>
|
|
623
|
-
detail
|
|
624
|
-
? {
|
|
625
|
-
...detail,
|
|
626
|
-
archivedAt: result.archivedAt,
|
|
627
|
-
detailEtag: undefined,
|
|
628
|
-
}
|
|
629
|
-
: detail,
|
|
630
|
-
);
|
|
631
|
-
if (!args.archived) {
|
|
632
|
-
queryClient.removeQueries({
|
|
633
|
-
exact: true,
|
|
634
|
-
queryKey: archivedConversationQueryKey(conversationId),
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
options?.onSuccess?.(result.archivedAt);
|
|
638
|
-
},
|
|
639
|
-
onSettled: async () => {
|
|
640
|
-
await Promise.all([
|
|
641
|
-
queryClient.invalidateQueries({
|
|
642
|
-
queryKey: ["dashboard", "conversations"],
|
|
643
|
-
}),
|
|
644
|
-
queryClient.invalidateQueries({
|
|
645
|
-
queryKey: ["dashboard", "locations"],
|
|
646
|
-
}),
|
|
647
|
-
queryClient.invalidateQueries({ queryKey: ["dashboard", "people"] }),
|
|
648
|
-
queryClient.invalidateQueries({
|
|
649
|
-
exact: true,
|
|
650
|
-
queryKey: conversationDetailQueryKey(conversationId),
|
|
651
|
-
}),
|
|
652
|
-
]);
|
|
653
|
-
},
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function buildArchivedConversationSnapshot(
|
|
658
|
-
feeds: Array<[readonly unknown[], ConversationFeed | undefined]>,
|
|
659
|
-
conversationId: string,
|
|
660
|
-
): ArchivedConversationSnapshot | undefined {
|
|
661
|
-
let conversation: ConversationSummaryReport | undefined;
|
|
662
|
-
const feedQueryHashes: string[] = [];
|
|
663
|
-
for (const [queryKey, feed] of feeds) {
|
|
664
|
-
const feedConversation = feed?.conversations.find(
|
|
665
|
-
(item) => item.conversationId === conversationId,
|
|
666
|
-
);
|
|
667
|
-
if (!feedConversation) continue;
|
|
668
|
-
conversation ??= feedConversation;
|
|
669
|
-
feedQueryHashes.push(JSON.stringify(queryKey));
|
|
670
|
-
}
|
|
671
|
-
return conversation ? { conversation, feedQueryHashes } : undefined;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
484
|
/** Fetch a bounded conversation snapshot and older pages on demand. */
|
|
675
485
|
export function useConversationData(conversationId: string | undefined) {
|
|
676
486
|
const queryClient = useQueryClient();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type {
|
|
3
|
+
ConversationDetailReport,
|
|
4
|
+
ConversationFeed,
|
|
5
|
+
} from "@sentry/junior/api/schema";
|
|
6
|
+
import { pinConversationResponseSchema } from "@sentry/junior/api/schema";
|
|
7
|
+
|
|
8
|
+
import { patch } from "../http";
|
|
9
|
+
import { conversationDetailQueryKey } from "./queries";
|
|
10
|
+
|
|
11
|
+
type DetailCache = ConversationDetailReport & { detailEtag?: string };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pin or unpin one conversation for the viewer. The feed and detail caches
|
|
15
|
+
* change at once, so the row moves to or from the Pinned section before the
|
|
16
|
+
* request returns. A failed request puts the old caches back.
|
|
17
|
+
*/
|
|
18
|
+
export function usePinConversation(
|
|
19
|
+
conversationId: string,
|
|
20
|
+
options?: { onError?(pinned: boolean): void; onSuccess?(): void },
|
|
21
|
+
) {
|
|
22
|
+
const queryClient = useQueryClient();
|
|
23
|
+
const feeds = { queryKey: ["dashboard", "conversations"] };
|
|
24
|
+
const detailQueryKey = conversationDetailQueryKey(conversationId);
|
|
25
|
+
const setPinnedAt = (pinnedAt: string | null) => {
|
|
26
|
+
// Match the API: a pin also restores an archived conversation.
|
|
27
|
+
const viewerMarks = pinnedAt
|
|
28
|
+
? { archivedAt: null, pinnedAt }
|
|
29
|
+
: { pinnedAt };
|
|
30
|
+
queryClient.setQueriesData<ConversationFeed>(feeds, (feed) =>
|
|
31
|
+
feed
|
|
32
|
+
? {
|
|
33
|
+
...feed,
|
|
34
|
+
conversations: feed.conversations.map((conversation) =>
|
|
35
|
+
conversation.conversationId === conversationId
|
|
36
|
+
? { ...conversation, ...viewerMarks }
|
|
37
|
+
: conversation,
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
: feed,
|
|
41
|
+
);
|
|
42
|
+
// Clear the etag so the next read gets a full body, not a 304 that keeps
|
|
43
|
+
// this local value.
|
|
44
|
+
queryClient.setQueryData<DetailCache>(detailQueryKey, (detail) =>
|
|
45
|
+
detail ? { ...detail, ...viewerMarks, detailEtag: undefined } : detail,
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
return useMutation({
|
|
49
|
+
mutationFn: (pinned: boolean) =>
|
|
50
|
+
patch(
|
|
51
|
+
pinConversationResponseSchema,
|
|
52
|
+
`/api/conversations/${encodeURIComponent(conversationId)}/pin`,
|
|
53
|
+
{ pinned },
|
|
54
|
+
),
|
|
55
|
+
onMutate: async (pinned) => {
|
|
56
|
+
await Promise.all([
|
|
57
|
+
queryClient.cancelQueries(feeds),
|
|
58
|
+
queryClient.cancelQueries({ queryKey: detailQueryKey }),
|
|
59
|
+
]);
|
|
60
|
+
const previous = {
|
|
61
|
+
detail: queryClient.getQueryData<DetailCache>(detailQueryKey),
|
|
62
|
+
feeds: queryClient.getQueriesData<ConversationFeed>(feeds),
|
|
63
|
+
};
|
|
64
|
+
setPinnedAt(pinned ? new Date().toISOString() : null);
|
|
65
|
+
return previous;
|
|
66
|
+
},
|
|
67
|
+
onError: (_error, pinned, previous) => {
|
|
68
|
+
previous?.feeds.forEach(([queryKey, feed]) => {
|
|
69
|
+
queryClient.setQueryData(queryKey, feed);
|
|
70
|
+
});
|
|
71
|
+
queryClient.setQueryData(detailQueryKey, previous?.detail);
|
|
72
|
+
options?.onError?.(pinned);
|
|
73
|
+
},
|
|
74
|
+
onSuccess: (result) => {
|
|
75
|
+
setPinnedAt(result.pinnedAt);
|
|
76
|
+
options?.onSuccess?.();
|
|
77
|
+
},
|
|
78
|
+
// A reader's first pin adds the conversation to their feed.
|
|
79
|
+
onSettled: () =>
|
|
80
|
+
Promise.all([
|
|
81
|
+
queryClient.invalidateQueries(feeds),
|
|
82
|
+
queryClient.invalidateQueries({
|
|
83
|
+
exact: true,
|
|
84
|
+
queryKey: detailQueryKey,
|
|
85
|
+
}),
|
|
86
|
+
]),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -2,7 +2,8 @@ import { memo } from "react";
|
|
|
2
2
|
import type { ActorIdentity } from "@sentry/junior/api/schema";
|
|
3
3
|
|
|
4
4
|
import { HighlightedCode } from "../code";
|
|
5
|
-
import {
|
|
5
|
+
import { CopyButton } from "../components/CopyButton";
|
|
6
|
+
import { parseMarkdownBlocks } from "./codeBlocks";
|
|
6
7
|
import { MarkdownText } from "./markdown";
|
|
7
8
|
import { MentionPeopleProvider } from "./mentions";
|
|
8
9
|
|
|
@@ -56,10 +57,14 @@ function DisplayTextBlocks(props: { chat?: boolean; text: string }) {
|
|
|
56
57
|
|
|
57
58
|
return (
|
|
58
59
|
<div
|
|
59
|
-
className="min-w-0 overflow-hidden rounded-md border border-dashboard-border-subtle bg-dashboard-surface-panel
|
|
60
|
+
className="group/code relative min-w-0 overflow-hidden rounded-md border border-dashboard-border-subtle bg-dashboard-surface-panel py-2.5 pl-3 pr-10"
|
|
60
61
|
key={index}
|
|
61
62
|
>
|
|
62
63
|
<HighlightedCode code={block.code} language={block.language} />
|
|
64
|
+
{/* Touch screens cannot hover, so keep the button visible there. */}
|
|
65
|
+
<div className="absolute right-0.5 top-0.5 transition-opacity [@media(hover:hover)_and_(pointer:fine)]:opacity-0 [@media(hover:hover)_and_(pointer:fine)]:group-hover/code:opacity-100 [@media(hover:hover)_and_(pointer:fine)]:group-focus-within/code:opacity-100">
|
|
66
|
+
<CopyButton getText={() => block.code} label="Copy code" />
|
|
67
|
+
</div>
|
|
63
68
|
</div>
|
|
64
69
|
);
|
|
65
70
|
})}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { BundledLanguage } from "shiki/bundle/web";
|
|
2
|
+
|
|
3
|
+
import { dashboardCodeLanguages } from "../code-languages";
|
|
4
|
+
import { detectOutputLanguage, prettyJsonData } from "../format";
|
|
5
|
+
import type { CodeBlock } from "../types";
|
|
6
|
+
|
|
7
|
+
// Shiki has no language detection, and agents often leave fences untagged.
|
|
8
|
+
// Only clear signals select a language, so prose in a fence stays plain.
|
|
9
|
+
const SCRIPT_PATTERN =
|
|
10
|
+
/^javascript:|=>|\b(?:const|let|var)\s+[\w$]+\s*=|\bfunction\b\s*[\w$]*\s*\(|^\s*(?:import\s.+\sfrom\s|export\s+(?:(?:async|class|const|default|enum|function|interface|let|type|var)\s|[{*]))/m;
|
|
11
|
+
const PYTHON_PATTERN =
|
|
12
|
+
/^\s*(?:(?:async\s+)?def\s+\w+\s*\(.*\)\s*(?:->.*)?:|class\s+\w+(?:\([^)]*\))?\s*:|from\s+[\w.]+\s+import\s|import\s+[\w.]+\s*$)/m;
|
|
13
|
+
const SHELL_PATTERN =
|
|
14
|
+
/^\s*(?:\$\s|(?:bun|brew|cargo|cat|cd|cp|curl|docker|echo|export|gh|git|go|grep|kubectl|ls|make|mkdir|mv|npm|npx|pip|pnpm|rm|ssh|sudo|uv|wget|yarn)\s)/;
|
|
15
|
+
|
|
16
|
+
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
|
|
17
|
+
console: "shellscript",
|
|
18
|
+
htm: "html",
|
|
19
|
+
js: "javascript",
|
|
20
|
+
jsonl: "json",
|
|
21
|
+
md: "markdown",
|
|
22
|
+
ndjson: "json",
|
|
23
|
+
sh: "shellscript",
|
|
24
|
+
text: "markdown",
|
|
25
|
+
txt: "markdown",
|
|
26
|
+
xml: "xml",
|
|
27
|
+
yml: "yaml",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Parse transcript prose as Markdown or JSON while preserving fenced code. */
|
|
31
|
+
export function parseMarkdownBlocks(text: string): CodeBlock[] {
|
|
32
|
+
const blocks: CodeBlock[] = [];
|
|
33
|
+
// The info string can hold more than the language, such as `c++` or
|
|
34
|
+
// `js title="x"`. Only its first word names the language.
|
|
35
|
+
const fence = /^([ \t]*)```([^\n`]*)\n([\s\S]*?)```/gm;
|
|
36
|
+
let cursor = 0;
|
|
37
|
+
let match: RegExpExecArray | null;
|
|
38
|
+
while ((match = fence.exec(text))) {
|
|
39
|
+
const prose = text.slice(cursor, match.index).trim();
|
|
40
|
+
if (prose) blocks.push(proseBlock(prose));
|
|
41
|
+
const tag = match[2]?.trim().split(/\s+/)[0];
|
|
42
|
+
const code = stripFenceIndent(match[3] ?? "", match[1]?.length ?? 0);
|
|
43
|
+
const language = tag ? normalizeLanguage(tag) : guessLanguage(code);
|
|
44
|
+
blocks.push({
|
|
45
|
+
code: formatCodeBlock(code, language),
|
|
46
|
+
fenced: true,
|
|
47
|
+
language,
|
|
48
|
+
});
|
|
49
|
+
cursor = match.index + match[0].length;
|
|
50
|
+
}
|
|
51
|
+
const rest = text.slice(cursor).trim();
|
|
52
|
+
if (rest) blocks.push(proseBlock(rest));
|
|
53
|
+
return blocks.length > 0 ? blocks : [proseBlock(text)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function proseBlock(text: string): CodeBlock {
|
|
57
|
+
const language = detectOutputLanguage(text);
|
|
58
|
+
return { code: formatCodeBlock(text, language), fenced: false, language };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeLanguage(tag: string): BundledLanguage {
|
|
62
|
+
const normalized = tag.toLowerCase();
|
|
63
|
+
const candidate = LANGUAGE_ALIASES[normalized] ?? normalized;
|
|
64
|
+
return Object.hasOwn(dashboardCodeLanguages, candidate)
|
|
65
|
+
? (candidate as BundledLanguage)
|
|
66
|
+
: "markdown";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function guessLanguage(code: string): BundledLanguage {
|
|
70
|
+
if (detectOutputLanguage(code) === "json") return "json";
|
|
71
|
+
if (SCRIPT_PATTERN.test(code)) return "tsx";
|
|
72
|
+
if (PYTHON_PATTERN.test(code)) return "python";
|
|
73
|
+
if (SHELL_PATTERN.test(code)) return "shellscript";
|
|
74
|
+
return "markdown";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A fence in a list item is indented, and so are its code lines. Remove that
|
|
78
|
+
// indent, as CommonMark does.
|
|
79
|
+
function stripFenceIndent(code: string, indent: number): string {
|
|
80
|
+
if (indent === 0) return code;
|
|
81
|
+
return code
|
|
82
|
+
.split("\n")
|
|
83
|
+
.map((line) => {
|
|
84
|
+
const lead = line.length - line.trimStart().length;
|
|
85
|
+
return line.slice(Math.min(indent, lead));
|
|
86
|
+
})
|
|
87
|
+
.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatCodeBlock(code: string, language: BundledLanguage): string {
|
|
91
|
+
return language === "json" ? (prettyJsonData(code) ?? code) : code;
|
|
92
|
+
}
|
package/src/client/format.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { BundledLanguage } from "shiki/bundle/web";
|
|
2
|
-
import { dashboardCodeLanguages } from "./code-languages";
|
|
3
2
|
import type {
|
|
4
3
|
ActorIdentity,
|
|
5
4
|
ConversationAuxiliaryCosts,
|
|
@@ -8,7 +7,6 @@ import type {
|
|
|
8
7
|
ConversationUsage,
|
|
9
8
|
} from "@sentry/junior/api/schema";
|
|
10
9
|
import type {
|
|
11
|
-
CodeBlock,
|
|
12
10
|
Conversation,
|
|
13
11
|
ConversationTranscript,
|
|
14
12
|
TranscriptViewMessage,
|
|
@@ -815,28 +813,6 @@ export function automationPath(
|
|
|
815
813
|
return tab === "overview" ? base : `${base}/${tab}`;
|
|
816
814
|
}
|
|
817
815
|
|
|
818
|
-
function normalizeLanguage(language: string | undefined): BundledLanguage {
|
|
819
|
-
const normalized = language?.trim().toLowerCase();
|
|
820
|
-
if (!normalized) return "markdown";
|
|
821
|
-
const aliases: Record<string, BundledLanguage> = {
|
|
822
|
-
console: "shellscript",
|
|
823
|
-
htm: "html",
|
|
824
|
-
js: "javascript",
|
|
825
|
-
jsonl: "json",
|
|
826
|
-
md: "markdown",
|
|
827
|
-
ndjson: "json",
|
|
828
|
-
sh: "shellscript",
|
|
829
|
-
text: "markdown",
|
|
830
|
-
txt: "markdown",
|
|
831
|
-
xml: "xml",
|
|
832
|
-
yml: "yaml",
|
|
833
|
-
};
|
|
834
|
-
const candidate = aliases[normalized] ?? normalized;
|
|
835
|
-
return Object.hasOwn(dashboardCodeLanguages, candidate)
|
|
836
|
-
? (candidate as BundledLanguage)
|
|
837
|
-
: "markdown";
|
|
838
|
-
}
|
|
839
|
-
|
|
840
816
|
function prettyJson(text: string): string | undefined {
|
|
841
817
|
const trimmed = text.trim();
|
|
842
818
|
if (!trimmed) return undefined;
|
|
@@ -863,14 +839,11 @@ function prettyJsonl(text: string): string | undefined {
|
|
|
863
839
|
return formatted.join("\n");
|
|
864
840
|
}
|
|
865
841
|
|
|
866
|
-
|
|
842
|
+
/** Pretty-print JSON or JSONL text, or return undefined for other text. */
|
|
843
|
+
export function prettyJsonData(text: string): string | undefined {
|
|
867
844
|
return prettyJson(text) ?? prettyJsonl(text);
|
|
868
845
|
}
|
|
869
846
|
|
|
870
|
-
function formatCodeBlock(code: string, language: BundledLanguage): string {
|
|
871
|
-
return language === "json" ? (prettyJsonData(code) ?? code) : code;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
847
|
/**
|
|
875
848
|
* Detect the language for LLM text output prose: json if the text is valid
|
|
876
849
|
* JSON or JSONL, markdown otherwise. Never auto-detects XML, HTML, TypeScript,
|
|
@@ -889,44 +862,6 @@ export function detectOutputLanguage(text: string): BundledLanguage {
|
|
|
889
862
|
return "markdown";
|
|
890
863
|
}
|
|
891
864
|
|
|
892
|
-
/** Parse transcript prose as Markdown or JSON while preserving fenced code. */
|
|
893
|
-
export function parseMarkdownBlocks(text: string): CodeBlock[] {
|
|
894
|
-
const blocks: CodeBlock[] = [];
|
|
895
|
-
const fence = /```([A-Za-z0-9_-]+)?\n([\s\S]*?)```/g;
|
|
896
|
-
let cursor = 0;
|
|
897
|
-
let match: RegExpExecArray | null;
|
|
898
|
-
while ((match = fence.exec(text))) {
|
|
899
|
-
const prose = text.slice(cursor, match.index).trim();
|
|
900
|
-
if (prose) {
|
|
901
|
-
const language = detectOutputLanguage(prose);
|
|
902
|
-
blocks.push({
|
|
903
|
-
code: formatCodeBlock(prose, language),
|
|
904
|
-
fenced: false,
|
|
905
|
-
language,
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
const language = normalizeLanguage(match[1]);
|
|
909
|
-
blocks.push({
|
|
910
|
-
code: formatCodeBlock(match[2] ?? "", language),
|
|
911
|
-
fenced: true,
|
|
912
|
-
language,
|
|
913
|
-
});
|
|
914
|
-
cursor = match.index + match[0].length;
|
|
915
|
-
}
|
|
916
|
-
const rest = text.slice(cursor).trim();
|
|
917
|
-
if (rest) {
|
|
918
|
-
const language = detectOutputLanguage(rest);
|
|
919
|
-
blocks.push({
|
|
920
|
-
code: formatCodeBlock(rest, language),
|
|
921
|
-
fenced: false,
|
|
922
|
-
language,
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
|
-
if (blocks.length > 0) return blocks;
|
|
926
|
-
const language = detectOutputLanguage(text);
|
|
927
|
-
return [{ code: formatCodeBlock(text, language), fenced: false, language }];
|
|
928
|
-
}
|
|
929
|
-
|
|
930
865
|
/** Convert SQL conversation summaries into dashboard rows. */
|
|
931
866
|
export function buildConversations(
|
|
932
867
|
summaries: ConversationSummaryReport[],
|
|
@@ -937,6 +872,7 @@ export function buildConversations(
|
|
|
937
872
|
annotations: summary.annotations,
|
|
938
873
|
sidebarAnnotations: summary.sidebarAnnotations,
|
|
939
874
|
archivedAt: summary.archivedAt,
|
|
875
|
+
pinnedAt: summary.pinnedAt,
|
|
940
876
|
auxiliaryCosts: summary.auxiliaryCosts,
|
|
941
877
|
channel: summary.channel,
|
|
942
878
|
channelName: summary.channelName,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "../../components/controls/TimeRangeSelector";
|
|
17
17
|
import { Card } from "../../components/layout/Card";
|
|
18
18
|
import { ConversationHomeList } from "../../conversations/ConversationHomeList";
|
|
19
|
-
import {
|
|
19
|
+
import { pinnedAndAttentionFirst } from "../../conversations/conversationSections";
|
|
20
20
|
import { buildConversations, getDashboardTimeZone } from "../../format";
|
|
21
21
|
import { useDebouncedValue } from "../../debouncedValue";
|
|
22
22
|
import { useWaitingConversationIds } from "../../review-desk/queries";
|
|
@@ -54,7 +54,7 @@ export function RepositoryConversationsTab(props: { repositoryId: string }) {
|
|
|
54
54
|
) : (
|
|
55
55
|
<ConversationHomeList
|
|
56
56
|
conversations={pageItems(
|
|
57
|
-
|
|
57
|
+
pinnedAndAttentionFirst(conversations, waitingConversationIds),
|
|
58
58
|
page,
|
|
59
59
|
PAGE_SIZE,
|
|
60
60
|
)}
|