@sentry/junior-dashboard 0.275.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 (68) hide show
  1. package/dist/app.js +194 -40
  2. package/dist/client.js +120 -92
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +189 -38
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -4
  7. package/src/api/schema.ts +20 -2
  8. package/src/app.ts +88 -15
  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/ConversationComposer.tsx +42 -26
  16. package/src/client/conversations/ConversationDetails.tsx +66 -35
  17. package/src/client/conversations/ConversationHeader.tsx +19 -2
  18. package/src/client/conversations/ConversationHeaderActions.tsx +23 -0
  19. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  20. package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
  21. package/src/client/conversations/ConversationMemories.tsx +114 -77
  22. package/src/client/conversations/ConversationMeta.tsx +47 -42
  23. package/src/client/conversations/ConversationPage.tsx +183 -60
  24. package/src/client/conversations/ConversationSidebar.tsx +80 -24
  25. package/src/client/conversations/ConversationTranscript.tsx +37 -8
  26. package/src/client/conversations/ConversationWatches.tsx +42 -26
  27. package/src/client/conversations/ConversationWorkspace.tsx +4 -4
  28. package/src/client/conversations/DetailsList.tsx +8 -14
  29. package/src/client/conversations/MemoryIcons.tsx +23 -0
  30. package/src/client/conversations/PendingMailboxStack.tsx +11 -1
  31. package/src/client/conversations/QuickQuestion.tsx +476 -0
  32. package/src/client/conversations/TelemetryMetrics.tsx +2 -1
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  34. package/src/client/conversations/TranscriptEventView.tsx +25 -2
  35. package/src/client/conversations/TranscriptMemoryPopover.tsx +174 -0
  36. package/src/client/conversations/TranscriptMessageView.tsx +46 -16
  37. package/src/client/conversations/TranscriptRows.tsx +9 -2
  38. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  39. package/src/client/conversations/UsageCard.tsx +3 -3
  40. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  41. package/src/client/conversations/conversationArchive.ts +255 -0
  42. package/src/client/conversations/conversationMemory.ts +139 -0
  43. package/src/client/conversations/conversationSections.ts +24 -18
  44. package/src/client/conversations/eventTranscript.ts +8 -0
  45. package/src/client/conversations/queries.ts +48 -238
  46. package/src/client/conversations/usePinConversation.ts +88 -0
  47. package/src/client/display-text/DisplayText.tsx +7 -2
  48. package/src/client/display-text/codeBlocks.ts +92 -0
  49. package/src/client/format.ts +3 -67
  50. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  51. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  52. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  53. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  54. package/src/client/review-desk/ReviewItemActions.tsx +1 -1
  55. package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
  56. package/src/client/review-desk/ReviewItemContext.tsx +41 -23
  57. package/src/client/review-desk/queries.ts +2 -1
  58. package/src/client/review-desk/reviewItemFormat.ts +16 -0
  59. package/src/client/sentry.ts +77 -0
  60. package/src/client/types.ts +7 -0
  61. package/src/client.tsx +3 -0
  62. package/src/conversation-cache.ts +1 -1
  63. package/src/mock-reporting/fixtures.ts +103 -27
  64. package/src/mock-reporting/routes.ts +47 -0
  65. package/src/mock-reporting/watches.ts +2 -0
  66. package/src/shell.ts +27 -2
  67. package/src/tailwind.css +8 -19
  68. package/src/client/components/ActiveIndicator.tsx +0 -14
@@ -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
  )}
