@sentry/junior-dashboard 0.273.0 → 0.275.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 (36) hide show
  1. package/dist/app.js +3 -3
  2. package/dist/client.js +76 -76
  3. package/dist/index.js +3 -3
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/client/App.tsx +11 -0
  7. package/src/client/components/Metric.tsx +0 -32
  8. package/src/client/components/ProfileMenu.tsx +56 -4
  9. package/src/client/components/layout/DashboardChrome.tsx +3 -3
  10. package/src/client/components/layout/DashboardHeader.tsx +41 -4
  11. package/src/client/components/layout/NavRail.tsx +43 -40
  12. package/src/client/conversations/ConversationComposer.tsx +7 -4
  13. package/src/client/conversations/ConversationDetails.tsx +117 -162
  14. package/src/client/conversations/ConversationHeader.tsx +119 -133
  15. package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
  16. package/src/client/conversations/ConversationMeta.tsx +49 -44
  17. package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
  18. package/src/client/conversations/ConversationPage.tsx +33 -368
  19. package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
  20. package/src/client/conversations/ConversationSidebar.tsx +119 -60
  21. package/src/client/conversations/ConversationWorkspace.tsx +8 -3
  22. package/src/client/conversations/TelemetryMetrics.tsx +0 -14
  23. package/src/client/conversations/TranscriptView.tsx +15 -32
  24. package/src/client/conversations/UsageCard.tsx +1 -15
  25. package/src/client/conversations/composerAutocorrect.ts +12 -0
  26. package/src/client/conversations/conversationAge.ts +24 -0
  27. package/src/client/conversations/conversationRoutes.ts +14 -5
  28. package/src/client/conversations/conversationSections.ts +66 -48
  29. package/src/client/conversations/useConversationFinishedIndicators.ts +40 -8
  30. package/src/client/pages/settings/SettingsPage.tsx +28 -0
  31. package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
  32. package/src/client/review-desk/queries.ts +30 -6
  33. package/src/mock-reporting/routes.ts +1 -1
  34. package/src/tailwind.css +4 -0
  35. package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
  36. package/src/client/review-desk/ConversationReviewBar.tsx +0 -67
