@sentry/junior-dashboard 0.265.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 (104) 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/ActorAvatar.tsx +10 -22
  10. package/src/client/components/ConnectionBanner.tsx +4 -3
  11. package/src/client/components/CopyButton.tsx +3 -4
  12. package/src/client/components/DetailsPanel.tsx +12 -13
  13. package/src/client/components/Drawer.tsx +46 -23
  14. package/src/client/components/EmptyTelemetry.tsx +5 -3
  15. package/src/client/components/ImageAttachment.tsx +49 -63
  16. package/src/client/components/InlineError.tsx +8 -4
  17. package/src/client/components/Notice.tsx +19 -26
  18. package/src/client/components/Pagination.tsx +26 -26
  19. package/src/client/components/ParticipantAvatarStack.tsx +13 -10
  20. package/src/client/components/README.md +28 -3
  21. package/src/client/components/SearchInput.tsx +51 -41
  22. package/src/client/components/VersionDriftBanner.tsx +9 -7
  23. package/src/client/components/charts/CacheWrites.tsx +52 -43
  24. package/src/client/components/ui/alert.tsx +64 -0
  25. package/src/client/components/ui/avatar.tsx +53 -0
  26. package/src/client/components/ui/button-group.tsx +59 -0
  27. package/src/client/components/ui/button.tsx +2 -1
  28. package/src/client/components/ui/dialog.tsx +60 -0
  29. package/src/client/components/ui/drawer.tsx +140 -0
  30. package/src/client/components/ui/empty.tsx +61 -0
  31. package/src/client/components/ui/input-group.tsx +96 -0
  32. package/src/client/components/ui/pagination.tsx +60 -0
  33. package/src/client/components/ui/separator.tsx +22 -0
  34. package/src/client/components/ui/spinner.tsx +21 -0
  35. package/src/client/components/ui/table.tsx +22 -1
  36. package/src/client/components/ui/tooltip.tsx +1 -1
  37. package/src/client/components/ui/useSheet.ts +5 -1
  38. package/src/client/conversations/ComposerImages.tsx +66 -14
  39. package/src/client/conversations/ConversationChildAgents.tsx +5 -5
  40. package/src/client/conversations/ConversationComposer.tsx +84 -12
  41. package/src/client/conversations/ConversationDetails.tsx +3 -27
  42. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  43. package/src/client/conversations/ConversationHeader.tsx +5 -4
  44. package/src/client/conversations/ConversationHeaderActions.tsx +7 -11
  45. package/src/client/conversations/ConversationPage.tsx +156 -2
  46. package/src/client/conversations/ConversationSidebar.tsx +5 -4
  47. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  48. package/src/client/conversations/ConversationUsageBreakdown.tsx +108 -65
  49. package/src/client/conversations/ConversationWatches.tsx +67 -59
  50. package/src/client/conversations/DetailsList.tsx +77 -29
  51. package/src/client/conversations/EventDetails.tsx +46 -19
  52. package/src/client/conversations/EventUsage.tsx +99 -74
  53. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  54. package/src/client/conversations/PendingMailboxStack.tsx +312 -119
  55. package/src/client/conversations/README.md +30 -10
  56. package/src/client/conversations/ShareConversationButton.tsx +3 -4
  57. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  58. package/src/client/conversations/TranscriptActivityGroup.tsx +22 -19
  59. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  60. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  61. package/src/client/conversations/TranscriptEventView.tsx +35 -16
  62. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  63. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  64. package/src/client/conversations/TranscriptMessageContextView.tsx +31 -20
  65. package/src/client/conversations/TranscriptMessageView.tsx +7 -1
  66. package/src/client/conversations/TranscriptStructuredEventView.tsx +25 -15
  67. package/src/client/conversations/TranscriptSummary.tsx +13 -7
  68. package/src/client/conversations/TranscriptTurnContextView.tsx +78 -56
  69. package/src/client/conversations/TranscriptView.tsx +7 -3
  70. package/src/client/conversations/UserInputRequestCard.tsx +244 -0
  71. package/src/client/conversations/conversationOutbox.ts +46 -0
  72. package/src/client/conversations/eventLog.ts +1 -0
  73. package/src/client/conversations/eventTime.ts +13 -1
  74. package/src/client/conversations/eventTranscript.ts +12 -5
  75. package/src/client/conversations/queries.ts +127 -26
  76. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  77. package/src/client/conversations/transcriptMentions.tsx +18 -5
  78. package/src/client/conversations/userInputRequest.ts +56 -0
  79. package/src/client/format.ts +10 -3
  80. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  81. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  82. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  83. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  84. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  85. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  86. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  87. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  88. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  89. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  90. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  91. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  92. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  93. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  94. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  95. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  96. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  97. package/src/client/pages/automations/README.md +56 -46
  98. package/src/client/types.ts +3 -0
  99. package/src/mock-reporting/fixtures.ts +44 -2
  100. package/src/mock-reporting/watches.ts +1 -1
  101. package/src/client/conversations/transcriptStyles.ts +0 -10
  102. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  103. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  104. 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,
