@sentry/junior-dashboard 0.266.0 → 0.267.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 (73) hide show
  1. package/dist/app.js +49 -12
  2. package/dist/client.js +94 -63
  3. package/dist/index.js +49 -12
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -5
  6. package/src/app.ts +0 -2
  7. package/src/auth.ts +4 -7
  8. package/src/client/App.tsx +35 -53
  9. package/src/client/components/DetailsPanel.tsx +12 -13
  10. package/src/client/components/Drawer.tsx +46 -23
  11. package/src/client/components/ImageAttachment.tsx +1 -1
  12. package/src/client/components/ParticipantAvatarStack.tsx +4 -1
  13. package/src/client/components/README.md +11 -2
  14. package/src/client/components/ui/alert.tsx +1 -1
  15. package/src/client/components/ui/button-group.tsx +59 -0
  16. package/src/client/components/ui/dialog.tsx +29 -35
  17. package/src/client/components/ui/drawer.tsx +140 -0
  18. package/src/client/components/ui/empty.tsx +30 -1
  19. package/src/client/components/ui/separator.tsx +22 -0
  20. package/src/client/components/ui/useSheet.ts +5 -1
  21. package/src/client/conversations/ComposerImages.tsx +66 -14
  22. package/src/client/conversations/ConversationComposer.tsx +84 -12
  23. package/src/client/conversations/ConversationDetails.tsx +3 -27
  24. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  25. package/src/client/conversations/ConversationPage.tsx +156 -2
  26. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  27. package/src/client/conversations/DetailsList.tsx +33 -0
  28. package/src/client/conversations/EventDetails.tsx +46 -19
  29. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  30. package/src/client/conversations/PendingMailboxStack.tsx +310 -112
  31. package/src/client/conversations/README.md +30 -13
  32. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +22 -19
  34. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  35. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  36. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  37. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  38. package/src/client/conversations/TranscriptMessageView.tsx +7 -1
  39. package/src/client/conversations/TranscriptSummary.tsx +5 -31
  40. package/src/client/conversations/TranscriptTurnContextView.tsx +33 -18
  41. package/src/client/conversations/TranscriptView.tsx +7 -3
  42. package/src/client/conversations/UserInputRequestCard.tsx +244 -0
  43. package/src/client/conversations/conversationOutbox.ts +46 -0
  44. package/src/client/conversations/eventTime.ts +13 -1
  45. package/src/client/conversations/eventTranscript.ts +12 -5
  46. package/src/client/conversations/queries.ts +127 -26
  47. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  48. package/src/client/conversations/userInputRequest.ts +56 -0
  49. package/src/client/format.ts +10 -3
  50. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  51. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  52. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  53. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  54. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  55. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  56. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  57. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  58. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  59. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  60. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  61. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  62. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  63. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  64. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  65. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  66. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  67. package/src/client/pages/automations/README.md +56 -46
  68. package/src/client/types.ts +3 -0
  69. package/src/mock-reporting/fixtures.ts +44 -2
  70. package/src/client/conversations/transcriptStyles.ts +0 -10
  71. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  72. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  73. package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
@@ -6,47 +6,91 @@ import {
6
6
  type ReactElement,
7
7
  type ReactNode,
8
8
  } from "react";
9
+ import { INPUT_IMAGE_TYPES } from "@sentry/junior/api/schema";
9
10
  import {
10
11
  AlertCircle,
11
12
  Clock3,
13
+ FileText,
14
+ Pencil,
12
15
  SkipForward,
13
- X,
16
+ Trash2,
17
+ Zap,
14
18
  type LucideIcon,
15
19
  } from "lucide-react";
16
20
 
21
+ import { Button } from "../components/ui/button";
17
22
  import {
18
23
  Tooltip,
19
24
  TooltipContent,
20
25
  TooltipTrigger,
21
26
  } from "../components/ui/tooltip";