@@ -115,7 +115,6 @@ function modelHeading(
115
115
  /** Rows shown before the rest of a list rolls into one "N more" line. */
116
116
  const MODEL_LIMIT = 4;
117
117
  const AUXILIARY_LIMIT = 3;
118
- const DETAILS_FOOTER = "Full breakdown in Details";
119
118
 
120
119
  type RankedRow = UsageCardRow & { amount: number };
121
120
 
@@ -222,7 +221,6 @@ function tokenCard(
222
221
  compactionCount: number | undefined,
223
222
  liveModelId: string | undefined,
224
223
  childAgentTotals: ConversationChildAgentTotals | undefined,
225
- detailsHint: boolean,
226
224
  ): UsageCardProps {
227
225
  const models = modelRows(
228
226
  modelUsage,
@@ -232,7 +230,6 @@ function tokenCard(
232
230
  formatCompactNumber,
233
231
  );
234
232
  return {
235
- footer: detailsHint && models.length ? DETAILS_FOOTER : undefined,
236
233
  note: compactionCount ? plural("compaction", compactionCount) : undefined,
237
234
  sections: models.length
238
235
  ? [
@@ -278,7 +275,6 @@ function costCard(
278
275
  auxiliaryCosts: ConversationAuxiliaryCosts | undefined,
279
276
  liveModelId: string | undefined,
280
277
  childAgentTotals: ConversationChildAgentTotals | undefined,
281
- detailsHint: boolean,
282
278
  ): UsageCardProps | undefined {
283
279
  const total = totalConversationCost(summary, auxiliaryCosts);
284
280
  if (!total) return undefined;
@@ -315,10 +311,6 @@ function costCard(
315
311
  });
316
312
  }
317
313
  return {
318
- footer:
319
- detailsHint && (models.length || operations.length)
320
- ? DETAILS_FOOTER
321
- : undefined,
322
314
  sections,
323
315
  split: agentSplit(
324
316
  childAgentTotals,
@@ -354,8 +346,6 @@ export function CostMetric(props: {
354
346
  align?: "left" | "right";
355
347
  auxiliaryCosts?: ConversationAuxiliaryCosts;
356
348
  childAgentTotals?: ConversationChildAgentTotals;
357
- /** Point to conversation details, which has the full breakdown. */
358
- detailsHint?: boolean;
359
349
  live?: boolean;
360
350
  liveModelId?: string;
361
351
  modelUsage?: ConversationModelUsage[];
@@ -375,7 +365,6 @@ export function CostMetric(props: {
375
365
  props.auxiliaryCosts,
376
366
  props.liveModelId,
377
367
  props.childAgentTotals,
378
- Boolean(props.detailsHint),
379
368
  );
380
369
  return (
381
370
  <MetricValue
@@ -398,8 +387,6 @@ export function TokenMetric(props: {
398
387
  align?: "left" | "right";
399
388
  childAgentTotals?: ConversationChildAgentTotals;
400
389
  compactionCount?: number;
401
- /** Point to conversation details, which has the full breakdown. */
402
- detailsHint?: boolean;
403
390
  live?: boolean;
404
391
  liveModelId?: string;
405
392
  modelUsage?: ConversationModelUsage[];
@@ -417,7 +404,6 @@ export function TokenMetric(props: {
417
404
  props.compactionCount,
418
405
  props.liveModelId,
419
406
  props.childAgentTotals,
420
- Boolean(props.detailsHint),
421
407
  )}
422
408
  />
423
409
  }
@@ -1,6 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import type { ConversationMailboxMessage } from "./conversationOutbox";
3
- import { ArrowDownToLine } from "lucide-react";
3
+ import { ArrowDown } from "lucide-react";
4
4
 
5
5
  import type {
6
6
  ConversationTranscript,
@@ -8,11 +8,6 @@ import type {
8
8
  } from "../types";
9
9
  import { EmptyTelemetry } from "../components/EmptyTelemetry";
10
10
  import { Button } from "../components/ui/button";
11
- import {
12
- Tooltip,
13
- TooltipContent,
14
- TooltipTrigger,
15
- } from "../components/ui/tooltip";
16
11
  import { ConversationTranscriptView } from "./ConversationTranscript";
17
12
  import { ConversationEventLog } from "./ConversationEventLog";
18
13
  import {
@@ -191,33 +186,21 @@ function JumpToLatestButton(props: {
191
186
  }) {
192
187
  if (!props.visible) return null;
193
188
 
194
- const label = props.hasPendingUpdate
195
- ? "Jump to latest update"
196
- : "Jump to latest";
197
-
198
189
  return (
199
- <div className="pointer-events-none sticky bottom-3 z-20 flex h-0 justify-center px-3 md:fixed md:inset-x-auto md:bottom-6 md:right-8 md:h-auto md:justify-end md:px-0">
200
- <Tooltip>
201
- <TooltipTrigger
202
- render={
203
- <Button
204
- aria-label={label}
205
- className="pointer-events-auto relative -translate-y-full rounded-lg !border !border-dashboard-border-emphasis !bg-dashboard-surface-active text-dashboard-text shadow-[0_6px_24px_rgba(0,0,0,0.36)] hover:!border-dashboard-border-interactive hover:!bg-dashboard-surface-hover hover:!text-dashboard-text md:translate-y-0"
206
- onClick={props.onClick}
207
- size="icon"
208
- >
209
- <ArrowDownToLine aria-hidden="true" size={16} strokeWidth={2} />
210
- {props.hasPendingUpdate ? (
211
- <span
212
- aria-hidden="true"
213
- className="absolute right-1.5 top-1.5 size-2 bg-emerald-300"
214
- />
215
- ) : null}
216
- </Button>
217
- }
218
- />
219
- <TooltipContent size="compact">{label}</TooltipContent>
220
- </Tooltip>
190
+ // Sticky offsets count from inside the scroll region's bottom padding
191
+ // (pb-4 / md:pb-6). The negative offsets cancel most of it, so the pill
192
+ // sits 12px (16px on md) above the composer, centered on the transcript.
193
+ <div className="pointer-events-none sticky -bottom-2.5 z-20 flex h-0 justify-center md:-bottom-5">
194
+ <Button
195
+ aria-label={
196
+ props.hasPendingUpdate ? "Jump to latest update" : "Jump to latest"
197
+ }
198
+ className="pointer-events-auto h-8 -translate-y-full gap-1.5 rounded-full pl-3 pr-3.5 text-xs shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
199
+ onClick={props.onClick}
200
+ >
201
+ <ArrowDown aria-hidden="true" size={14} strokeWidth={2} />
202
+ Jump to latest
203
+ </Button>
221
204
  </div>
222
205
  );
223
206
  }
@@ -27,8 +27,6 @@ export type UsageCardSplit = {
27
27
  };
28
28
 
29
29
  export type UsageCardProps = {
30
- /** Closing hint, such as where to find the full breakdown. */
31
- footer?: string;
32
30
  /** Muted detail after the title, such as a compaction count. */
33
31
  note?: string;
34
32
  sections: UsageCardSection[];
@@ -64,8 +62,7 @@ export function UsageCard(props: UsageCardProps) {
64
62
  <span
65
63
  className={cn(
66
64
  "grid gap-2.5",
67
- (props.sections.length > 0 || props.footer) &&
68
- "border-b border-dashboard-border pb-3",
65
+ props.sections.length > 0 && "border-b border-dashboard-border pb-3",
69
66
  )}
70
67
  >
71
68
  <span className="flex items-baseline justify-between gap-3">
@@ -156,17 +153,6 @@ export function UsageCard(props: UsageCardProps) {
156
153
  ) : null}
157
154
  </span>
158
155
  ))}
159
- {props.footer ? (
160
- <span
161
- className={cn(
162
- "text-dashboard-text-faint",
163
- props.sections.length > 0 &&
164
- "border-t border-dashboard-border pt-3",
165
- )}
166
- >
167
- {props.footer}
168
- </span>
169
- ) : null}
170
156
  </span>
171
157
  );
172
158
  }
@@ -0,0 +1,12 @@
1
+ import { useLocalStorage } from "@uidotdev/usehooks";
2
+
3
+ const AUTOCORRECT_STORAGE_KEY = "junior:composer:autocorrect";
4
+
5
+ /**
6
+ * Edit the browser-only choice to let the keyboard autocorrect, spell check,
7
+ * and capitalize the message composer. Off by default because these change
8
+ * ids, paths, and code.
9
+ */
10
+ export function useComposerAutocorrect() {
11
+ return useLocalStorage<boolean>(AUTOCORRECT_STORAGE_KEY, false);
12
+ }
@@ -0,0 +1,24 @@
1
+ import { getDashboardTimeZone } from "../format";
2
+
3
+ /**
4
+ * Format an activity time as a short age for dense lists: `now`, `12m`, `3h`,
5
+ * `2d`, `3w`, then a month and day.
6
+ */
7
+ export function formatCompactAge(value: string, nowMs = Date.now()): string {
8
+ const time = Date.parse(value);
9
+ if (!Number.isFinite(time)) return "";
10
+
11
+ const minutes = Math.floor(Math.max(0, nowMs - time) / 60_000);
12
+ if (minutes < 1) return "now";
13
+ if (minutes < 60) return `${minutes}m`;
14
+ const hours = Math.floor(minutes / 60);
15
+ if (hours < 24) return `${hours}h`;
16
+ const days = Math.floor(hours / 24);
17
+ if (days < 7) return `${days}d`;
18
+ if (days < 30) return `${Math.floor(days / 7)}w`;
19
+ return new Date(time).toLocaleDateString("en-US", {
20
+ day: "numeric",
21
+ month: "short",
22
+ timeZone: getDashboardTimeZone(),
23
+ });
24
+ }
@@ -2,16 +2,21 @@ import type { ConversationReportEvent } from "@sentry/junior/api/schema";
2
2
 
3
3
  /**
4
4
  * Build the dashboard route for a conversation id. A `turnId` opens the
5
- * start of that Turn in the event log.
5
+ * start of that Turn in the event log. `reviewItems` opens the Review items
6
+ * panel.
6
7
  */
7
8
  export function conversationPath(
8
9
  conversationId: string,
9
- options?: { turnId?: string },
10
+ options?: { reviewItems?: boolean; turnId?: string },
10
11
  ): string {
11
12
  const path = `/conversations/${encodeURIComponent(conversationId)}`;
12
- return options?.turnId
13
- ? `${path}?${CONVERSATION_TURN_PARAM}=${encodeURIComponent(options.turnId)}`
14
- : path;
13
+ const params = new URLSearchParams();
14
+ if (options?.turnId) params.set(CONVERSATION_TURN_PARAM, options.turnId);
15
+ if (options?.reviewItems) {
16
+ params.set(CONVERSATION_REVIEW_PARAM, CONVERSATION_REVIEW_OPEN);
17
+ }
18
+ const search = params.toString();
19
+ return search ? `${path}?${search}` : path;
15
20
  }
16
21
 
17
22
  /** Dashboard home route. Legacy `/conversations/new` redirects here. */
@@ -32,6 +37,10 @@ export function parseConversationEventSeq(
32
37
  return value && /^\d+$/.test(value) ? Number(value) : undefined;
33
38
  }
34
39
 
40
+ /** Query param that opens the Review items panel of a Conversation. */
41
+ export const CONVERSATION_REVIEW_PARAM = "review";
42
+ export const CONVERSATION_REVIEW_OPEN = "open";
43
+
35
44
  /** Query param that opens the start of one Turn in the event log. */
36
45
  export const CONVERSATION_TURN_PARAM = "turn";
37
46
 
@@ -7,21 +7,18 @@ export type ConversationSection = {
7
7
  };
8
8
 
9
9
  const DAY_MS = 24 * 60 * 60 * 1000;
10
- const SECTION_ORDER = [
11
- "attention",
12
- "priority",
13
- "today",
14
- "yesterday",
15
- "last-week",
16
- "2-weeks",
17
- "3-weeks",
18
- "older",
19
- ] as const;
10
+ // Sorts before every time section.
11
+ const ATTENTION_SECTION = {
12
+ key: "attention",
13
+ label: "Needs attention",
14
+ order: Number.NEGATIVE_INFINITY,
15
+ };
20
16
 
21
17
  /**
22
- * Group conversations into progressively broader activity sections.
18
+ * Group conversations into Needs attention, then by last activity: Today,
19
+ * Yesterday, Previous 7 days, Previous 30 days, then one section per month.
23
20
  *
24
- * Conversations in `attentionIds` go to the first section, before Priority.
21
+ * Conversations in `attentionIds` go to Needs attention, newest first.
25
22
  */
26
23
  export function buildConversationSections(
27
24
  conversations: Conversation[],
@@ -32,16 +29,20 @@ export function buildConversationSections(
32
29
  },
33
30
  ): ConversationSection[] {
34
31
  const nowDay = calendarDay(options.nowMs, options.timeZone);
35
- const sections = new Map<string, ConversationSection>();
32
+ const sections = new Map<string, ConversationSection & { order: number }>();
36
33
  const sorted = [...conversations].sort(
37
34
  (left, right) => activityTime(right) - activityTime(left),
38
35
  );
39
36
 
40
37
  for (const conversation of sorted) {
41
- const time = activityTime(conversation);
42
38
  const section = options.attentionIds?.has(conversation.id)
43
- ? { key: "attention", label: "Needs attention" }
44
- : conversationSection(conversation, time, nowDay, options.timeZone);
39
+ ? ATTENTION_SECTION
40
+ : conversationSection(
41
+ activityTime(conversation),
42
+ nowDay,
43
+ options.nowMs,
44
+ options.timeZone,
45
+ );
45
46
  const existing = sections.get(section.key);
46
47
  if (existing) {
47
48
  existing.conversations.push(conversation);
@@ -50,9 +51,9 @@ export function buildConversationSections(
50
51
  }
51
52
  }
52
53
 
53
- return [...sections.values()].sort(
54
- (left, right) => sectionRank(left.key) - sectionRank(right.key),
55
- );
54
+ return [...sections.values()]
55
+ .sort((left, right) => left.order - right.order)
56
+ .map(({ order: _order, ...section }) => section);
56
57
  }
57
58
 
58
59
  /** Join the conversations that wait on the viewer or finished unseen. */
@@ -82,44 +83,61 @@ function activityTime(conversation: Conversation): number {
82
83
  return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY;
83
84
  }
84
85
 
86
+ /**
87
+ * Pick the section for an activity time. `order` sorts sections newest
88
+ * first.
89
+ */
85
90
  function conversationSection(
86
- conversation: Conversation,
87
91
  time: number,
88
92
  nowDay: number,
93
+ nowMs: number,
89
94
  timeZone: string,
90
- ): Pick<ConversationSection, "key" | "label"> {
91
- if (!Number.isFinite(time)) return { key: "older", label: "Older" };
92
-
93
- // Priority membership is decided by the conversation feed API.
94
- if (conversation.isPriority) {
95
- return { key: "priority", label: "Priority" };
95
+ ): Pick<ConversationSection, "key" | "label"> & { order: number } {
96
+ if (!Number.isFinite(time)) {
97
+ return { key: "older", label: "Older", order: Number.POSITIVE_INFINITY };
96
98
  }
97
99
 
98
- const day = calendarDay(time, timeZone);
99
- const ageInDays = Math.max(0, Math.floor((nowDay - day) / DAY_MS));
100
- if (ageInDays === 0) return { key: "today", label: "Today" };
101
- if (ageInDays === 1) return { key: "yesterday", label: "Yesterday" };
102
- if (ageInDays < 7) {
103
- return {
104
- key: `day-${day}`,
105
- label: new Intl.DateTimeFormat("en-US", {
106
- timeZone,
107
- weekday: "long",
108
- }).format(time),
109
- };
100
+ const ageInDays = Math.max(
101
+ 0,
102
+ Math.round((nowDay - calendarDay(time, timeZone)) / DAY_MS),
103
+ );
104
+ if (ageInDays === 0) return { key: "today", label: "Today", order: 0 };
105
+ if (ageInDays === 1) {
106
+ return { key: "yesterday", label: "Yesterday", order: 1 };
107
+ }
108
+ if (ageInDays <= 7) {
109
+ return { key: "previous-7-days", label: "Previous 7 days", order: 2 };
110
110
  }
111
- if (ageInDays < 14) return { key: "last-week", label: "Last week" };
112
- if (ageInDays < 21) return { key: "2-weeks", label: "2 weeks ago" };
113
- if (ageInDays < 28) return { key: "3-weeks", label: "3 weeks ago" };
114
- return { key: "older", label: "Older" };
111
+ if (ageInDays <= 30) {
112
+ return { key: "previous-30-days", label: "Previous 30 days", order: 3 };
113
+ }
114
+
115
+ const month = yearMonth(time, timeZone);
116
+ const nowMonth = yearMonth(nowMs, timeZone);
117
+ return {
118
+ key: `month-${month.year}-${month.month}`,
119
+ label: new Intl.DateTimeFormat("en-US", {
120
+ month: "long",
121
+ timeZone,
122
+ ...(month.year === nowMonth.year ? undefined : { year: "numeric" }),
123
+ }).format(time),
124
+ // Month sections follow the rolling windows, newest month first.
125
+ order:
126
+ 4 + (nowMonth.year - month.year) * 12 + (nowMonth.month - month.month),
127
+ };
115
128
  }
116
129
 
117
- function sectionRank(key: string): number {
118
- const known = SECTION_ORDER.indexOf(key as (typeof SECTION_ORDER)[number]);
119
- if (known >= 0) return known;
120
- // Weekday buckets sit between Yesterday and Last week.
121
- if (key.startsWith("day-")) return SECTION_ORDER.indexOf("yesterday") + 0.5;
122
- return SECTION_ORDER.length;
130
+ function yearMonth(
131
+ time: number,
132
+ timeZone: string,
133
+ ): { month: number; year: number } {
134
+ const parts = new Intl.DateTimeFormat("en-US", {
135
+ month: "numeric",
136
+ timeZone,
137
+ year: "numeric",
138
+ }).formatToParts(time);
139
+ const values = new Map(parts.map((part) => [part.type, Number(part.value)]));
140
+ return { month: values.get("month") ?? 1, year: values.get("year") ?? 0 };
123
141
  }
124
142
 
125
143
  function calendarDay(time: number, timeZone: string): number {
@@ -12,6 +12,12 @@ type StoredConversationStates = Record<string, StoredConversationState>;
12
12
 
13
13
  type ConversationFinishedIndicators = {
14
14
  finishedConversationIds: ReadonlySet<string>;
15
+ /**
16
+ * The selected conversation when it was unread at the moment it opened.
17
+ * Lists keep it in its unread place until another conversation opens, so
18
+ * the row does not move under the pointer.
19
+ */
20
+ heldConversationId?: string;
15
21
  markRead(conversationId: string, lastReadAt: string): void;
16
22
  };
17
23
 
@@ -23,6 +29,23 @@ export function useConversationFinishedIndicators(
23
29
  pruneMissing: boolean,
24
30
  ): ConversationFinishedIndicators {
25
31
  const [storedStates, setStoredStates] = useState(readStoredStates);
32
+ const [held, setHeld] = useState<{ heldId?: string; selectedId?: string }>(
33
+ {},
34
+ );
35
+ // Read the unread state when the selection changes, before the effect
36
+ // below or the opened page marks it read. Hold only a row that the list
37
+ // already showed. A deep link has no row under the pointer, and the
38
+ // selected row never leads its section, so it does not move.
39
+ if (held.selectedId !== selectedId) {
40
+ const selected = conversations.find(({ id }) => id === selectedId);
41
+ setHeld({
42
+ heldId:
43
+ selected && finishedSinceRead(storedStates, selected)
44
+ ? selected.id
45
+ : undefined,
46
+ selectedId,
47
+ });
48
+ }
26
49
 
27
50
  useEffect(() => {
28
51
  if (!ready) return;
@@ -55,6 +78,7 @@ export function useConversationFinishedIndicators(
55
78
  () => finishedConversationIds(storedStates, conversations, selectedId),
56
79
  [conversations, selectedId, storedStates],
57
80
  ),
81
+ heldConversationId: held.heldId,
58
82
  markRead,
59
83
  };
60
84
  }
@@ -67,19 +91,27 @@ export function finishedConversationIds(
67
91
  ): ReadonlySet<string> {
68
92
  return new Set(
69
93
  conversations
70
- .filter((conversation) => {
71
- const state = states[conversation.id];
72
- return (
94
+ .filter(
95
+ (conversation) =>
73
96
  conversation.id !== selectedId &&
74
- conversation.status === "completed" &&
75
- state !== undefined &&
76
- isAfter(conversation.lastSeenAt, state.lastReadAt)
77
- );
78
- })
97
+ finishedSinceRead(states, conversation),
98
+ )
79
99
  .map((conversation) => conversation.id),
80
100
  );
81
101
  }
82
102
 
103
+ function finishedSinceRead(
104
+ states: StoredConversationStates,
105
+ conversation: Conversation,
106
+ ): boolean {
107
+ const state = states[conversation.id];
108
+ return (
109
+ conversation.status === "completed" &&
110
+ state !== undefined &&
111
+ isAfter(conversation.lastSeenAt, state.lastReadAt)
112
+ );
113
+ }
114
+
83
115
  /** Record reads and remove conversations outside the visible list. */
84
116
  export function reconcileStoredStates(
85
117
  current: StoredConversationStates,
@@ -12,12 +12,14 @@ import {
12
12
  import { Button } from "../../components/ui/button";
13
13
  import { Input } from "../../components/ui/input";
14
14
  import { Textarea } from "../../components/ui/textarea";
15
+ import { Checkbox } from "../../components/ui/checkbox";
15
16
  import { Field, FieldDescription, FieldLabel } from "../../components/ui/field";
16
17
  import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group";
17
18
  import {
18
19
  useConversationStartPosition,
19
20
  type ConversationStartPosition,
20
21
  } from "../../conversations/transcriptReadPosition";
22
+ import { useComposerAutocorrect } from "../../conversations/composerAutocorrect";
21
23
  import { InlineError } from "../../components/InlineError";
22
24
  import { Card } from "../../components/layout/Card";
23
25
  import { DashboardApiError, fetchDashboardJson, patch, put } from "../../http";
@@ -105,6 +107,7 @@ export function SettingsPage({ identity }: SettingsPageProps) {
105
107
  </Card>
106
108
  </form>
107
109
  <ConversationStartSetting />
110
+ <ComposerAutocorrectSetting />
108
111
  <CustomizationForm />
109
112
  </SettingsPageLayout>
110
113
  );
@@ -166,6 +169,31 @@ function ConversationStartSetting() {
166
169
  );
167
170
  }
168
171
 
172
+ /** Let the user turn on keyboard autocorrect in the composer on this browser. */
173
+ function ComposerAutocorrectSetting() {
174
+ const [autocorrect, setAutocorrect] = useComposerAutocorrect();
175
+ return (
176
+ <Card className="mt-6 mb-0" padding="md" variant="raised">
177
+ <h2 className="m-0 text-lg font-bold">Message box</h2>
178
+ <label className="mt-4 flex cursor-pointer items-start gap-3">
179
+ <Checkbox
180
+ checked={autocorrect}
181
+ className="mt-0.5"
182
+ onCheckedChange={setAutocorrect}
183
+ />
184
+ <span className="grid gap-0.5">
185
+ <span className="text-sm font-medium">Autocorrect</span>
186
+ <span className="text-xs text-dashboard-text-muted">
187
+ Let your keyboard correct spelling and capitalize sentences as you
188
+ type. This can change ids, paths, and code. This choice applies to
189
+ this browser.
190
+ </span>
191
+ </span>
192
+ </label>
193
+ </Card>
194
+ );
195
+ }
196
+
169
197
  /**
170
198
  * Let the user save text that changes how the agent replies to them. The
171
199
  * server returns 404 when the app does not enable this experiment.
@@ -0,0 +1,99 @@
1
+ import { ChevronRight, Inbox } from "lucide-react";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import type { ReviewItemPayload } from "@sentry/junior/api/schema";
4
+
5
+ import { LoadMorePagination } from "../components/Pagination";
6
+ import {
7
+ DetailsCard,
8
+ DetailsList,
9
+ DetailsRow,
10
+ } from "../conversations/DetailsList";
11
+ import { cn, dashboardDisclosureChevronClass } from "../styles";
12
+ import { ReviewItemCard } from "./ReviewItemCard";
13
+ import { useReviewItemsData } from "./queries";
14
+
15
+ /**
16
+ * Show the open Review items linked to a Conversation in its details. Each
17
+ * item is one line, like its sidebar chip, and expands to the full card in
18
+ * place. `highlight` marks the card when a sidebar link opened it.
19
+ */
20
+ export function ConversationReviewItems(props: {
21
+ conversationId: string;
22
+ highlight: boolean;
23
+ }) {
24
+ const { items, query } = useReviewItemsData({
25
+ conversationId: props.conversationId,
26
+ });
27
+ const sectionRef = useRef<HTMLDivElement>(null);
28
+ const shown = items.length > 0;
29
+ useEffect(() => {
30
+ if (props.highlight && shown) {
31
+ sectionRef.current?.scrollIntoView({ block: "nearest" });
32
+ }
33
+ }, [props.highlight, shown]);
34
+ if (!shown) return null;
35
+ return (
36
+ <div
37
+ className={cn(
38
+ "rounded-lg",
39
+ props.highlight && "ring-2 ring-violet-300/50",
40
+ )}
41
+ ref={sectionRef}
42
+ >
43
+ {/* A count of loaded pages only would understate, so hide it. */}
44
+ <DetailsCard
45
+ count={query.hasNextPage ? undefined : items.length}
46
+ label="Needs you"
47
+ title="Needs you"
48
+ >
49
+ <DetailsList
50
+ itemKey={(item) => item.id}
51
+ items={items}
52
+ renderItem={(item) => (
53
+ <ReviewItemRow defaultExpanded={props.highlight} item={item} />
54
+ )}
55
+ />
56
+ <LoadMorePagination
57
+ className="mt-2"
58
+ hasMore={Boolean(query.hasNextPage)}
59
+ loading={query.isFetchingNextPage}
60
+ onLoadMore={() => void query.fetchNextPage()}
61
+ />
62
+ </DetailsCard>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ function ReviewItemRow(props: {
68
+ defaultExpanded: boolean;
69
+ item: ReviewItemPayload;
70
+ }) {
71
+ const [expanded, setExpanded] = useState(props.defaultExpanded);
72
+ // Details can already be open when a sidebar review chip highlights it.
73
+ useEffect(() => {
74
+ if (props.defaultExpanded) setExpanded(true);
75
+ }, [props.defaultExpanded]);
76
+ return (
77
+ <>
78
+ <DetailsRow
79
+ expanded={expanded}
80
+ icon={<Inbox aria-hidden="true" className="size-3.5" />}
81
+ meta={
82
+ <span className="grid size-5 place-items-center">
83
+ <ChevronRight
84
+ aria-hidden="true"
85
+ className={dashboardDisclosureChevronClass}
86
+ />
87
+ </span>
88
+ }
89
+ onToggle={() => setExpanded((value) => !value)}
90
+ title={props.item.title}
91
+ />
92
+ {expanded ? (
93
+ <div className="px-1.5 pb-1">
94
+ <ReviewItemCard item={props.item} />
95
+ </div>
96
+ ) : null}
97
+ </>
98
+ );
99
+ }