@sentry/junior-dashboard 0.280.0 → 0.281.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 (81) hide show
  1. package/dist/app.js +23 -30
  2. package/dist/client-manifest.json +1 -1
  3. package/dist/index.js +23 -30
  4. package/dist/public/chunks/{AdminPage-YEKZOKH6.js → AdminPage-M5RBVZFN.js} +1 -1
  5. package/dist/public/chunks/{AutomationPage-5GS3IUAK.js → AutomationPage-WALCREDT.js} +1 -1
  6. package/dist/public/chunks/{AutomationRunsPage-IPYEZU4L.js → AutomationRunsPage-CV47E4UX.js} +1 -1
  7. package/dist/public/chunks/{AutomationsPage-HSAYPC3C.js → AutomationsPage-57L3AQVJ.js} +1 -1
  8. package/dist/public/chunks/{CodePage-IPFKXWFB.js → CodePage-XJUYMFJH.js} +1 -1
  9. package/dist/public/chunks/{ConnectionsPage-5XUEK6FS.js → ConnectionsPage-3QQAGYWF.js} +1 -1
  10. package/dist/public/chunks/{LocationDetailPage-27ETUGR6.js → LocationDetailPage-4IL5HQML.js} +1 -1
  11. package/dist/public/chunks/{LocationsPage-WOKMQAGM.js → LocationsPage-D2DUQAHJ.js} +1 -1
  12. package/dist/public/chunks/{PeoplePage-6YQLDOUL.js → PeoplePage-6F5GPQ7C.js} +1 -1
  13. package/dist/public/chunks/{PersonProfilePage-GYMAZFIK.js → PersonProfilePage-SPAYQPTD.js} +1 -1
  14. package/dist/public/chunks/{PersonalTokensPage-4BCR744B.js → PersonalTokensPage-SHAUJ74M.js} +1 -1
  15. package/dist/public/chunks/{PluginUserPage-IP6WL7MD.js → PluginUserPage-64FIWD7M.js} +1 -1
  16. package/dist/public/chunks/{RepositoryPage-TRD44LQ3.js → RepositoryPage-L7IHOXSN.js} +1 -1
  17. package/dist/public/chunks/{SettingsPage-4E3SLQ36.js → SettingsPage-U7WG4FHE.js} +1 -1
  18. package/dist/public/chunks/{SystemPage-C3XBKXKW.js → SystemPage-ZBC43OJH.js} +1 -1
  19. package/dist/public/chunks/{WorkspaceFormPage-2LS2X67Q.js → WorkspaceFormPage-3NVIIZ4P.js} +1 -1
  20. package/dist/public/chunks/{WorkspacesPage-5WB3CUTX.js → WorkspacesPage-G73HPBDD.js} +1 -1
  21. package/dist/public/chunks/{chunk-VFEBNOPX.js → chunk-2J3LGF5Q.js} +1 -1
  22. package/dist/public/chunks/chunk-2NNKHSZE.js +1 -0
  23. package/dist/public/chunks/chunk-4TQSTCIN.js +13 -0
  24. package/dist/public/chunks/chunk-64GPEGGE.js +1 -0
  25. package/dist/public/chunks/{chunk-WZVO3HG4.js → chunk-6FHY65EQ.js} +1 -1
  26. package/dist/public/chunks/{chunk-YYNMZ4WC.js → chunk-DEEO4D4N.js} +1 -1
  27. package/dist/public/chunks/{chunk-SDKV3OID.js → chunk-FSGVY7HB.js} +1 -1
  28. package/dist/public/chunks/chunk-GZBMHYYU.js +1 -0
  29. package/dist/public/chunks/{chunk-NQMDED2R.js → chunk-II4NXLSQ.js} +1 -1
  30. package/dist/public/chunks/{chunk-B6HGRBBX.js → chunk-ILH47V7O.js} +1 -1
  31. package/dist/public/chunks/{chunk-GRMX2HX6.js → chunk-JPL2NRWK.js} +1 -1
  32. package/dist/public/chunks/{chunk-NQOJAZFQ.js → chunk-JVJ5UWIK.js} +1 -1
  33. package/dist/public/chunks/{chunk-V5UDSIRA.js → chunk-K4TYMAA3.js} +1 -1
  34. package/dist/public/chunks/{chunk-EFEREKX4.js → chunk-KDUTK357.js} +1 -1
  35. package/dist/public/chunks/{chunk-RHIXHBS5.js → chunk-LZYAHJVQ.js} +1 -1
  36. package/dist/public/chunks/{chunk-5GZDBGAR.js → chunk-MKKFXRYO.js} +1 -1
  37. package/dist/public/chunks/{chunk-PEQNT5MO.js → chunk-NGQRNS3C.js} +1 -1
  38. package/dist/public/chunks/{chunk-IA6BJDKN.js → chunk-ONDKE72P.js} +1 -1
  39. package/dist/public/chunks/{chunk-WJ3OXMVX.js → chunk-PG7M7L24.js} +1 -1
  40. package/dist/public/chunks/{chunk-6GQL3EVJ.js → chunk-QQUFROWT.js} +1 -1
  41. package/dist/public/chunks/{chunk-UAH6DWNS.js → chunk-RB5FBDEY.js} +1 -1
  42. package/dist/public/chunks/{chunk-A2QWWA7O.js → chunk-RGXXD4DM.js} +1 -1
  43. package/dist/public/chunks/{chunk-AYXSBMWA.js → chunk-TC7TU4GK.js} +1 -1
  44. package/dist/public/chunks/chunk-WLIOZO3W.js +1 -0
  45. package/dist/public/chunks/{chunk-VXT3FIVL.js → chunk-XDUSY26N.js} +11 -11
  46. package/dist/public/client-4OAAOHF4.js +68 -0
  47. package/dist/tailwind.css +1 -1
  48. package/package.json +4 -4
  49. package/src/client/components/BottomSheet.tsx +1 -1
  50. package/src/client/components/DetailsPanel.tsx +2 -1
  51. package/src/client/conversations/ConversationBrief.tsx +129 -65
  52. package/src/client/conversations/ConversationDetails.tsx +13 -14
  53. package/src/client/conversations/ConversationEventLog.tsx +20 -97
  54. package/src/client/conversations/ConversationHeader.tsx +41 -26
  55. package/src/client/conversations/ConversationHeaderActions.tsx +3 -1
  56. package/src/client/conversations/ConversationLinkedWork.tsx +30 -10
  57. package/src/client/conversations/ConversationMeta.tsx +15 -15
  58. package/src/client/conversations/ConversationPage.tsx +31 -7
  59. package/src/client/conversations/ConversationTranscript.tsx +234 -186
  60. package/src/client/conversations/ConversationUsageBreakdown.tsx +142 -64
  61. package/src/client/conversations/DetailsList.tsx +7 -1
  62. package/src/client/conversations/PendingMailboxStack.tsx +88 -44
  63. package/src/client/conversations/TranscriptActivityGroup.tsx +7 -14
  64. package/src/client/conversations/TranscriptEventView.tsx +7 -3
  65. package/src/client/conversations/TranscriptReasoningView.tsx +4 -2
  66. package/src/client/conversations/TranscriptRows.tsx +37 -5
  67. package/src/client/conversations/TranscriptView.tsx +4 -0
  68. package/src/client/conversations/queries.ts +74 -43
  69. package/src/client/conversations/transcriptBottomPinning.ts +13 -5
  70. package/src/client/conversations/transcriptListRows.ts +215 -0
  71. package/src/client/conversations/virtualRows.ts +168 -0
  72. package/src/client/http.ts +6 -5
  73. package/src/client/sentry.ts +53 -3
  74. package/src/conversation-cache.ts +8 -10
  75. package/src/mock-reporting/fixtures.ts +0 -8
  76. package/dist/public/chunks/chunk-GO45RQDL.js +0 -1
  77. package/dist/public/chunks/chunk-N4AONKD3.js +0 -1
  78. package/dist/public/chunks/chunk-QNA7F7QB.js +0 -1
  79. package/dist/public/chunks/chunk-R4ORRE77.js +0 -1
  80. package/dist/public/chunks/chunk-Z2JPRGFT.js +0 -1
  81. package/dist/public/client-TYC7JKRE.js +0 -91