12
- LoaderCircle,
13
+ FileText,
14
+ Pencil,
13
15
  SkipForward,
14
- X,
16
+ Trash2,
17
+ Zap,
15
18
  type LucideIcon,
16
19
  } from "lucide-react";
17
20
 
21
+ import { Button } from "../components/ui/button";
18
22
  import {
19
23
  Tooltip,
20
24
  TooltipContent,
21
25
  TooltipTrigger,
22
26
  } from "../components/ui/tooltip";
23
- import { actorLabel } from "../format";
27
+ import { ActorAvatar } from "../components/ActorAvatar";
28
+ import { ImageAttachment } from "../components/ImageAttachment";
29
+ import { ParticipantAvatar } from "../components/ParticipantAvatarStack";
30
+ import { Spinner } from "../components/ui/spinner";
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; label: string; spin?: boolean } {
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: LoaderCircle, label: "Sending", spin: true };
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,70 +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
- className={delivery.spin ? "animate-spin" : undefined}
96
- size={13}
184
+ className="size-3.25"
97
185
  strokeWidth={2.2}
98
186
  />
99
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}
100
208
  {props.onCancel ? (
101
- <Tooltip>
102
- <TooltipTrigger
103
- render={
104
- <button
105
- aria-label={
106
- props.cancelError
107
- ? "Could not remove. Try again."
108
- : "Remove queued message"
109
- }
110
- 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"
111
- disabled={props.cancelDisabled}
112
- onClick={props.onCancel}
113
- type="button"
114
- >
115
- {props.cancelPending ? (
116
- <LoaderCircle
117
- aria-hidden="true"
118
- className="animate-spin"
119
- size={13}
120
- />
121
- ) : (
122
- <X aria-hidden="true" size={14} strokeWidth={2.2} />
123
- )}
124
- </button>
125
- }
126
- />
127
- <TooltipContent>Remove queued message</TooltipContent>
128
- </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
+ />
129
226
  ) : null}
130
227
  </TranscriptHeadingMeta>
131
228
  );
132
229
  }