@@ -0,0 +1,145 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+
3
+ import {
4
+ AUTO_ARCHIVE_MAX_GRACE_HOURS,
5
+ dashboardIdentitySchema,
6
+ type DashboardIdentity,
7
+ } from "../../../api/schema";
8
+ import { Button } from "../../components/ui/button";
9
+ import { Checkbox } from "../../components/ui/checkbox";
10
+ import { Field, FieldDescription, FieldLabel } from "../../components/ui/field";
11
+ import { NativeSelect } from "../../components/ui/native-select";
12
+ import { InlineError } from "../../components/InlineError";
13
+ import { Card } from "../../components/layout/Card";
14
+ import { useArchiveDoneConversations } from "../../conversations/autoArchiveQueries";
15
+ import { patch } from "../../http";
16
+ import type { DashboardCoreData } from "../../types";
17
+
18
+ const dashboardCoreQueryKey = ["dashboard", "core"] as const;
19
+
20
+ /** Grace periods offered in Settings, in hours. */
21
+ const AUTO_ARCHIVE_GRACE_OPTIONS = [1, 6, 24, 72, AUTO_ARCHIVE_MAX_GRACE_HOURS];
22
+
23
+ function formatArchivedCount(count: number): string {
24
+ if (count === 0) return "No done conversations to archive.";
25
+ return count === 1
26
+ ? "Archived 1 conversation."
27
+ : `Archived ${count} conversations.`;
28
+ }
29
+
30
+ function formatGraceHours(hours: number): string {
31
+ if (hours % 24 !== 0) return hours === 1 ? "1 hour" : `${hours} hours`;
32
+ const days = hours / 24;
33
+ if (days === 7) return "1 week";
34
+ return days === 1 ? "1 day" : `${days} days`;
35
+ }
36
+
37
+ /**
38
+ * Let the user turn auto-archive on for their conversations and pick how long
39
+ * finished work must stay quiet before Junior archives it.
40
+ */
41
+ export function AutoArchiveSettings({
42
+ identity,
43
+ }: {
44
+ identity: DashboardIdentity;
45
+ }) {
46
+ const queryClient = useQueryClient();
47
+ const update = useMutation({
48
+ mutationFn: (
49
+ settings: Pick<
50
+ DashboardIdentity,
51
+ "autoArchiveDefault" | "autoArchiveGraceHours"
52
+ >,
53
+ ) => patch(dashboardIdentitySchema, "/api/me", settings),
54
+ onSuccess: (updated) => {
55
+ queryClient.setQueryData<DashboardCoreData>(
56
+ dashboardCoreQueryKey,
57
+ (current) => (current ? { ...current, me: updated } : current),
58
+ );
59
+ },
60
+ });
61
+ const archiveDone = useArchiveDoneConversations();
62
+ const enabled = identity.autoArchiveDefault ?? false;
63
+ const graceHours = identity.autoArchiveGraceHours ?? 24;
64
+ // Keep a saved value that is not a preset, so the select shows the truth.
65
+ const graceOptions = AUTO_ARCHIVE_GRACE_OPTIONS.includes(graceHours)
66
+ ? AUTO_ARCHIVE_GRACE_OPTIONS
67
+ : [...AUTO_ARCHIVE_GRACE_OPTIONS, graceHours].sort((a, b) => a - b);
68
+
69
+ return (
70
+ <Card className="mt-6 mb-0" padding="md" variant="raised">
71
+ <h2 className="m-0 text-lg font-bold">Conversations</h2>
72
+ <label className="mt-4 flex cursor-pointer items-start gap-3">
73
+ <Checkbox
74
+ checked={enabled}
75
+ className="mt-0.5"
76
+ disabled={update.isPending}
77
+ onCheckedChange={(checked) =>
78
+ update.mutate({ autoArchiveDefault: checked })
79
+ }
80
+ />
81
+ <span className="grid gap-0.5">
82
+ <span className="text-sm font-medium">
83
+ Auto-archive conversations when done
84
+ </span>
85
+ <span className="text-xs text-dashboard-text-muted">
86
+ Archive a conversation for you when all of its linked work, such as
87
+ pull requests, is merged or closed and nobody replies during the
88
+ wait time. You can change this for each conversation in its details.
89
+ </span>
90
+ </span>
91
+ </label>
92
+ <Field className="mt-5 max-w-xs">
93
+ <FieldLabel htmlFor="auto-archive-grace">Wait time</FieldLabel>
94
+ <NativeSelect
95
+ aria-describedby="auto-archive-grace-help"
96
+ disabled={update.isPending}
97
+ id="auto-archive-grace"
98
+ onChange={(event) =>
99
+ update.mutate({ autoArchiveGraceHours: Number(event.target.value) })
100
+ }
101
+ value={graceHours}
102
+ >
103
+ {graceOptions.map((hours) => (
104
+ <option key={hours} value={hours}>
105
+ {formatGraceHours(hours)}
106
+ </option>
107
+ ))}
108
+ </NativeSelect>
109
+ <FieldDescription id="auto-archive-grace-help">
110
+ How long finished work and the conversation stay quiet before archive.
111
+ </FieldDescription>
112
+ </Field>
113
+ {update.isError ? (
114
+ <InlineError className="mt-3">
115
+ Could not save this setting. Try again.
116
+ </InlineError>
117
+ ) : null}
118
+ <div className="mt-5 grid gap-1.5">
119
+ <div className="flex flex-wrap items-center gap-3">
120
+ <Button
121
+ disabled={archiveDone.isPending}
122
+ onClick={() => archiveDone.mutate()}
123
+ type="button"
124
+ >
125
+ {archiveDone.isPending ? "Archiving…" : "Archive done now"}
126
+ </Button>
127
+ {archiveDone.isSuccess ? (
128
+ <p className="m-0 text-sm text-dashboard-text-muted" role="status">
129
+ {formatArchivedCount(archiveDone.data.archived)}
130
+ </p>
131
+ ) : null}
132
+ {archiveDone.isError ? (
133
+ <InlineError>
134
+ Could not archive conversations. Try again.
135
+ </InlineError>
136
+ ) : null}
137
+ </div>
138
+ <p className="m-0 text-xs text-dashboard-text-muted">
139
+ Archive every done conversation once, without the wait time. Skips
140
+ conversations where you turned auto-archive off.
141
+ </p>
142
+ </div>
143
+ </Card>
144
+ );
145
+ }
@@ -24,6 +24,7 @@ import { InlineError } from "../../components/InlineError";
24
24
  import { Card } from "../../components/layout/Card";
