@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.
Files changed (45) hide show
  1. package/dist/app.js +168 -33
  2. package/dist/client.js +88 -83
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +163 -31
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +4 -4
  7. package/src/api/schema.ts +20 -2
  8. package/src/app.ts +80 -12
  9. package/src/auth.ts +5 -1
  10. package/src/client/components/FormNotice.tsx +11 -14
  11. package/src/client/components/ProfileMenu.tsx +5 -6
  12. package/src/client/conversations/AutoArchiveToggle.tsx +35 -0
  13. package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
  14. package/src/client/conversations/ConversationChildAgents.tsx +2 -1
  15. package/src/client/conversations/ConversationDetails.tsx +6 -3
  16. package/src/client/conversations/ConversationHeader.tsx +9 -0
  17. package/src/client/conversations/ConversationHeaderActions.tsx +23 -0
  18. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  19. package/src/client/conversations/ConversationMemories.tsx +64 -67
  20. package/src/client/conversations/ConversationPage.tsx +21 -8
  21. package/src/client/conversations/ConversationSidebar.tsx +80 -24
  22. package/src/client/conversations/ConversationWatches.tsx +15 -21
  23. package/src/client/conversations/ConversationWorkspace.tsx +4 -4
  24. package/src/client/conversations/DetailsList.tsx +8 -14
  25. package/src/client/conversations/MemoryIcons.tsx +23 -0
  26. package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
  27. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  28. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  29. package/src/client/conversations/conversationArchive.ts +255 -0
  30. package/src/client/conversations/conversationMemory.ts +2 -1
  31. package/src/client/conversations/conversationSections.ts +24 -18
  32. package/src/client/conversations/queries.ts +48 -238
  33. package/src/client/conversations/usePinConversation.ts +88 -0
  34. package/src/client/display-text/DisplayText.tsx +7 -2
  35. package/src/client/display-text/codeBlocks.ts +92 -0
  36. package/src/client/format.ts +3 -67
  37. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  38. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  39. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  40. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  41. package/src/client/review-desk/queries.ts +2 -1
  42. package/src/client/types.ts +1 -0
  43. package/src/conversation-cache.ts +1 -1
  44. package/src/mock-reporting/fixtures.ts +103 -27
  45. 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
- type ConversationSnapshot = ConversationDetailReport & {
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
- readConversationSnapshot(
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
- client.getQueryData<ConversationSnapshot>(
173
- conversationDetailQueryKey(conversationId),
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 { parseMarkdownBlocks } from "../format";
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 px-3 py-2.5"
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
+ }
@@ -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
- function prettyJsonData(text: string): string | undefined {
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 { attentionFirst } from "../../conversations/conversationSections";
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
- attentionFirst(conversations, waitingConversationIds),
57
+ pinnedAndAttentionFirst(conversations, waitingConversationIds),
58
58
  page,
59
59
  PAGE_SIZE,
60
60
  )}