133
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
+
134
293
  function PendingRow(props: {
135
- cancelDisabled: boolean;
294
+ actionsDisabled: boolean;
136
295
  cancelError: boolean;
137
296
  cancelPending: boolean;
297
+ canEdit: boolean;
298
+ canSteer: boolean;
299
+ conversationId?: string;
300
+ editPending: boolean;
138
301
  message: ConversationMailboxMessage;
139
302
  onCancel?(message: ConversationMailboxMessage): void;
303
+ onEdit?(message: ConversationMailboxMessage): void;
140
304
  onRetry?(message: ConversationMailboxMessage): void;
305
+ onSteer?(message: ConversationMailboxMessage): void;
306
+ steerPending: boolean;
141
307
  }) {
142
- const filenames = [
143
- ...(props.message.images ?? []),
144
- ...(props.message.attachments ?? []),
145
- ]
146
- .map((attachment) => attachment.filename)
147
- .join(", ");
148
- const text = [props.message.text, filenames].filter(Boolean).join(" · ");
308
+ const text = props.message.text ?? "";
149
309
  const redacted = Boolean(props.message.redacted);
150
- // Pending rows can come from web or Slack. Avoid rebuilding a full
151
- // transcript projection just to label the stack above the composer.
152
- const roleLabel = actorLabel(props.message.actorIdentity) ?? "User";
153
310
  const showSlack = props.message.source === "slack";
154
311
  const canRetry =
155
312
  props.message.clientStatus === "failed" &&
@@ -157,36 +314,59 @@ function PendingRow(props: {
157
314
  Boolean(props.onRetry);
158
315
 
159
316
  return (
160
- <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">
161
318
  <TranscriptHeadingRow
319
+ className="items-center"
162
320
  left={
163
- <span className="inline-block max-w-full truncate font-sans text-sm font-semibold leading-tight text-dashboard-text">
164
- {roleLabel}
165
- </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
+ </>
166
343
  }
167
- leftClassName="text-sm leading-snug text-dashboard-text"
344
+ leftClassName="flex-1 text-sm leading-snug text-dashboard-text"
168
345
  right={
169
346
  <PendingMetaIcons
170
- cancelDisabled={props.cancelDisabled}
347
+ actionsDisabled={props.actionsDisabled}
171
348
  cancelError={props.cancelError}
172
349
  cancelPending={props.cancelPending}
350
+ editPending={props.editPending}
173
351
  message={props.message}
174
352
  onCancel={
175
353
  props.onCancel ? () => props.onCancel?.(props.message) : undefined
176
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
+ }
177
365
  showSlack={showSlack}
366
+ steerPending={props.steerPending}
178
367
  />
179
368
  }
180
369
  />
181
- {redacted ? (
182
- <p className="m-0 font-sans text-sm leading-snug text-dashboard-text-muted">
183
- [redacted]
184
- </p>
185
- ) : (
186
- <p className="m-0 line-clamp-3 font-sans text-sm leading-snug text-dashboard-text-subtle">
187
- {text}
188
- </p>
189
- )}
190
370
  {canRetry ? (
191
371
  <div className="flex min-w-0 items-center gap-2">
192
372
  <p className="m-0 font-sans text-xs text-red-300/80">
@@ -203,7 +383,7 @@ function PendingRow(props: {
203
383
  ) : null}
204
384
  {props.cancelError ? (
205
385
  <p className="m-0 font-sans text-xs text-amber-100/75">
206
- Could not remove. Try again.
386
+ Could not delete. Try again.
207
387
  </p>
208
388
  ) : null}
209
389
  </article>
@@ -247,6 +427,12 @@ function ExpandQueuedMessagesButton(props: {
247
427
  );
248
428
  }
249
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
+
250
436
  /** Render accepted mailbox rows as a compact stack attached above the composer. */
251
437
  export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
252
438
  cancelError?: boolean;
@@ -254,11 +440,23 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
254
440
  cancelTargetInboundMessageId?: string;
255
441
  /** Message ids already present in committed history. */
256
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;
257
447
  messages: readonly ConversationMailboxMessage[];
258
448
  onCancelMessage?: (message: ConversationMailboxMessage) => void;
449
+ /** Take the viewer's own queued web message back into the composer. */
450
+ onEditMessage?: (message: ConversationMailboxMessage) => void;
259
451
  /** Fires after expand/collapse changes the stack height above the composer. */
260
452
  onLayoutChange?: () => void;
261
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;
262
460
  }): ReactNode {
263
461
  const [expanded, setExpanded] = useState(false);
264
462
  const onLayoutChangeRef = useRef(props.onLayoutChange);
@@ -288,6 +486,43 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
288
486
  const visibleRows = showCollapsed ? previewRows : rows;
289
487
  const hiddenCount = Math.max(0, rows.length - COLLAPSED_PENDING_ROW_COUNT);
290
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
+ };
291
526
 
292
527
  return (
293
528
  <div
@@ -295,52 +530,10 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
295
530
  className="mx-2 overflow-hidden rounded-t-lg bg-amber-300/[0.055] md:mx-3"
296
531
  >
297
532
  {showCollapsed ? (
298
- // Desktop keeps a two-row preview; mobile collapses to the control only.
299
- <div className="hidden md:block">
300
- {previewRows.map((message) => (
301
- <PendingRow
302
- cancelDisabled={Boolean(props.cancelPending)}
303
- cancelError={Boolean(
304
- props.cancelError &&
305
- props.cancelTargetInboundMessageId === message.inboundMessageId,
306
- )}
307
- cancelPending={Boolean(
308
- props.cancelPending &&
309
- props.cancelTargetInboundMessageId === message.inboundMessageId,
310
- )}
311
- key={message.messageId}
312
- message={message}
313
- onCancel={
314
- message.clientStatus === undefined
315
- ? props.onCancelMessage
316
- : undefined
317
- }
318
- onRetry={props.onRetry}
319
- />
320
- ))}
321
- </div>
533
+ // Desktop keeps a short preview; mobile collapses to the control only.
534
+ <div className="hidden md:block">{previewRows.map(renderRow)}</div>
322
535
  ) : (
323
- visibleRows.map((message) => (
324
- <PendingRow
325
- cancelDisabled={Boolean(props.cancelPending)}
326
- cancelError={Boolean(
327
- props.cancelError &&
328
- props.cancelTargetInboundMessageId === message.inboundMessageId,
329
- )}
330
- cancelPending={Boolean(
331
- props.cancelPending &&
332
- props.cancelTargetInboundMessageId === message.inboundMessageId,
333
- )}
334
- key={message.messageId}
335
- message={message}
336
- onCancel={
337
- message.clientStatus === undefined
338
- ? props.onCancelMessage
339
- : undefined
340
- }
341
- onRetry={props.onRetry}
342
- />
343
- ))
536
+ visibleRows.map(renderRow)
344
537
  )}
345
538
  {canCollapse ? (
346
539
  <ExpandQueuedMessagesButton
@@ -12,10 +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.
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.
19
21
  - Activity rows (tools, reasoning, and grouped rail events) share one
20
22
  layout: a 1rem icon column, an 8px gap, then the title and metadata.
21
23
  Tool and reasoning rows use `ui/trace`. Transcript search forces them open.
@@ -111,9 +113,12 @@ The timezone toggle uses `useEventTime` to share a UTC/local preference through
111
113
  `@uidotdev/usehooks` and browser local storage. It applies to event row clocks
112
114
  and detail timestamps. Local mode follows the browser timezone. The button uses
113
115
  a generic zone name because a log can span standard and daylight-saving time.
114
- Detail timestamps use the zone name for each event date. The mode, not
115
- the timezone identifier, persists across visits and tabs. This hook belongs to
116
- 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.
117
122
 
118
123
  ## Event details
119
124
 
@@ -122,10 +127,14 @@ places `ConversationEventDetails` beside the log in the page panel. Selecting
122
127
  another row changes the event and resets its scroll position and disclosures.
123
128
  Closing returns focus to that row. Switching Conversations removes the panel.
124
129
 
125
- 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
126
132
  the event search, like Up and Down. They are necessary below 1280px, where the
127
133
  sheet covers the log. At the ends of the list, the control is disabled but
128
- 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.
129
138
 
130
139
  A linked event can be older than the loaded pages. While the event loads, the
131
140
  panel stays open. Only its header shows placeholders, so it does not compete
@@ -134,6 +143,17 @@ the log toolbar, columns, and row skeletons in the event log view.
134
143
 
135
144
  Standalone transcripts, such as a subagent drawer, keep selection in
136
145
  `ConversationEventLog`. Their event details use a nested drawer on desktop and
137
- `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
138
149
  while a nested transcript retains its parent drawer's focus rules. These event
139
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.
@@ -96,15 +96,14 @@ export function ShareConversationButton(props: {
96
96
  </TooltipContent>
97
97
  </Tooltip>
98
98
  ) : props.layout === "menu" ? (
99
- <button
99
+ <Button
100
100
  aria-label={label}
101
- className="flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-sm font-semibold text-dashboard-text transition-colors hover:bg-dashboard-fill-hover focus-visible:bg-dashboard-fill-hover focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
102
101
  disabled={status === "sharing"}
103
102
  onClick={() => void shareLink()}
104
- type="button"
103
+ size="menu"
105
104
  >
106
105
  {content}
107
- </button>
106
+ </Button>
108
107
  ) : (
109
108
  <Button
110
109
  aria-label={label}