25
25
  import { DashboardApiError, fetchDashboardJson, patch, put } from "../../http";
26
26
  import type { DashboardCoreData } from "../../types";
27
+ import { AutoArchiveSettings } from "./AutoArchiveSettings";
27
28
  import { SettingsPageLayout } from "./SettingsPageLayout";
28
29
 
29
30
  const dashboardCoreQueryKey = ["dashboard", "core"] as const;
@@ -108,6 +109,7 @@ export function SettingsPage({ identity }: SettingsPageProps) {
108
109
  </form>
109
110
  <ConversationStartSetting />
110
111
  <ComposerAutocorrectSetting />
112
+ <AutoArchiveSettings identity={identity} />
111
113
  <CustomizationForm />
112
114
  </SettingsPageLayout>
113
115
  );
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
3
3
  import type { ReviewItemPayload } from "@sentry/junior/api/schema";
4
4
 
5
5
  import { LoadMorePagination } from "../components/Pagination";
6
+ import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
6
7
  import {
7
8
  DetailsCard,
8
9
  DetailsList,
@@ -74,9 +75,9 @@ function ReviewItemRow(props: {
74
75
  if (props.defaultExpanded) setExpanded(true);
75
76
  }, [props.defaultExpanded]);
76
77
  return (
77
- <>
78
+ <Collapsible className="min-w-0" onOpenChange={setExpanded} open={expanded}>
78
79
  <DetailsRow
79
- expanded={expanded}
80
+ disclosure
80
81
  icon={<Inbox aria-hidden="true" className="size-3.5" />}
81
82
  meta={
82
83
  <span className="grid size-5 place-items-center">
@@ -86,14 +87,11 @@ function ReviewItemRow(props: {
86
87
  />
87
88
  </span>
88
89
  }
89
- onToggle={() => setExpanded((value) => !value)}
90
90
  title={props.item.title}
91
91
  />
92
- {expanded ? (
93
- <div className="px-1.5 pb-1">
94
- <ReviewItemCard item={props.item} />
95
- </div>
96
- ) : null}
97
- </>
92
+ <CollapsibleContent className="px-1.5 pb-1">
93
+ <ReviewItemCard item={props.item} />
94
+ </CollapsibleContent>
95
+ </Collapsible>
98
96
  );
99
97
  }
@@ -26,7 +26,7 @@ function actionError(error: unknown): string {
26
26
  }
27
27
 
28
28
  /** Show the submitted answer as one line for each answered field. */
29
- function AnswerSummary(props: {
29
+ export function AnswerSummary(props: {
30
30
  answer: NonNullable<ReviewItemPayload["answer"]>;
31
31
  request: NonNullable<ReviewItemPayload["request"]>;
32
32
  }) {
@@ -0,0 +1,85 @@
1
+ import { Inbox } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { Link } from "react-router";
4
+ import type {
5
+ ReviewItemChangeRecord,
6
+ ReviewItemPayload,
7
+ } from "@sentry/junior/api/schema";
8
+
9
+ import { AnswerSummary } from "./ReviewItemActions";
10
+ import { useReviewItemData } from "./queries";
11
+ import { reviewItemPath } from "./reviewItemFormat";
12
+
13
+ const CHANGE_VERBS = {
14
+ dismissed: "Dismissed",
15
+ submitted: "Answered",
16
+ taken: "Took",
17
+ } as const satisfies Record<ReviewItemChangeRecord["kind"], string>;
18
+
19
+ function ChangeRow(props: {
20
+ item: ReviewItemPayload;
21
+ record: ReviewItemChangeRecord;
22
+ }) {
23
+ const { item, record } = props;
24
+ const verb = CHANGE_VERBS[record.kind];
25
+ return (
26
+ <section
27
+ aria-label={`${verb} ${item.title}`}
28
+ className="grid min-w-0 max-w-[37.5rem] gap-2 rounded-lg border border-dashboard-border bg-dashboard-surface-panel p-3 font-sans text-sm"
29
+ >
30
+ <div className="flex min-w-0 items-center gap-2">
31
+ <Inbox
32
+ aria-hidden="true"
33
+ className="shrink-0 text-dashboard-text-muted"
34
+ size={15}
35
+ />
36
+ <span className="shrink-0 text-dashboard-text-muted">{verb}</span>
37
+ <Link
38
+ className="min-w-0 flex-1 truncate font-medium text-dashboard-text no-underline hover:underline focus-visible:outline-dashboard-focus"
39
+ to={reviewItemPath(item.id)}
40
+ >
41
+ {item.title}
42
+ </Link>
43
+ </div>
44
+ {/* An answer is final, so the item's answer is this message's answer. */}
45
+ {record.kind === "submitted" && item.request && item.answer ? (
46
+ <AnswerSummary answer={item.answer} request={item.request} />
47
+ ) : null}
48
+ </section>
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Show the Review item change that a message records as one row: what the
54
+ * message did and the item it did it to. The row shows the change from the
55
+ * message, not the item's current state, so a take stays a take after a
56
+ * later answer. Shows `fallback` when the viewer cannot read the item.
57
+ */
58
+ export function ReviewItemChangeRow(props: {
59
+ fallback: ReactNode;
60
+ record: ReviewItemChangeRecord;
61
+ }) {
62
+ const { data, isError } = useReviewItemData(props.record.reviewItemId);
63
+ if (isError) return props.fallback;
64
+ // The row replaces the text, so wait for the item before showing it.
65
+ if (!data) return null;
66
+ return <ChangeRow item={data} record={props.record} />;
67
+ }
68
+
69
+ /**
70
+ * Name the Review item change that a queued message records, on one line.
71
+ * Shows `fallback` until the item loads or when the viewer cannot read it.
72
+ */
73
+ export function ReviewItemChangeLabel(props: {
74
+ fallback: ReactNode;
75
+ record: ReviewItemChangeRecord;
76
+ }) {
77
+ const { data } = useReviewItemData(props.record.reviewItemId);
78
+ if (!data) return props.fallback;
79
+ return (
80
+ <>
81
+ {CHANGE_VERBS[props.record.kind]}{" "}
82
+ <span className="font-medium text-dashboard-text">{data.title}</span>
83
+ </>
84
+ );
85
+ }
@@ -9,28 +9,39 @@ import { ConversationTranscriptView } from "../conversations/ConversationTranscr
9
9
  import { TranscriptLoading } from "../conversations/TranscriptLoading";
10
10
  import { useConversationData } from "../conversations/queries";
11
11
  import type { ConversationTranscript } from "../types";
12
+ import { excerptMessage } from "./reviewItemFormat";
12
13
 
13
14
  /** Messages before the item that the excerpt shows. */
14
15
  const BEFORE_COUNT = 3;
15
- /** Messages after the item that the excerpt shows, newest last. */
16
- const AFTER_COUNT = 8;
16
+ /** Messages after the item that the excerpt shows. */
17
+ const AFTER_COUNT = 3;
17
18
 
18
19
  /**
19
- * Split the messages of a Conversation at the time an item was added. Only
20
- * messages show: the tool call that added the item would repeat its card.
20
+ * Pick the messages nearest the time an item was added. Only messages show:
21
+ * the tool call that added the item would repeat its card. For a closed item,
22
+ * messages from its close on do not show: the card already has the answer.
21
23
  */
22
24
  function conversationExcerpt(
23
25
  events: readonly ConversationReportEvent[],
24
- itemCreatedAt: string,
26
+ item: Pick<ReviewItemPayload, "closedAt" | "createdAt">,
25
27
  ): { after: ConversationReportEvent[]; before: ConversationReportEvent[] } {
26
- const askedAt = Date.parse(itemCreatedAt);
27
- const messages = events.filter(
28
- (event) => event.data.type === "message" && event.data.role !== "system",
29
- );
28
+ const askedAt = Date.parse(item.createdAt);
29
+ const closedAt = item.closedAt ? Date.parse(item.closedAt) : Infinity;
30
+ // Changes to items show on their cards, not as context for this one.
31
+ const messages = events.flatMap((event) => {
32
+ if (event.data.type !== "message" || event.data.role === "system") {
33
+ return [];
34
+ }
35
+ const message = excerptMessage(event);
36
+ return message ? [message] : [];
37
+ });
30
38
  return {
31
39
  after: messages
32
- .filter((event) => Date.parse(event.createdAt) > askedAt)
33
- .slice(-AFTER_COUNT),
40
+ .filter((event) => {
41
+ const at = Date.parse(event.createdAt);
42
+ return at > askedAt && at < closedAt;
43
+ })
44
+ .slice(0, AFTER_COUNT),
34
45
  before: messages
35
46
  .filter((event) => Date.parse(event.createdAt) <= askedAt)
36
47
  .slice(-BEFORE_COUNT),
@@ -49,8 +60,8 @@ function Excerpt(props: {
49
60
  }
50
61
 
51
62
  /**
52
- * Show the end of the Conversation that added an item, split where the agent
53
- * asked. Returns `fallback` when the viewer cannot read its messages.
63
+ * Show the messages around the point where the agent added an item, split
64
+ * where it asked. Returns `fallback` when the viewer cannot read its messages.
54
65
  */
55
66
  export function ReviewItemContext(props: {
56
67
  fallback: ReactNode;
@@ -60,30 +71,37 @@ export function ReviewItemContext(props: {
60
71
  const data = detail.data;
61
72
  const events = data?.events;
62
73
  const excerpt = useMemo(
63
- () =>
64
- events ? conversationExcerpt(events, props.item.createdAt) : undefined,
65
- [events, props.item.createdAt],
74
+ () => (events ? conversationExcerpt(events, props.item) : undefined),
75
+ [events, props.item],
66
76
  );
67
77
  // The first page holds the newest events. Load earlier pages until the
68
- // page reaches the time the item was added, or history ends.
78
+ // page reaches the time the item was added, or history ends. Until then
79
+ // the loaded messages after the ask are not the first ones, so the
80
+ // excerpt waits.
69
81
  const loadPreviousPageRef = useRef(detail.loadPreviousPage);
70
82
  loadPreviousPageRef.current = detail.loadPreviousPage;
71
83
  const oldest = events?.[0]?.createdAt;
72
- const loadEarlier =
84
+ // Redacted or expired history has no messages to wait for.
85
+ const available = data?.eventHistory.status === "available";
86
+ const missingEarlier =
87
+ available &&
73
88
  oldest !== undefined &&
74
89
  Date.parse(oldest) > Date.parse(props.item.createdAt) &&
75
- detail.hasPreviousPage &&
76
- !detail.isLoadingPreviousPage &&
77
- !detail.historyError;
90
+ detail.hasPreviousPage;
91
+ const loadEarlier =
92
+ missingEarlier && !detail.isLoadingPreviousPage && !detail.historyError;
78
93
  useEffect(() => {
79
94
  if (loadEarlier) loadPreviousPageRef.current();
80
95
  }, [loadEarlier, oldest]);
81
96
 
82
- if (detail.isPending) return <TranscriptLoading />;
97
+ if (detail.isPending || (missingEarlier && !detail.historyError)) {
98
+ return <TranscriptLoading />;
99
+ }
83
100
  if (
84
101
  !data ||
85
102
  !excerpt ||
86
- data.eventHistory.status !== "available" ||
103
+ !available ||
104
+ missingEarlier ||
87
105
  excerpt.before.length + excerpt.after.length === 0
88
106
  ) {
89
107
  return props.fallback;
@@ -20,7 +20,8 @@ import {
20
20
  import { conversationDetailTitle } from "../format";
21
21
  import { fetchDashboardJson, post } from "../http";
22
22
 
23
- const REVIEW_ITEMS_QUERY_KEY = ["dashboard", "review-items"] as const;
23
+ /** Prefix of every Review item read, for refreshes after a change. */
24
+ export const REVIEW_ITEMS_QUERY_KEY = ["dashboard", "review-items"] as const;
24
25
  const COUNT_REFRESH_MS = 60_000;
25
26
 
26
27
  /**