27
+ import { ActorAvatar } from "../components/ActorAvatar";
28
+ import { ImageAttachment } from "../components/ImageAttachment";
29
+ import { ParticipantAvatar } from "../components/ParticipantAvatarStack";
22
30
  import { Spinner } from "../components/ui/spinner";
23
- import { actorLabel } from "../format";
24
31
  import type { ConversationMailboxMessage } from "./conversationOutbox";
32
+ import {
33
+ conversationAttachmentUrl,
34
+ inputImageSrc,
35
+ } from "./TranscriptAttachment";
25
36
  import {
26
37
  TranscriptHeadingMeta,
27
38
  TranscriptHeadingRow,
28
39
  } from "./TranscriptHeadingRow";
29
40
  import { SlackMark } from "./SlackMark";
30
41
 
31
- const MAX_EXPANDED_PENDING_ROWS = 3;
32
- const COLLAPSED_PENDING_ROW_COUNT = 2;
42
+ // Rows are one line each, so the stack can show five before it collapses.
43
+ const MAX_EXPANDED_PENDING_ROWS = 5;
44
+ const COLLAPSED_PENDING_ROW_COUNT = 4;
45
+
46
+ // The steer button and the steered status share an icon, so a steered row
47
+ // shows the icon of the action that the reader used.
48
+ const STEER_ICON = Zap;
33
49
 
34
50
  function pendingDeliveryMeta(
35
- message: Pick<ConversationMailboxMessage, "clientStatus" | "delivery">,
36
- ): { icon: LucideIcon | typeof Spinner; label: string } {
51
+ message: Pick<
52
+ ConversationMailboxMessage,
53
+ "clientStatus" | "delivery" | "source"
54
+ >,
55
+ ): { icon: LucideIcon | typeof Spinner; label: string; tooltip: string } {
37
56
  if (message.clientStatus === "failed") {
38
- return { icon: AlertCircle, label: "Failed to send" };
57
+ return {
58
+ icon: AlertCircle,
59
+ label: "Failed to send",
60
+ tooltip: "Failed to send",
61
+ };
39
62
  }
40
63
  if (message.clientStatus === "sending") {
41
- return { icon: Spinner, label: "Sending" };
64
+ return { icon: Spinner, label: "Sending", tooltip: "Sending" };
65
+ }
66
+ // Only a dashboard steer turns a web message into an interrupt. Other
67
+ // sources, such as a Slack mention, arrive as interrupts.
68
+ if (message.delivery === "interrupt" && message.source !== "web") {
69
+ return {
70
+ icon: SkipForward,
71
+ label: "Next step",
72
+ tooltip: "Junior reads it at its next step",
73
+ };
42
74
  }
43
75
  if (message.delivery === "interrupt") {
44
- return { icon: SkipForward, label: "Interrupt" };
76
+ return {
77
+ icon: STEER_ICON,
78
+ label: "Steered",
79
+ tooltip: "Steered: Junior reads it at its next step",
80
+ };
45
81
  }
46
- return { icon: Clock3, label: "Queued" };
82
+ return {
83
+ icon: Clock3,
84
+ label: "Queued",
85
+ tooltip: "Queued: Junior reads it after the current reply",
86
+ };
47
87
  }
48
88
 