@@ -1,18 +1,24 @@
1
- import type {
2
- ConversationDetailReport,
3
- ConversationModelUsage,
4
- } from "@sentry/junior/api/schema";
1
+ import type { ConversationDetailReport } from "@sentry/junior/api/schema";
2
+ import { ChevronRight } from "lucide-react";
5
3
  import type { ReactNode } from "react";
6
4
 
7
5
  import {
8
6
  formatCompactNumber,
7
+ formatCostSummary,
9
8
  summarizeCost,
10
9
  summarizeModelCost,
11
10
  summarizeModelUsage,
12
11
  summarizeUsage,
13
12
  totalConversationCost,
13
+ type CostUsageSummary,
14
+ type TokenUsageSummary,
14
15
  } from "../format";
15
16
  import { ShimmerText } from "../components/ShimmerText";
17
+ import {
18
+ Collapsible,
19
+ CollapsibleContent,
20
+ CollapsibleTrigger,
21
+ } from "../components/ui/collapsible";
16
22
  import {
17
23
  Table,
18
24
  TableBody,
@@ -22,7 +28,7 @@ import {
22
28
  TableHeader,
23
29
  TableRow,
24
30
  } from "../components/ui/table";
25
- import { cn } from "../styles";
31
+ import { cn, dashboardDisclosureChevronClass } from "../styles";
26
32
  import type { Conversation } from "../types";
27
33
  import { liveModelId } from "./ConversationMeta";
28
34
  import {
@@ -31,7 +37,6 @@ import {
31
37
  modelLabel,
32
38
  subagentShare,
33
39
  subagentUsage,
34
- tokenTypeRows,
35
40
  } from "./TelemetryMetrics";
36
41
 
37
42
  /** One token type of a model, shown as an indented row under the model. */
@@ -52,31 +57,33 @@ type BreakdownRow = {
52
57
  const NOT_RECORDED = "—";
53
58
 
54
59
  /**
55
- * List each recorded token type of one model with its own count and, when
56
- * the provider reports it, its own cost. Reasoning tokens are part of the
57
- * output tokens, so they have no cost of their own.
60
+ * Show token types with a recorded count or cost. Reasoning tokens are part
61
+ * of output tokens and have no cost of their own.
58
62
  */
59
63
  function tokenTypeParts(
60
- usage: ConversationModelUsage["usage"],
64
+ tokens: TokenUsageSummary | undefined,
65
+ costs: CostUsageSummary | undefined,
61
66
  formatCost: (value: number) => string,
62
67
  ): BreakdownPart[] | undefined {
63
- const summary = summarizeUsage(usage);
64
- if (!summary) return undefined;
65
- const cost = summarizeCost(usage);
66
- const costs: Readonly<Record<string, number | undefined>> = {
67
- cached: cost?.cacheRead,
68
- "cache write": cost?.cacheWrite,
69
- input: cost?.input,
70
- output: cost?.output,
71
- };
72
- const parts = tokenTypeRows(summary).map((row) => {
73
- const value = costs[row.label];
74
- return {
75
- cost: value === undefined ? undefined : formatCost(value),
76
- count: row.value,
77
- label: row.label === "reasoning" ? "reasoning (in output)" : row.label,
78
- };
79
- });
68
+ const types = [
69
+ ["input", tokens?.inputTokens, costs?.input],
70
+ ["output", tokens?.outputTokens, costs?.output],
71
+ ["cached", tokens?.cachedInputTokens, costs?.cacheRead],
72
+ ["cache write", tokens?.cacheCreationTokens, costs?.cacheWrite],
73
+ ["reasoning (in output)", tokens?.reasoningTokens, undefined],
74
+ ] as const;
75
+ const parts = types.flatMap(([label, count, cost]) =>
76
+ count || cost !== undefined
77
+ ? [
78
+ {
79
+ cost: cost === undefined ? undefined : formatCost(cost),
80
+ count:
81
+ count === undefined ? NOT_RECORDED : formatCompactNumber(count),
82
+ label,
83
+ },
84
+ ]
85
+ : [],
86
+ );
80
87
  return parts.length ? parts : undefined;
81
88
  }
82
89
 
@@ -224,10 +231,8 @@ export function hasConversationUsageBreakdown(props: {
224
231
  }
225
232
 
226
233
  /**
227
- * Break a Conversation's usage down by agent, model, and auxiliary
228
- * operation. The header tooltips show only the largest items; this lists
229
- * every one with its token types. Render nothing without model or auxiliary
230
- * usage.
234
+ * Summarize usage and let the reader inspect the breakdown by agent, model,
235
+ * and auxiliary operation.
231
236
  */
232
237
  export function ConversationUsageBreakdown(props: {
233
238
  conversation: Conversation | undefined;
@@ -243,11 +248,16 @@ export function ConversationUsageBreakdown(props: {
243
248
  props.detail?.cumulativeUsage ?? props.conversation.cumulativeUsage;
244
249
  const childAgentTotals =
245
250
  props.detail?.childAgentTotals ?? props.conversation.childAgentTotals;
246
- const tokens =
247
- summarizeModelUsage(modelUsage)?.totalTokens ??
248
- summarizeUsage(usage)?.totalTokens;
249
- const modelCost = summarizeModelCost(modelUsage) ?? summarizeCost(usage);
250
- const cost = totalConversationCost(modelCost, auxiliaryCosts)?.total;
251
+ const modelTokens = summarizeModelUsage(modelUsage);
252
+ const savedTokens = summarizeUsage(usage);
253
+ const tokens = (modelTokens ?? savedTokens)?.totalTokens;
254
+ const modelCost = summarizeModelCost(modelUsage);
255
+ const savedCost = summarizeCost(usage);
256
+ const totalCost = totalConversationCost(
257
+ modelCost ?? savedCost,
258
+ auxiliaryCosts,
259
+ );
260
+ const cost = totalCost?.total;
251
261
  const operations = [...(auxiliaryCosts?.operations ?? [])].sort(
252
262
  (a, b) => b.costUsd - a.costUsd,
253
263
  );
@@ -257,17 +267,38 @@ export function ConversationUsageBreakdown(props: {
257
267
  const formatMaybeCost = (value: number | undefined) =>
258
268
  value === undefined ? NOT_RECORDED : formatCost(value);
259
269
  const activeModelId = liveModelId(props.detail);
270
+ const live = (props.detail?.status ?? props.conversation.status) === "active";
271
+ const aggregateTokenTypes = tokenTypeParts(
272
+ modelTokens ? undefined : savedTokens,
273
+ modelCost ? undefined : savedCost,
274
+ formatCost,
275
+ );
276
+ const compactions = props.detail?.previousCursor
277
+ ? undefined
278
+ : props.detail?.events.filter((event) => event.data.type === "compaction")
279
+ .length;
260
280
 
261
281
  const models = modelUsage
262
282
  .map((item) => ({
263
- cost: summarizeCost(item.usage)?.total,
264
- item,
265
- tokens: summarizeUsage(item.usage)?.totalTokens,
283
+ cost: summarizeCost(item.usage),
284
+ modelId: item.modelId,
285
+ tokens: summarizeUsage(item.usage),
266
286
  }))
267
287
  .sort(
268
288
  (a, b) =>
269
- (b.cost ?? 0) - (a.cost ?? 0) || (b.tokens ?? 0) - (a.tokens ?? 0),
289
+ (b.cost?.total ?? 0) - (a.cost?.total ?? 0) ||
290
+ (b.tokens?.totalTokens ?? 0) - (a.tokens?.totalTokens ?? 0),
270
291
  );
292
+ if (
293
+ activeModelId &&
294
+ !models.some((model) => model.modelId === activeModelId)
295
+ ) {
296
+ models.unshift({
297
+ cost: undefined,
298
+ modelId: activeModelId,
299
+ tokens: undefined,
300
+ });
301
+ }
271
302
 
272
303
  const subagents = subagentUsage(childAgentTotals);
273
304
  const subagentCost = subagentShare(subagents.cost, cost ?? subagents.cost);
@@ -276,8 +307,8 @@ export function ConversationUsageBreakdown(props: {
276
307
  tokens ?? subagents.tokens,
277
308
  );
278
309
 
279
- return (
280
- <div className="grid min-w-0 gap-5">
310
+ const breakdown = (
311
+ <div className="grid min-w-0 gap-5 px-1 pb-2.5">
281
312
  {childAgentTotals ? (
282
313
  <BreakdownTable
283
314
  countLabel="Tokens"
@@ -311,38 +342,51 @@ export function ConversationUsageBreakdown(props: {
311
342
  <BreakdownTable
312
343
  countLabel="Tokens"
313
344
  label="Model"
314
- rows={models.map(({ cost: itemCost, item, tokens: itemTokens }) => ({
315
- cost: formatMaybeCost(itemCost),
316
- count: formatTokens(itemTokens),
317
- key: item.modelId,
318
- label: (
319
- <span className="flex min-w-0 items-baseline gap-2">
320
- <span
321
- className="min-w-0 truncate font-mono"
322
- title={item.modelId}
323
- >
324
- {modelLabel(item.modelId)}
325
- </span>
326
- {item.modelId === activeModelId ? (
327
- <span className="shrink-0 text-dashboard-text-muted">
328
- <ShimmerText active>in progress</ShimmerText>
345
+ rows={models.map(({ cost, modelId, tokens }) => {
346
+ const active = modelId === activeModelId;
347
+ return {
348
+ cost: active && !cost ? "…" : formatMaybeCost(cost?.total),
349
+ count:
350
+ active && !tokens ? "…" : formatTokens(tokens?.totalTokens),
351
+ key: modelId,
352
+ label: (
353
+ <span className="flex min-w-0 items-baseline gap-2">
354
+ <span className="min-w-0 truncate font-mono" title={modelId}>
355
+ {modelLabel(modelId)}
329
356
  </span>
330
- ) : null}
331
- </span>
332
- ),
333
- parts: tokenTypeParts(item.usage, formatCost),
334
- }))}
357
+ {active ? (
358
+ <span className="shrink-0 text-dashboard-text-muted">
359
+ <ShimmerText active>in progress</ShimmerText>
360
+ </span>
361
+ ) : null}
362
+ </span>
363
+ ),
364
+ parts: tokenTypeParts(tokens, cost, formatCost),
365
+ };
366
+ })}
335
367
  total={
336
- models.length > 1
368
+ modelUsage.length > 1
337
369
  ? {
338
370
  cost: formatMaybeCost(modelCost?.total),
339
- count: formatTokens(tokens),
371
+ count: formatTokens(modelTokens?.totalTokens),
340
372
  label: "All models",
341
373
  }
342
374
  : undefined
343
375
  }
344
376
  />
345
377
  ) : null}
378
+ {aggregateTokenTypes ? (
379
+ <BreakdownTable
380
+ countLabel="Tokens"
381
+ label="Token type"
382
+ rows={aggregateTokenTypes.map((part) => ({
383
+ cost: part.cost ?? NOT_RECORDED,
384
+ count: part.count,
385
+ key: part.label,
386
+ label: part.label,
387
+ }))}
388
+ />
389
+ ) : null}
346
390
  {auxiliaryCosts && operations.length ? (
347
391
  <BreakdownTable
348
392
  countLabel="Calls"
@@ -369,7 +413,41 @@ export function ConversationUsageBreakdown(props: {
369
413
  <p className="m-0 text-xs leading-relaxed text-dashboard-text-muted">
370
414
  Estimated costs.
371
415
  {childAgentTotals ? " Totals include subagents at any depth." : null}
416
+ {compactions
417
+ ? ` ${formatCompactNumber(compactions)} compaction${compactions === 1 ? "" : "s"}.`
418
+ : null}
372
419
  </p>
373
420
  </div>
374
421
  );
422
+
423
+ const summary = [
424
+ tokens === undefined ? undefined : `${formatTokens(tokens)} tokens`,
425
+ totalCost
426
+ ? `${formatCostSummary(totalCost)}${live ? "+" : ""}`
427
+ : live
428
+ ? "$…"
429
+ : undefined,
430
+ ]
431
+ .filter(Boolean)
432
+ .join(" · ");
433
+
434
+ return (
435
+ <Collapsible className="min-w-0 border-t border-dashboard-border-subtle">
436
+ <h3 className="m-0">
437
+ <CollapsibleTrigger className="group flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-md px-1 py-2 text-left font-sans text-xs font-medium text-dashboard-text-muted transition-colors hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-dashboard-focus">
438
+ <ChevronRight
439
+ aria-hidden="true"
440
+ className={dashboardDisclosureChevronClass}
441
+ />
442
+ Usage
443
+ {summary ? (
444
+ <span className="ml-auto font-normal tabular-nums">
445
+ <ShimmerText active={live}>{summary}</ShimmerText>
446
+ </span>
447
+ ) : null}
448
+ </CollapsibleTrigger>
449
+ </h3>
450
+ <CollapsibleContent hiddenUntilFound>{breakdown}</CollapsibleContent>
451
+ </Collapsible>
452
+ );
375
453
  }
@@ -12,13 +12,19 @@ import { cn, dashboardDisclosureChevronClass } from "../styles";
12
12
  export function DetailsCard(props: {
13
13
  children: ReactNode;
14
14
  count?: number;
15
+ flat?: boolean;
15
16
  label?: string;
16
17
  title?: string;
17
18
  }) {
18
19
  return (
19
20
  <section
20
21
  aria-label={props.label}
21
- className="grid min-w-0 gap-2.5 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel p-3"
22
+ className={cn(
23
+ "grid min-w-0 gap-2.5 border-dashboard-border-subtle",
24
+ props.flat
25
+ ? "border-t px-1 py-2.5"
26
+ : "rounded-lg border bg-dashboard-surface-panel p-3",
27
+ )}
22
28
  >
23
29
  {props.title ? (
24
30
  <h3 className="m-0 flex min-w-0 items-center gap-1.5 break-words font-sans text-xs font-medium text-dashboard-text-muted">
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  memo,
3
+ useId,
3
4
  useLayoutEffect,
4
5
  useRef,
5
6
  useState,
@@ -12,6 +13,7 @@ import {
12
13
  } from "@sentry/junior/api/schema";
13
14
  import {
14
15
  AlertCircle,
16
+ ChevronRight,
15
17
  Clock3,
16
18
  FileText,
17
19
  Pencil,
@@ -44,6 +46,7 @@ import { SlackMark } from "./SlackMark";
44
46
  import { DisplayText } from "../display-text/DisplayText";
45
47
  import { ReviewItemChangeLabel } from "../review-desk/ReviewItemChangeRow";
46
48
  import { displayPlainText } from "../display-text/plainText";
49
+ import { cn, dashboardDisclosureChevronClass } from "../styles";
47
50
 
48
51
  // Rows are one line each, so the stack can show five before it collapses.
49
52
  const MAX_EXPANDED_PENDING_ROWS = 5;
@@ -306,6 +309,7 @@ function PendingRow(props: {
306
309
  cancelPending: boolean;
307
310
  canEdit: boolean;
308
311
  canSteer: boolean;
312
+ className: string;
309
313
  conversationId?: string;
310
314
  editPending: boolean;
311
315
  message: ConversationMailboxMessage;
@@ -319,13 +323,14 @@ function PendingRow(props: {
319
323
  const mentions = props.message.mentions ?? [];
320
324
  const redacted = Boolean(props.message.redacted);
321
325
  const showSlack = props.message.source === "slack";
322
- const canRetry =
323
- props.message.clientStatus === "failed" &&
324
- Boolean(props.message.idempotencyKey) &&
325
- Boolean(props.onRetry);
326
326
 
327
327
  return (
328
- <article className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1 px-3 py-1.5 text-dashboard-text md:px-3.5">
328
+ <article
329
+ className={cn(
330
+ "min-w-0 grid-cols-[minmax(0,1fr)] gap-1 px-3 py-1.5 text-dashboard-text md:px-3.5",
331
+ props.className,
332
+ )}
333
+ >
329
334
  <TranscriptHeadingRow
330
335
  className="items-center"
331
336
  left={
@@ -390,7 +395,7 @@ function PendingRow(props: {
390
395
  />
391
396
  }
392
397
  />
393
- {canRetry ? (
398
+ {props.onRetry ? (
394
399
  <div className="flex min-w-0 items-center gap-2">
395
400
  <p className="m-0 font-sans text-xs text-red-300/80">
396
401
  Could not send.
@@ -414,36 +419,42 @@ function PendingRow(props: {
414
419
  }
415
420
 
416
421
  function ExpandQueuedMessagesButton(props: {
422
+ className?: string;
423
+ controls: string;
417
424
  expanded: boolean;
418
- hiddenCount: number;
419
425
  onClick(): void;
426
+ tallHiddenCount: number;
420
427
  totalCount: number;
421
428
  }) {
422
- const totalLabel =
423
- props.totalCount === 1
424
- ? "1 queued message"
425
- : `${props.totalCount} queued messages`;
426
- const moreLabel =
427
- props.hiddenCount > 0
428
- ? `${props.hiddenCount} more queued messages`
429
- : totalLabel;
430
- // Mobile collapses previews and uses the total count as the expand control.
431
- const mobileCollapsedLabel = totalLabel;
432
- const label = props.expanded ? "Show fewer queued messages" : moreLabel;
433
-
434
429
  return (
435
430
  <button
431
+ aria-controls={props.controls}
436
432
  aria-expanded={props.expanded}
437
- className="w-full cursor-pointer border-0 bg-transparent px-3 py-2 text-left font-sans text-xs font-medium text-amber-100/80 transition-colors hover:text-amber-50 focus-visible:outline focus-visible:outline-1 focus-visible:outline-amber-200/55 md:px-3.5"
433
+ className={cn(
434
+ "group flex w-full cursor-pointer items-center gap-1.5 border-0 bg-transparent px-3 py-2 text-left font-sans text-xs font-medium text-amber-100/80 transition-colors hover:text-amber-50 focus-visible:outline focus-visible:outline-1 focus-visible:outline-amber-200/55 md:px-3.5",
435
+ props.className,
436
+ )}
438
437
  onClick={props.onClick}
439
438
  type="button"
440
439
  >
440
+ <ChevronRight
441
+ aria-hidden="true"
442
+ className={dashboardDisclosureChevronClass}
443
+ />
441
444
  {props.expanded ? (
442
- label
445
+ "Show fewer queued messages"
443
446
  ) : (
444
447
  <>
445
- <span className="md:hidden">{mobileCollapsedLabel}</span>
446
- <span className="hidden md:inline">{moreLabel}</span>
448
+ <span className="md:tall:hidden">
449
+ {props.totalCount} queued messages
450
+ </span>
451
+ {props.tallHiddenCount > 0 ? (
452
+ <span className="hidden md:tall:inline">
453
+ {props.tallHiddenCount === 1
454
+ ? "1 more queued message"
455
+ : `${props.tallHiddenCount} more queued messages`}
456
+ </span>
457
+ ) : null}
447
458
  </>
448
459
  )}
449
460
  </button>
@@ -484,6 +495,7 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
484
495
  viewerEmail?: string;
485
496
  }): ReactNode {
486
497
  const [expanded, setExpanded] = useState(false);
498
+ const rowsId = useId();
487
499
  const onLayoutChangeRef = useRef(props.onLayoutChange);
488
500
  onLayoutChangeRef.current = props.onLayoutChange;
489
501
  const hasMountedExpandedRef = useRef(false);
@@ -500,24 +512,46 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
500
512
  // Preserve mailbox order and clientStatus from the merged source rows.
501
513
  // History wins on messageId so a send does not appear twice once workers
502
514
  // persist the user message.
503
- const rows = props.messages.filter(
504
- (message) => !committedIds.has(message.messageId),
505
- );
515
+ const rows = props.messages
516
+ .filter((message) => !committedIds.has(message.messageId))
517
+ .map((message) => {
518
+ const cancelError = Boolean(
519
+ props.cancelError &&
520
+ props.cancelTargetInboundMessageId === message.inboundMessageId,
521
+ );
522
+ const retry =
523
+ message.clientStatus === "failed" && message.idempotencyKey
524
+ ? props.onRetry
525
+ : undefined;
526
+ return {
527
+ cancelError,
528
+ message,
529
+ retry,
530
+ needsAttention: cancelError || !!retry,
531
+ };
532
+ });
506
533
  if (rows.length === 0) return null;
507
534
 
508
- const canCollapse = rows.length > MAX_EXPANDED_PENDING_ROWS;
509
- const showCollapsed = canCollapse && !expanded;
510
- const previewRows = rows.slice(0, COLLAPSED_PENDING_ROW_COUNT);
511
- const visibleRows = showCollapsed ? previewRows : rows;
512
- const hiddenCount = Math.max(0, rows.length - COLLAPSED_PENDING_ROW_COUNT);
535
+ const longQueue = rows.length > MAX_EXPANDED_PENDING_ROWS;
536
+ const canCollapse =
537
+ rows.length > 1 && rows.some((row) => !row.needsAttention);
538
+ const tallHiddenCount = longQueue
539
+ ? rows
540
+ .slice(COLLAPSED_PENDING_ROW_COUNT)
541
+ .filter((row) => !row.needsAttention).length
542
+ : 0;
513
543
  const toggleExpanded = () => setExpanded((value) => !value);
514
544
  const actionsDisabled =
515
545
  Boolean(props.cancelPending) ||
516
546
  Boolean(props.editTargetInboundMessageId) ||
517
547
  Boolean(props.steerTargetInboundMessageId);
518
- const renderRow = (message: ConversationMailboxMessage) => {
519
- const isTarget =
520
- props.cancelTargetInboundMessageId === message.inboundMessageId;
548
+ const renderRow = (row: (typeof rows)[number], index: number) => {
549
+ const { cancelError, message, needsAttention, retry } = row;
550
+ const collapsed = canCollapse && !expanded && !needsAttention;
551
+ if (collapsed && longQueue && index >= COLLAPSED_PENDING_ROW_COUNT) {
552
+ return null;
553
+ }
554
+
521
555
  // Only accepted server rows can leave the queue. Local rows are still sending.
522
556
  const accepted = message.clientStatus === undefined;
523
557
  const ownWebRow =
@@ -529,10 +563,20 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
529
563
  <PendingRow
530
564
  actionsDisabled={actionsDisabled}
531
565
  authors={props.authors}
532
- cancelError={Boolean(props.cancelError && isTarget)}
533
- cancelPending={Boolean(props.cancelPending && isTarget)}
566
+ cancelError={cancelError}
567
+ cancelPending={Boolean(
568
+ props.cancelPending &&
569
+ props.cancelTargetInboundMessageId === message.inboundMessageId,
570
+ )}
534
571
  canEdit={ownWebRow}
535
572
  canSteer={ownWebRow && message.delivery === "defer"}
573
+ className={
574
+ collapsed
575
+ ? longQueue
576
+ ? "hidden md:tall:grid"
577
+ : "grid md:hidden md:tall:grid"
578
+ : "grid"
579
+ }
536
580
  conversationId={props.conversationId}
537
581
  editPending={
538
582
  props.editTargetInboundMessageId === message.inboundMessageId
@@ -541,7 +585,7 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
541
585
  message={message}
542
586
  onCancel={accepted ? props.onCancelMessage : undefined}
543
587
  onEdit={props.onEditMessage}
544
- onRetry={props.onRetry}
588
+ onRetry={retry}
545
589
  onSteer={props.onSteerMessage}
546
590
  steerPending={
547
591
  props.steerTargetInboundMessageId === message.inboundMessageId
@@ -555,17 +599,17 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
555
599
  aria-label="Pending messages"
556
600
  className="mx-2 overflow-hidden rounded-t-lg bg-amber-300/[0.055] md:mx-3"
557
601
  >
558
- {showCollapsed ? (
559
- // Desktop keeps a short preview; mobile collapses to the control only.
560
- <div className="hidden md:block">{previewRows.map(renderRow)}</div>
561
- ) : (
562
- visibleRows.map(renderRow)
563
- )}
602
+ <div id={rowsId}>{rows.map(renderRow)}</div>
564
603
  {canCollapse ? (
565
604
  <ExpandQueuedMessagesButton
605
+ className={cn(
606
+ !longQueue && "hidden md:flex",
607
+ tallHiddenCount === 0 && "md:tall:hidden",
608
+ )}
609
+ controls={rowsId}
566
610
  expanded={expanded}
567
- hiddenCount={hiddenCount}
568
611
  onClick={toggleExpanded}
612
+ tallHiddenCount={tallHiddenCount}
569
613
  totalCount={rows.length}
570
614
  />
571
615
  ) : null}
@@ -1,4 +1,4 @@
1
- import { Fragment, useState, type ReactNode } from "react";
1
+ import { Fragment, type ReactNode } from "react";
2
2
  import { ChevronRight } from "lucide-react";
3
3
 
4
4
  import { ShimmerText } from "../components/ShimmerText";
@@ -15,6 +15,7 @@ import type { RenderedTranscriptEntry } from "./transcriptRenderModel";
15
15
  import { cn } from "../styles";
16
16
  import { useTranscriptSearch } from "./transcriptSearch";
17
17
  import { TranscriptActivityNesting } from "./TranscriptRailEvent";
18
+ import { useTranscriptRowOpen } from "./TranscriptRows";
18
19
  import { TranscriptSummaryTrigger } from "./TranscriptSummary";
19
20
 
20
21
  function countLabel(count: number, singular: string, plural: string): string {
@@ -175,18 +176,16 @@ function hasLiveActivity(entries: RenderedTranscriptEntry[]): boolean {
175
176
  );
176
177
  }
177
178
 
178
- /** Keep activity collapsed until the reader explicitly opens it. */
179
- export function activityGroupOpen(userOpen: boolean | null): boolean {
180
- return userOpen ?? false;
181
- }
182
-
183
179
  /** Collapse completed non-message activity so chat messages stay primary. */
184
180
  export function TranscriptActivityGroup(props: {
185
181
  entries: RenderedTranscriptEntry[];
182
+ /** Keeps the group open while it unmounts outside the viewport. */
183
+ openKey?: string;
186
184
  renderEntry: (entry: RenderedTranscriptEntry) => ReactNode;
187
185
  }) {
188
186
  const { active: searchActive } = useTranscriptSearch();
189
- const [userOpen, setUserOpen] = useState<boolean | null>(null);
187
+ // Activity stays collapsed until the reader opens it.
188
+ const [open, setOpen] = useTranscriptRowOpen(props.openKey);
190
189
  const rows = props.entries.map((entry) => (
191
190
  <Fragment key={entry.key}>{props.renderEntry(entry)}</Fragment>
192
191
  ));
@@ -202,14 +201,8 @@ export function TranscriptActivityGroup(props: {
202
201
  );
203
202
  }
204
203
 
205
- const open = activityGroupOpen(userOpen);
206
-
207
204
  return (
208
- <Collapsible
209
- className="min-w-0 py-0.5"
210
- onOpenChange={setUserOpen}
211
- open={open}
212
- >
205
+ <Collapsible className="min-w-0 py-0.5" onOpenChange={setOpen} open={open}>
213
206
  <TranscriptSummaryTrigger
214
207
  flush
215
208
  className={cn(
@@ -1,5 +1,4 @@
1
1
  import { ChevronRight } from "lucide-react";
2
- import { useState } from "react";
3
2
 
4
3
  import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
5
4
  import { cn, dashboardDisclosureChevronClass } from "../styles";
@@ -9,6 +8,7 @@ import { messageRawText } from "./transcriptRenderModel";
9
8
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
10
9
  import { RedactedMarker } from "./TranscriptRedacted";
11
10
  import type { TranscriptViewMessage } from "../types";
11
+ import { useTranscriptRowOpen } from "./TranscriptRows";
12
12
  import { TranscriptSummaryTrigger } from "./TranscriptSummary";
13
13
 
14
14
  function EventTypeChip(props: { eventType: string }) {
@@ -28,9 +28,13 @@ function batchSummary(events: readonly { label?: string }[]): string {
28
28
  }
29
29
 
30
30
  /** Render an Event Message whose heading expands its full text. */
31
- export function TranscriptEventView(props: { message: TranscriptViewMessage }) {
31
+ export function TranscriptEventView(props: {
32
+ message: TranscriptViewMessage;
33
+ /** Keeps the text open while the row unmounts outside the viewport. */
34
+ openKey?: string;
35
+ }) {
32
36
  const { active: searchActive } = useTranscriptSearch();
33
- const [open, setOpen] = useState(false);
37
+ const [open, setOpen] = useTranscriptRowOpen(props.openKey);
34
38
  const text = messageRawText(props.message);
35
39
  const redacted = props.message.parts.some(
36
40
  (part) => part.type === "text" && part.redacted,
@@ -1,4 +1,3 @@
1
- import { useState } from "react";
2
1
  import { Lightbulb } from "lucide-react";
3
2
 
4
3
  import {
@@ -13,15 +12,18 @@ import {
13
12
  TranscriptHeadingMeta,
14
13
  TranscriptHeadingRow,
15
14
  } from "./TranscriptHeadingRow";
15
+ import { useTranscriptRowOpen } from "./TranscriptRows";
16
16
  import { useTranscriptSearch } from "./transcriptSearch";
17
17
 
18
18
  /** Render reasoning collapsed with a short preview until expanded or searched. */
19
19
  export function TranscriptReasoningView(props: {
20
+ /** Keeps the entry open while it unmounts outside the viewport. */
21
+ openKey?: string;
20
22
  part: TranscriptViewReasoningPart;
21
23
  timestamp?: number;
22
24
  }) {
23
25
  const { active: searchActive } = useTranscriptSearch();
24
- const [open, setOpen] = useState(false);
26
+ const [open, setOpen] = useTranscriptRowOpen(props.openKey);
25
27
  const expanded = searchActive || open;
26
28
  const rendered = props.part.redacted ? "Reasoning redacted" : props.part.text;
27
29
  const timestamp = formatMessageTimestamp(props.timestamp);