49
- function PendingMetaIcon(props: { children: ReactElement; label: string }) {
89
+ function PendingMetaIcon(props: {
90
+ children: ReactElement;
91
+ label: string;
92
+ tooltip?: string;
93
+ }) {
50
94
  return (
51
95
  <Tooltip>
52
96
  <TooltipTrigger
@@ -59,24 +103,69 @@ function PendingMetaIcon(props: { children: ReactElement; label: string }) {
59
103
  </span>
60
104
  }
61
105
  />
62
- <TooltipContent>{props.label}</TooltipContent>
106
+ <TooltipContent size="compact">
107
+ {props.tooltip ?? props.label}
108
+ </TooltipContent>
109
+ </Tooltip>
110
+ );
111
+ }
112
+
113
+ /** One icon-only row action. The tooltip says what the action does. */
114
+ function PendingActionButton(props: {
115
+ disabled: boolean;
116
+ icon: LucideIcon;
117
+ label: string;
118
+ onClick(): void;
119
+ pending: boolean;
120
+ tooltip: string;
121
+ variant?: "danger";
122
+ }) {
123
+ const Icon = props.icon;
124
+ return (
125
+ <Tooltip>
126
+ <TooltipTrigger
127
+ render={
128
+ <Button
129
+ aria-label={props.label}
130
+ className="size-6"
131
+ disabled={props.disabled}
132
+ // A disabled action still shows its tooltip on hover and focus.
133
+ focusableWhenDisabled
134
+ onClick={props.onClick}
135
+ size="icon"
136
+ type="button"
137
+ variant={props.variant}
138
+ >
139
+ {props.pending ? (
140
+ <Spinner aria-hidden="true" className="size-3.25" />
141
+ ) : (
142
+ <Icon aria-hidden="true" size={14} strokeWidth={2.2} />
143
+ )}
144
+ </Button>
145
+ }
146
+ />
147
+ <TooltipContent size="compact">{props.tooltip}</TooltipContent>
63
148
  </Tooltip>
64
149
  );
65
150
  }
66
151
 
67
152
  function PendingMetaIcons(props: {
68
- cancelDisabled: boolean;
153
+ actionsDisabled: boolean;
69
154
  cancelError: boolean;
70
155
  cancelPending: boolean;
156
+ editPending: boolean;
71
157
  message: ConversationMailboxMessage;
72
158
  onCancel?: () => void;
159
+ onEdit?: () => void;
160
+ onSteer?: () => void;
73
161
  showSlack: boolean;
162
+ steerPending: boolean;
74
163
  }) {
75
164
  const delivery = pendingDeliveryMeta(props.message);
76
165
  const DeliveryIcon = delivery.icon;
77
166
 
78
167
  return (
79
- <TranscriptHeadingMeta className="flex min-w-0 items-center justify-end gap-1.5 text-xs leading-none text-dashboard-text-muted">
168
+ <TranscriptHeadingMeta className="flex min-w-0 items-center justify-end gap-1 text-xs leading-none text-dashboard-text-muted">
80
169
  {props.showSlack ? (
81
170
  <Tooltip>
82
171
  <TooltipTrigger
@@ -86,65 +175,138 @@ function PendingMetaIcons(props: {
86
175
  </span>
87
176
  }
88
177
  />
89
- <TooltipContent>Slack</TooltipContent>
178
+ <TooltipContent size="compact">From Slack</TooltipContent>
90
179
  </Tooltip>
91
180
  ) : null}
92
- <PendingMetaIcon label={delivery.label}>
181
+ <PendingMetaIcon label={delivery.label} tooltip={delivery.tooltip}>
93
182
  <DeliveryIcon
94
183
  aria-hidden="true"
95
184
  className="size-3.25"
96
185
  strokeWidth={2.2}
97
186
  />
98
187
  </PendingMetaIcon>
188
+ {props.onSteer ? (
189
+ <PendingActionButton
190
+ disabled={props.actionsDisabled}
191
+ icon={STEER_ICON}
192
+ label="Steer queued message"
193
+ onClick={props.onSteer}
194
+ pending={props.steerPending}
195
+ tooltip="Steer: Junior reads it at its next step"
196
+ />
197
+ ) : null}
198
+ {props.onEdit ? (
199
+ <PendingActionButton
200
+ disabled={props.actionsDisabled}
201
+ icon={Pencil}
202
+ label="Edit queued message"
203
+ onClick={props.onEdit}
204
+ pending={props.editPending}
205
+ tooltip="Edit: move it back to the composer"
206
+ />
207
+ ) : null}
99
208
  {props.onCancel ? (
100
- <Tooltip>
101
- <TooltipTrigger
102
- render={
103
- <button
104
- aria-label={
105
- props.cancelError
106
- ? "Could not remove. Try again."
107
- : "Remove queued message"
108
- }
109
- className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-dashboard-text-muted transition-colors hover:bg-dashboard-fill-hover hover:text-amber-50 focus-visible:outline focus-visible:outline-1 focus-visible:outline-amber-200/55 disabled:cursor-default disabled:opacity-50"
110
- disabled={props.cancelDisabled}
111
- onClick={props.onCancel}
112
- type="button"
113
- >
114
- {props.cancelPending ? (
115
- <Spinner aria-hidden="true" className="size-3.25" />
116
- ) : (
117
- <X aria-hidden="true" size={14} strokeWidth={2.2} />
118
- )}
119
- </button>
120
- }
121
- />
122
- <TooltipContent>Remove queued message</TooltipContent>
123
- </Tooltip>
209
+ <PendingActionButton
210
+ disabled={props.actionsDisabled}
211
+ icon={Trash2}
212
+ label={
213
+ props.cancelError
214
+ ? "Could not delete. Try again."
215
+ : "Delete queued message"
216
+ }
217
+ onClick={props.onCancel}
218
+ pending={props.cancelPending}
219
+ tooltip={
220
+ props.cancelError
221
+ ? "Could not delete. Try again."
222
+ : "Delete from the queue"
223
+ }
224
+ variant="danger"
225
+ />
124
226
  ) : null}
125
227
  </TranscriptHeadingMeta>
126
228
  );
127
229
  }
128
230
 
231
+ function PendingAuthorAvatar(props: { message: ConversationMailboxMessage }) {
232
+ // Pending rows can come from web or Slack. Redacted rows hide the author.
233
+ if (!props.message.actorIdentity) {
234
+ return <ActorAvatar name="?" size="list" />;
235
+ }
236
+ return (
237
+ <ParticipantAvatar participant={props.message.actorIdentity} size="list" />
238
+ );
239
+ }
240
+
241
+ /** Show small previews so the reader can tell which images a queued message has. */
242
+ function PendingAttachmentThumbnails(props: {
243
+ conversationId?: string;
244
+ message: ConversationMailboxMessage;
245
+ }) {
246
+ const thumbnailClassName = "size-6 rounded object-cover";
247
+ const triggerClassName =
248
+ "block size-6 flex-none overflow-hidden rounded border border-dashboard-border bg-dashboard-fill-faint transition-colors hover:border-dashboard-border-interactive focus-visible:outline-2 focus-visible:outline-dashboard-focus";
249
+ const localImages = props.message.images ?? [];
250
+ const attachments = props.message.attachments ?? [];
251
+ if (localImages.length === 0 && attachments.length === 0) return null;
252
+
253
+ return (
254
+ <span className="flex flex-none items-center gap-1">
255
+ {localImages.map((image, index) => (
256
+ <ImageAttachment
257
+ filename={image.filename}
258
+ imageClassName={thumbnailClassName}
259
+ key={`local:${index}`}
260
+ src={inputImageSrc(image)}
261
+ triggerClassName={triggerClassName}
262
+ />
263
+ ))}
264
+ {attachments.map((attachment) => {
265
+ const isImage = INPUT_IMAGE_TYPES.some(
266
+ (type) => type === attachment.contentType,
267
+ );
268
+ if (isImage && props.conversationId) {
269
+ return (
270
+ <ImageAttachment
271
+ filename={attachment.filename}
272
+ imageClassName={thumbnailClassName}
273
+ key={attachment.id}
274
+ loading="lazy"
275
+ src={conversationAttachmentUrl(
276
+ props.conversationId,
277
+ attachment.id,
278
+ )}
279
+ triggerClassName={triggerClassName}
280
+ />
281
+ );
282
+ }
283
+ return (
284
+ <PendingMetaIcon key={attachment.id} label={attachment.filename}>
285
+ <FileText aria-hidden="true" size={14} />
286
+ </PendingMetaIcon>
287
+ );
288
+ })}
289
+ </span>
290
+ );
291
+ }
292
+
129
293
  function PendingRow(props: {
130
- cancelDisabled: boolean;
294
+ actionsDisabled: boolean;
131
295
  cancelError: boolean;
132
296
  cancelPending: boolean;
297
+ canEdit: boolean;
298
+ canSteer: boolean;
299
+ conversationId?: string;
300
+ editPending: boolean;
133
301
  message: ConversationMailboxMessage;
134
302
  onCancel?(message: ConversationMailboxMessage): void;
303
+ onEdit?(message: ConversationMailboxMessage): void;
135
304
  onRetry?(message: ConversationMailboxMessage): void;
305
+ onSteer?(message: ConversationMailboxMessage): void;
306
+ steerPending: boolean;
136
307
  }) {
137
- const filenames = [
138
- ...(props.message.images ?? []),
139
- ...(props.message.attachments ?? []),
140
- ]
141
- .map((attachment) => attachment.filename)
142
- .join(", ");
143
- const text = [props.message.text, filenames].filter(Boolean).join(" · ");
308
+ const text = props.message.text ?? "";
144
309
  const redacted = Boolean(props.message.redacted);
145
- // Pending rows can come from web or Slack. Avoid rebuilding a full
146
- // transcript projection just to label the stack above the composer.
147
- const roleLabel = actorLabel(props.message.actorIdentity) ?? "User";
148
310
  const showSlack = props.message.source === "slack";
149
311
  const canRetry =
150
312
  props.message.clientStatus === "failed" &&
@@ -152,36 +314,59 @@ function PendingRow(props: {
152
314
  Boolean(props.onRetry);
153
315
 
154
316
  return (
155
- <article className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1 px-3 py-2 text-dashboard-text md:px-3.5">
317
+ <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">
156
318
  <TranscriptHeadingRow
319
+ className="items-center"
157
320
  left={
158
- <span className="inline-block max-w-full truncate font-sans text-sm font-semibold leading-tight text-dashboard-text">
159
- {roleLabel}
160
- </span>
321
+ <>
322
+ <PendingAuthorAvatar message={props.message} />
323
+ {redacted ? null : (
324
+ <PendingAttachmentThumbnails
325
+ conversationId={props.conversationId}
326
+ message={props.message}
327
+ />
328
+ )}
329
+ {redacted ? (
330
+ <p className="m-0 font-sans text-sm leading-snug text-dashboard-text-muted">
331
+ [redacted]
332
+ </p>
333
+ ) : text ? (
334
+ // One line keeps the queue short. The title shows the full text.
335
+ <p
336
+ className="m-0 min-w-0 truncate font-sans text-sm leading-snug text-dashboard-text-subtle"
337
+ title={text}
338
+ >
339
+ {text}
340
+ </p>
341
+ ) : null}
342
+ </>
161
343
  }
162
- leftClassName="text-sm leading-snug text-dashboard-text"
344
+ leftClassName="flex-1 text-sm leading-snug text-dashboard-text"
163
345
  right={
164
346
  <PendingMetaIcons
165
- cancelDisabled={props.cancelDisabled}
347
+ actionsDisabled={props.actionsDisabled}
166
348
  cancelError={props.cancelError}
167
349
  cancelPending={props.cancelPending}
350
+ editPending={props.editPending}
168
351
  message={props.message}
169
352
  onCancel={
170
353
  props.onCancel ? () => props.onCancel?.(props.message) : undefined
171
354
  }
355
+ onEdit={
356
+ props.canEdit && props.onEdit
357
+ ? () => props.onEdit?.(props.message)
358
+ : undefined
359
+ }
360
+ onSteer={
361
+ props.canSteer && props.onSteer
362
+ ? () => props.onSteer?.(props.message)
363
+ : undefined
364
+ }
172
365
  showSlack={showSlack}
366
+ steerPending={props.steerPending}
173
367
  />
174
368
  }
175
369
  />
176
- {redacted ? (
177
- <p className="m-0 font-sans text-sm leading-snug text-dashboard-text-muted">
178
- [redacted]
179
- </p>
180
- ) : (
181
- <p className="m-0 line-clamp-3 font-sans text-sm leading-snug text-dashboard-text-subtle">
182
- {text}
183
- </p>
184
- )}
185
370
  {canRetry ? (
186
371
  <div className="flex min-w-0 items-center gap-2">
187
372
  <p className="m-0 font-sans text-xs text-red-300/80">
@@ -198,7 +383,7 @@ function PendingRow(props: {
198
383
  ) : null}
199
384
  {props.cancelError ? (
200
385
  <p className="m-0 font-sans text-xs text-amber-100/75">
201
- Could not remove. Try again.
386
+ Could not delete. Try again.
202
387
  </p>
203
388
  ) : null}
204
389
  </article>
@@ -242,6 +427,12 @@ function ExpandQueuedMessagesButton(props: {
242
427
  );
243
428
  }
244
429
 
430
+ function sameEmail(left: string | undefined, right: string | undefined) {
431
+ return Boolean(
432
+ left && right && left.trim().toLowerCase() === right.trim().toLowerCase(),
433
+ );
434
+ }
435
+
245
436
  /** Render accepted mailbox rows as a compact stack attached above the composer. */
246
437
  export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
247
438
  cancelError?: boolean;
@@ -249,11 +440,23 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
249
440
  cancelTargetInboundMessageId?: string;
250
441
  /** Message ids already present in committed history. */
251
442
  committedMessageIds?: readonly string[];
443
+ /** Conversation that owns stored attachment previews. */
444
+ conversationId?: string;
445
+ /** Inbound id of the row that an edit is taking out of the queue. */
446
+ editTargetInboundMessageId?: string;
252
447
  messages: readonly ConversationMailboxMessage[];
253
448
  onCancelMessage?: (message: ConversationMailboxMessage) => void;
449
+ /** Take the viewer's own queued web message back into the composer. */
450
+ onEditMessage?: (message: ConversationMailboxMessage) => void;
254
451
  /** Fires after expand/collapse changes the stack height above the composer. */
255
452
  onLayoutChange?: () => void;
256
453
  onRetry?(message: ConversationMailboxMessage): void;
454
+ /** Ask Junior to read the viewer's own queued web message now. */
455
+ onSteerMessage?: (message: ConversationMailboxMessage) => void;
456
+ /** Inbound id of the row that a steer is sending now. */
457
+ steerTargetInboundMessageId?: string;
458
+ /** Email of the signed-in reader. Only their own web rows can be edited. */
459
+ viewerEmail?: string;
257
460
  }): ReactNode {
258
461
  const [expanded, setExpanded] = useState(false);
259
462
  const onLayoutChangeRef = useRef(props.onLayoutChange);
@@ -283,6 +486,43 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
283
486
  const visibleRows = showCollapsed ? previewRows : rows;
284
487
  const hiddenCount = Math.max(0, rows.length - COLLAPSED_PENDING_ROW_COUNT);
285
488
  const toggleExpanded = () => setExpanded((value) => !value);
489
+ const actionsDisabled =
490
+ Boolean(props.cancelPending) ||
491
+ Boolean(props.editTargetInboundMessageId) ||
492
+ Boolean(props.steerTargetInboundMessageId);
493
+ const renderRow = (message: ConversationMailboxMessage) => {
494
+ const isTarget =
495
+ props.cancelTargetInboundMessageId === message.inboundMessageId;
496
+ // Only accepted server rows can leave the queue. Local rows are still sending.
497
+ const accepted = message.clientStatus === undefined;
498
+ const ownWebRow =
499
+ accepted &&
500
+ message.source === "web" &&
501
+ !message.redacted &&
502
+ sameEmail(message.actorIdentity?.email, props.viewerEmail);
503
+ return (
504
+ <PendingRow
505
+ actionsDisabled={actionsDisabled}
506
+ cancelError={Boolean(props.cancelError && isTarget)}
507
+ cancelPending={Boolean(props.cancelPending && isTarget)}
508
+ canEdit={ownWebRow}
509
+ canSteer={ownWebRow && message.delivery === "defer"}
510
+ conversationId={props.conversationId}
511
+ editPending={
512
+ props.editTargetInboundMessageId === message.inboundMessageId
513
+ }
514
+ key={message.messageId}
515
+ message={message}
516
+ onCancel={accepted ? props.onCancelMessage : undefined}
517
+ onEdit={props.onEditMessage}
518
+ onRetry={props.onRetry}
519
+ onSteer={props.onSteerMessage}
520
+ steerPending={
521
+ props.steerTargetInboundMessageId === message.inboundMessageId
522
+ }
523
+ />
524
+ );
525
+ };
286
526
 
287
527
  return (
288
528
  <div
@@ -290,52 +530,10 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
290
530
  className="mx-2 overflow-hidden rounded-t-lg bg-amber-300/[0.055] md:mx-3"
291
531
  >
292
532
  {showCollapsed ? (
293
- // Desktop keeps a two-row preview; mobile collapses to the control only.
294
- <div className="hidden md:block">
295
- {previewRows.map((message) => (
296
- <PendingRow
297
- cancelDisabled={Boolean(props.cancelPending)}
298
- cancelError={Boolean(
299
- props.cancelError &&
300
- props.cancelTargetInboundMessageId === message.inboundMessageId,
301
- )}
302
- cancelPending={Boolean(
303
- props.cancelPending &&
304
- props.cancelTargetInboundMessageId === message.inboundMessageId,
305
- )}
306
- key={message.messageId}
307
- message={message}
308
- onCancel={
309
- message.clientStatus === undefined
310
- ? props.onCancelMessage
311
- : undefined
312
- }
313
- onRetry={props.onRetry}
314
- />
315
- ))}
316
- </div>
533
+ // Desktop keeps a short preview; mobile collapses to the control only.
534
+ <div className="hidden md:block">{previewRows.map(renderRow)}</div>
317
535
  ) : (
318
- visibleRows.map((message) => (
319
- <PendingRow
320
- cancelDisabled={Boolean(props.cancelPending)}
321
- cancelError={Boolean(
322
- props.cancelError &&
323
- props.cancelTargetInboundMessageId === message.inboundMessageId,
324
- )}
325
- cancelPending={Boolean(
326
- props.cancelPending &&
327
- props.cancelTargetInboundMessageId === message.inboundMessageId,
328
- )}
329
- key={message.messageId}
330
- message={message}
331
- onCancel={
332
- message.clientStatus === undefined
333
- ? props.onCancelMessage
334
- : undefined
335
- }
336
- onRetry={props.onRetry}
337
- />
338
- ))
536
+ visibleRows.map(renderRow)
339
537
  )}
340
538
  {canCollapse ? (
341
539
  <ExpandQueuedMessagesButton
@@ -12,13 +12,12 @@ shared controls and overlays.
12
12
  `TranscriptMessageHeading` aligns authors, timestamps, and the context action.
13
13
  The action stays visible on touch devices and while open. Mouse users see it
14
14
  on message hover; keyboard users see it on focus.
15
- - `TranscriptSummary` owns disclosure padding, hover, and keyboard focus. Use
16
- `flush` when the text already sits on the transcript edge. It extends the
17
- padded control around that text. Do not repeat negative margins at call sites.
18
- The caller owns the native `details` element and its content.
19
- `TranscriptSummaryTrigger` gives a `ui/collapsible` trigger the same look.
20
- Its caller owns the `Collapsible` and its content. Transcript search holds
21
- event and context rows open.
15
+ - `TranscriptSummaryTrigger` owns disclosure padding, hover, and keyboard
16
+ focus for a `ui/collapsible` trigger. Use `flush` when the text already sits
17
+ on the transcript edge. It extends the padded control around that text. Do
18
+ not repeat negative margins at call sites. The caller owns the `Collapsible`
19
+ and its content. Transcript search holds event and context rows open, and
20
+ shows activity group rows without the group.
22
21
  - Activity rows (tools, reasoning, and grouped rail events) share one
23
22
  layout: a 1rem icon column, an 8px gap, then the title and metadata.
24
23
  Tool and reasoning rows use `ui/trace`. Transcript search forces them open.
@@ -114,9 +113,12 @@ The timezone toggle uses `useEventTime` to share a UTC/local preference through
114
113
  `@uidotdev/usehooks` and browser local storage. It applies to event row clocks
115
114
  and detail timestamps. Local mode follows the browser timezone. The button uses
116
115
  a generic zone name because a log can span standard and daylight-saving time.
117
- Detail timestamps use the zone name for each event date. The mode, not
118
- the timezone identifier, persists across visits and tabs. This hook belongs to
119
- the client-rendered event viewer. Raw JSON retains the original ISO values.
116
+ Detail timestamps use a readable date to the second and the zone name for each
117
+ event date, in both modes. The header keeps them on one line beside the
118
+ actions. Their tooltip adds milliseconds, and the `time` element keeps the
119
+ original ISO value. The mode, not the timezone identifier, persists across
120
+ visits and tabs. This hook belongs to the client-rendered event viewer. Raw
121
+ JSON retains the original ISO values.
120
122
 
121
123
  ## Event details
122
124
 
@@ -125,10 +127,14 @@ places `ConversationEventDetails` beside the log in the page panel. Selecting
125
127
  another row changes the event and resets its scroll position and disclosures.
126
128
  Closing returns focus to that row. Switching Conversations removes the panel.
127
129
 
128
- The previous and next controls select the adjacent visible row. They follow
130
+ The previous and next controls select the adjacent visible row. They form one
131
+ `ui/button-group` named "Event navigation". They follow
129
132
  the event search, like Up and Down. They are necessary below 1280px, where the
130
133
  sheet covers the log. At the ends of the list, the control is disabled but
131
- keeps focus. The sheet title is the event type.
134
+ keeps focus. The sheet title is the event type. The header shows the event
135
+ type, then the sequence number and timestamp. The body groups fields in the
136
+ same `DetailsCard` that Conversation details use. Groups inside a card, such
137
+ as tool input and output, use a divider, not another card.
132
138
 
133
139
  A linked event can be older than the loaded pages. While the event loads, the
134
140
  panel stays open. Only its header shows placeholders, so it does not compete
@@ -137,6 +143,17 @@ the log toolbar, columns, and row skeletons in the event log view.
137
143
 
138
144
  Standalone transcripts, such as a subagent drawer, keep selection in
139
145
  `ConversationEventLog`. Their event details use a nested drawer on desktop and
140
- `BottomSheet` on smaller screens. This keeps the main log free of modal behavior
146
+ `BottomSheet` on smaller screens. The nested drawer stacks in front of the
147
+ subagent drawer. The subagent drawer shows its event log without a body inset,
148
+ so rows meet the drawer edges and the toolbar sticks to the top. This keeps the main log free of modal behavior
141
149
  while a nested transcript retains its parent drawer's focus rules. These event
142
150
  details have the same previous and next controls.
151
+
152
+ `SubagentTranscriptDrawer` uses the wide `Drawer` at all widths. Below 768px
153
+ the drawer fills the screen, so the long transcript gets the most room. Its
154
+ header holds search, view, and copy actions. `BottomSheet` has no place for
155
+ them.
156
+
157
+ `ForkConversationButton` opens a `ui/dialog` form, not a drawer. The dialog
158
+ stays above event details in a docked panel or a sheet. Cancel returns focus
159
+ to the fork button. A pending fork blocks dismissal until the request ends.