@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
@@ -1,5 +1,8 @@
1
1
  import { ForkConversationButton } from "./ForkConversationButton";
2
- import { TranscriptAttachments } from "./TranscriptAttachment";
2
+ import {
3
+ TranscriptAttachments,
4
+ TranscriptLocalImages,
5
+ } from "./TranscriptAttachment";
3
6
  import { ObjectCard } from "./ObjectCard";
4
7
  import { AutomationCard } from "../components/AutomationCard";
5
8
  import { memo, type ReactNode } from "react";
@@ -69,6 +72,9 @@ export const TranscriptMessageView = memo(
69
72
  conversationId={props.conversation.conversationId}
70
73
  />
71
74
  ) : null}
75
+ {props.message.images?.length ? (
76
+ <TranscriptLocalImages images={props.message.images} />
77
+ ) : null}
72
78
  {props.message.cards?.map((card) => {
73
79
  switch (card.kind) {
74
80
  case "object":
@@ -1,37 +1,12 @@
1
- import type { ComponentProps, ComponentPropsWithoutRef } from "react";
1
+ import type { ComponentProps } from "react";
2
2
 
3
3
  import { CollapsibleTrigger } from "../components/ui/collapsible";
4
4
  import { cn } from "../styles";
5
5
 
6
- const summaryClass =
7
- "cursor-pointer rounded-md px-2 py-1.5 transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus";
8
-
9
- /** Keep hover and keyboard focus around a padded transcript cell. */
10
- export function TranscriptSummary({
11
- className,
12
- flush = false,
13
- ...props
14
- }: ComponentPropsWithoutRef<"summary"> & {
15
- /** Extend padding around text that already sits on the transcript edge. */
16
- flush?: boolean;
17
- }) {
18
- return (
19
- <summary
20
- {...props}
21
- className={cn(
22
- summaryClass,
23
- "list-none [&::-webkit-details-marker]:hidden",
24
- flush && "-mx-2 -my-1.5",
25
- className,
26
- )}
27
- />
28
- );
29
- }
30
-
31
6
  /**
32
- * Give a `Collapsible` trigger the same padding, hover, and focus as
33
- * `TranscriptSummary`. The caller sets the display. Put only inline elements,
34
- * such as `span`, inside the trigger.
7
+ * Give a `Collapsible` trigger the padding, hover, and keyboard focus of a
8
+ * transcript disclosure. The caller sets the display. Put only inline
9
+ * elements, such as `span`, inside the trigger.
35
10
  */
36
11
  export function TranscriptSummaryTrigger({
37
12
  className,
@@ -46,8 +21,7 @@ export function TranscriptSummaryTrigger({
46
21
  <CollapsibleTrigger
47
22
  {...props}
48
23
  className={cn(
49
- summaryClass,
50
- "text-left disabled:cursor-default disabled:hover:bg-transparent",
24
+ "cursor-pointer rounded-md px-2 py-1.5 text-left transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus disabled:cursor-default disabled:hover:bg-transparent",
51
25
  flush ? "-mx-2 -my-1.5 w-[calc(100%+1rem)]" : "w-full",
52
26
  className,
53
27
  )}
@@ -9,8 +9,14 @@ import {
9
9
  import { createPortal } from "react-dom";
10
10
 
11
11
  import { DetailsPanel } from "../components/DetailsPanel";
12
+ import { Button } from "../components/ui/button";
12
13
  import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
13
14
  import { Drawer } from "../components/Drawer";
15
+ import {
16
+ Tooltip,
17
+ TooltipContent,
18
+ TooltipTrigger,
19
+ } from "../components/ui/tooltip";
14
20
  import type { TranscriptViewTurnContext } from "../types";
15
21
  import { formatMessageTimestamp, formatTime } from "../format";
16
22
  import { memoryRecallContent, type MemoryRecallContent } from "./turnContext";
@@ -67,24 +73,33 @@ export function TranscriptTurnContextView(props: {
67
73
 
68
74
  return (
69
75
  <>
70
- <button
71
- aria-haspopup={panel ? undefined : "dialog"}
72
- aria-expanded={expanded}
73
- aria-label="View turn context"
74
- className={cn(
75
- "inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-dashboard-text-muted transition-colors hover:bg-dashboard-fill-hover hover:text-dashboard-text focus-visible:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus",
76
- expanded && "bg-dashboard-fill-hover text-dashboard-text",
77
- )}
78
- data-turn-context-id={id}
79
- onClick={() => {
80
- if (!panel) return setOpen(true);
81
- panel.onToggle({ contexts: props.contexts, id });
82
- }}
83
- title="View context"
84
- type="button"
85
- >
86
- <Layers aria-hidden="true" size={14} strokeWidth={1.8} />
87
- </button>
76
+ <Tooltip>
77
+ <TooltipTrigger
78
+ // The open panel keeps focus here. Close the tooltip on click so
79
+ // Escape reaches the panel.
80
+ closeOnClick
81
+ render={
82
+ <Button
83
+ aria-haspopup={panel ? undefined : "dialog"}
84
+ aria-expanded={expanded}
85
+ aria-label="View turn context"
86
+ className={cn(
87
+ "size-6 shrink-0",
88
+ expanded && "bg-dashboard-fill-strong text-dashboard-text",
89
+ )}
90
+ data-turn-context-id={id}
91
+ onClick={() => {
92
+ if (!panel) return setOpen(true);
93
+ panel.onToggle({ contexts: props.contexts, id });
94
+ }}
95
+ size="icon"
96
+ >
97
+ <Layers aria-hidden="true" size={14} strokeWidth={1.8} />
98
+ </Button>
99
+ }
100
+ />
101
+ <TooltipContent size="compact">View turn context</TooltipContent>
102
+ </Tooltip>
88
103
 
89
104
  {!panel && open
90
105
  ? createPortal(
@@ -1,10 +1,12 @@
1
1
  import { useState } from "react";
2
+ import type { ConversationMailboxMessage } from "./conversationOutbox";
2
3
  import { ArrowDownToLine } from "lucide-react";
3
4
 
4
5
  import type {
5
6
  ConversationTranscript,
6
7
  TranscriptViewSubagentPart,
7
8
  } from "../types";
9
+ import { EmptyTelemetry } from "../components/EmptyTelemetry";
8
10
  import { Button } from "../components/ui/button";
9
11
  import {
10
12
  Tooltip,
@@ -20,7 +22,6 @@ import {
20
22
  usePinnedTranscriptBottom,
21
23
  } from "./transcriptBottomPinning";
22
24
  import type { TranscriptViewMode } from "./transcriptRenderModel";
23
- import { transcriptEmptyClass } from "./transcriptStyles";
24
25
  import { TranscriptSearchProvider } from "./transcriptSearch";
25
26
  import {
26
27
  TurnContextPanelProvider,
@@ -37,6 +38,8 @@ export function Transcript(props: {
37
38
  pinRequestVersion?: number;
38
39
  onLoadPreviousPage?: () => void;
39
40
  onSelectedEventSeqChange?(seq: number | undefined): void;
41
+ /** The reader's own sends to show at the end until history has them. */
42
+ pendingMessages?: readonly ConversationMailboxMessage[];
40
43
  responding?: boolean;
41
44
  search?: string;
42
45
  onSearchChange?(value: string): void;
@@ -86,9 +89,9 @@ export function Transcript(props: {
86
89
 
87
90
  if (!props.transcript) {
88
91
  return (
89
- <div className={transcriptEmptyClass()}>
92
+ <EmptyTelemetry>
90
93
  No transcript is available for this conversation.
91
- </div>
94
+ </EmptyTelemetry>
92
95
  );
93
96
  }
94
97
 
@@ -139,6 +142,7 @@ export function Transcript(props: {
139
142
  <ConversationTranscriptView
140
143
  onOpenSubagentTranscript={props.onOpenSubagentTranscript}
141
144
  conversation={props.transcript}
145
+ pendingMessages={props.pendingMessages}
142
146
  responding={props.responding ?? props.live ?? false}
143
147
  />
144
148
  </TurnContextPanelProvider>
@@ -0,0 +1,244 @@
1
+ import { useId, useRef, useState, type FormEvent } from "react";
2
+ import {
3
+ buttonUserInputField,
4
+ formatUserInputAnswer,
5
+ isEmptyUserInputAnswer,
6
+ normalizeUserInputAnswer,
7
+ type UserInputAnswer,
8
+ type UserInputField,
9
+ } from "@sentry/junior/api/schema";
10
+
11
+ import { Button } from "../components/ui/button";
12
+ import { Textarea } from "../components/ui/textarea";
13
+ import { TranscriptText } from "./TranscriptText";
14
+ import type { OpenUserInputRequest } from "./userInputRequest";
15
+
16
+ const labelClass = "font-sans text-xs font-medium text-dashboard-text-muted";
17
+
18
+ function ChoiceField(props: {
19
+ disabled: boolean;
20
+ field: Extract<UserInputField, { kind: "choice" | "multi_choice" }>;
21
+ onChange(values: string[]): void;
22
+ values: string[];
23
+ }) {
24
+ const name = useId();
25
+ const multi = props.field.kind === "multi_choice";
26
+ return (
27
+ <fieldset className="m-0 min-w-0 border-0 p-0">
28
+ <legend className={labelClass}>{props.field.label}</legend>
29
+ {props.field.description ? (
30
+ <p className="m-0 text-xs text-dashboard-text-muted">
31
+ {props.field.description}
32
+ </p>
33
+ ) : null}
34
+ <div className="mt-1 grid gap-1">
35
+ {props.field.options.map((option) => (
36
+ <label
37
+ className="flex cursor-pointer items-start gap-2 text-sm"
38
+ key={option.label}
39
+ >
40
+ <input
41
+ checked={props.values.includes(option.label)}
42
+ className="mt-0.5 size-4 shrink-0 accent-dashboard-focus"
43
+ disabled={props.disabled}
44
+ name={name}
45
+ onChange={() =>
46
+ props.onChange(
47
+ !multi
48
+ ? [option.label]
49
+ : props.values.includes(option.label)
50
+ ? props.values.filter((value) => value !== option.label)
51
+ : [...props.values, option.label],
52
+ )
53
+ }
54
+ type={multi ? "checkbox" : "radio"}
55
+ />
56
+ <span className="min-w-0 break-words">
57
+ {option.label}
58
+ {option.description ? (
59
+ <span className="block text-xs text-dashboard-text-muted">
60
+ {option.description}
61
+ </span>
62
+ ) : null}
63
+ </span>
64
+ </label>
65
+ ))}
66
+ </div>
67
+ </fieldset>
68
+ );
69
+ }
70
+
71
+ function TextField(props: {
72
+ description?: string;
73
+ disabled: boolean;
74
+ label: string;
75
+ onChange(value: string): void;
76
+ value: string;
77
+ }) {
78
+ const id = useId();
79
+ return (
80
+ <div className="grid gap-1">
81
+ <label className={labelClass} htmlFor={id}>
82
+ {props.label}
83
+ </label>
84
+ {props.description ? (
85
+ <p className="m-0 text-xs text-dashboard-text-muted">
86
+ {props.description}
87
+ </p>
88
+ ) : null}
89
+ <Textarea
90
+ className="min-h-16 leading-relaxed"
91
+ disabled={props.disabled}
92
+ id={id}
93
+ onChange={(event) => props.onChange(event.target.value)}
94
+ value={props.value}
95
+ />
96
+ </div>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Show Junior's open request above the composer. A request with one short
102
+ * choice field shows one button per choice, and a click is the answer. Other
103
+ * requests show a form that the reader submits once. Both send the formatted
104
+ * answer as the reader's next message. Typing a reply also answers it.
105
+ */
106
+ export function UserInputRequestCard(props: {
107
+ onAnswer(answer: string): Promise<void>;
108
+ request: OpenUserInputRequest;
109
+ }) {
110
+ const [sending, setSending] = useState(false);
111
+ const [failed, setFailed] = useState(false);
112
+ const [values, setValues] = useState<Record<string, string[]>>({});
113
+ const [other, setOther] = useState("");
114
+ const buttonField = buttonUserInputField(props.request);
115
+
116
+ // A ref blocks a second click before React renders the disabled controls.
117
+ // The lock stays after a sent answer; only a failed send can retry.
118
+ const sendLock = useRef(false);
119
+
120
+ async function send(answer: UserInputAnswer) {
121
+ if (sendLock.current) return;
122
+ sendLock.current = true;
123
+ setSending(true);
124
+ setFailed(false);
125
+ try {
126
+ await props.onAnswer(formatUserInputAnswer(props.request, answer));
127
+ } catch {
128
+ sendLock.current = false;
129
+ setSending(false);
130
+ setFailed(true);
131
+ }
132
+ }
133
+
134
+ const answer: UserInputAnswer = {
135
+ other,
136
+ requestId: props.request.toolCallId,
137
+ values,
138
+ };
139
+ function submit(event: FormEvent) {
140
+ event.preventDefault();
141
+ if (
142
+ !isEmptyUserInputAnswer(normalizeUserInputAnswer(props.request, answer))
143
+ ) {
144
+ void send(answer);
145
+ }
146
+ }
147
+
148
+ return (
149
+ <section
150
+ aria-label="Junior asks"
151
+ className="mb-2 rounded-lg border border-cyan-300/20 bg-cyan-300/[0.07] px-3 py-2.5 font-sans text-sm text-dashboard-text"
152
+ >
153
+ <div className="max-h-48 overflow-y-auto">
154
+ <TranscriptText
155
+ mentions={[]}
156
+ role="assistant"
157
+ text={props.request.question}
158
+ />
159
+ </div>
160
+ {buttonField ? (
161
+ <>
162
+ <div className="mt-2 flex flex-wrap gap-2">
163
+ {buttonField.options.map((option) => (
164
+ <Button
165
+ disabled={sending}
166
+ key={option.label}
167
+ onClick={() =>
168
+ void send({
169
+ requestId: props.request.toolCallId,
170
+ values: { [buttonField.id]: [option.label] },
171
+ })
172
+ }
173
+ title={option.description}
174
+ >
175
+ {option.label}
176
+ </Button>
177
+ ))}
178
+ </div>
179
+ {buttonField.options.some((option) => option.description) ? (
180
+ <ul className="mb-0 mt-2 list-none p-0 text-xs text-dashboard-text-muted">
181
+ {buttonField.options
182
+ .filter((option) => option.description)
183
+ .map((option) => (
184
+ <li key={option.label}>
185
+ <span className="font-medium text-dashboard-text">
186
+ {option.label}
187
+ </span>
188
+ : {option.description}
189
+ </li>
190
+ ))}
191
+ </ul>
192
+ ) : null}
193
+ </>
194
+ ) : (
195
+ <form
196
+ className="mt-2 grid max-h-96 gap-3 overflow-y-auto"
197
+ onSubmit={submit}
198
+ >
199
+ {props.request.fields.map((field) =>
200
+ field.kind === "text" ? (
201
+ <TextField
202
+ description={field.description}
203
+ disabled={sending}
204
+ key={field.id}
205
+ label={field.label}
206
+ onChange={(value) =>
207
+ setValues((current) => ({ ...current, [field.id]: [value] }))
208
+ }
209
+ value={values[field.id]?.[0] ?? ""}
210
+ />
211
+ ) : (
212
+ <ChoiceField
213
+ disabled={sending}
214
+ field={field}
215
+ key={field.id}
216
+ onChange={(picked) =>
217
+ setValues((current) => ({ ...current, [field.id]: picked }))
218
+ }
219
+ values={values[field.id] ?? []}
220
+ />
221
+ ),
222
+ )}
223
+ <TextField
224
+ description="Anything else, or an answer that is not listed."
225
+ disabled={sending}
226
+ label="Other"
227
+ onChange={setOther}
228
+ value={other}
229
+ />
230
+ <div>
231
+ <Button disabled={sending} type="submit" variant="primary">
232
+ Send
233
+ </Button>
234
+ </div>
235
+ </form>
236
+ )}
237
+ {failed ? (
238
+ <p className="mb-0 mt-2 text-xs text-rose-300" role="alert">
239
+ Could not send your answer. Try again.
240
+ </p>
241
+ ) : null}
242
+ </section>
243
+ );
244
+ }
@@ -9,6 +9,11 @@ export type ConversationOutboxMessage = {
9
9
  actorIdentity?: ActorIdentity;
10
10
  createdAt: string;
11
11
  idempotencyKey: string;
12
+ /**
13
+ * Sent while Junior was idle and the queue was empty. The transcript shows
14
+ * the row in place, so it does not pass through the queue first.
15
+ */
16
+ inline?: boolean;
12
17
  message: string;
13
18
  images?: InputImage[];
14
19
  messageId: string;
@@ -31,6 +36,7 @@ export function conversationOutboxQueryKey(conversationId: string | undefined) {
31
36
  export function conversationOutboxMessageForSubmit(input: {
32
37
  actorIdentity?: ActorIdentity;
33
38
  idempotencyKey: string;
39
+ inline?: boolean;
34
40
  message: string;
35
41
  images?: InputImage[];
36
42
  messageId: string;
@@ -41,6 +47,7 @@ export function conversationOutboxMessageForSubmit(input: {
41
47
  actorIdentity: input.actorIdentity,
42
48
  createdAt,
43
49
  idempotencyKey: input.idempotencyKey,
50
+ ...(input.inline ? { inline: true } : undefined),
44
51
  message: input.message,
45
52
  ...(input.images?.length ? { images: input.images } : undefined),
46
53
  messageId: input.messageId,
@@ -153,6 +160,45 @@ function sameActorIdentity(
153
160
  );
154
161
  }
155
162
 
163
+ /** True while an outbox row belongs in the transcript instead of the queue. */
164
+ export function isInlineOutboxMessage(
165
+ message: ConversationOutboxMessage,
166
+ ): boolean {
167
+ // A failed send moves to the queue, which owns the retry action.
168
+ return Boolean(message.inline) && message.status !== "failed";
169
+ }
170
+
171
+ /**
172
+ * Split pending rows between the queue and the transcript.
173
+ *
174
+ * Inline rows prefer the server copy once the mailbox has it, so attachments
175
+ * and identity match what the queue would show.
176
+ */
177
+ export function splitInlineMailboxMessages(
178
+ merged: readonly ConversationMailboxMessage[],
179
+ server: readonly ConversationPendingMessage[] | undefined,
180
+ outbox: readonly ConversationOutboxMessage[],
181
+ ): {
182
+ inline: ConversationMailboxMessage[];
183
+ queued: ConversationMailboxMessage[];
184
+ } {
185
+ const inlineRows = outbox.filter(isInlineOutboxMessage);
186
+ if (inlineRows.length === 0) {
187
+ return { inline: [], queued: merged as ConversationMailboxMessage[] };
188
+ }
189
+ const inlineIds = new Set(inlineRows.map((message) => message.messageId));
190
+ const serverById = new Map(
191
+ (server ?? []).map((message) => [message.messageId, message]),
192
+ );
193
+ return {
194
+ inline: inlineRows.map(
195
+ (message) =>
196
+ serverById.get(message.messageId) ?? mailboxMessageFromOutbox(message),
197
+ ),
198
+ queued: merged.filter((message) => !inlineIds.has(message.messageId)),
199
+ };
200
+ }
201
+
156
202
  /** Append or replace one outbox row by idempotency key. */
157
203
  export function upsertConversationOutboxMessage(
158
204
  current: readonly ConversationOutboxMessage[] | undefined,
@@ -28,6 +28,15 @@ export function useEventTime() {
28
28
  day: "numeric",
29
29
  timeZoneName: "short",
30
30
  }),
31
+ // The details header is narrow, so it leaves out milliseconds.
32
+ shortTimestamp: new Intl.DateTimeFormat(undefined, {
33
+ ...clock,
34
+ fractionalSecondDigits: undefined,
35
+ year: "numeric",
36
+ month: "short",
37
+ day: "numeric",
38
+ timeZoneName: "short",
39
+ }),
31
40
  // A log can span both standard time and daylight-saving time.
32
41
  zone: new Intl.DateTimeFormat(undefined, {
33
42
  timeZone,
@@ -54,7 +63,10 @@ export function useEventTime() {
54
63
  return formatters.clock.format(new Date(value));
55
64
  },
56
65
  formatTimestamp(value: string) {
57
- return local ? formatters.timestamp.format(new Date(value)) : value;
66
+ return formatters.timestamp.format(new Date(value));
67
+ },
68
+ formatShortTimestamp(value: string) {
69
+ return formatters.shortTimestamp.format(new Date(value));
58
70
  },
59
71
  };
60
72
  }
@@ -2,6 +2,7 @@ import { resolveMessageCards } from "@sentry/junior/api/schema";
2
2
  import type {
3
3
  ConversationPendingMessage,
4
4
  ConversationReportEvent,
5
+ InputImage,
5
6
  } from "@sentry/junior/api/schema";
6
7
 
7
8
  import type {
@@ -10,6 +11,11 @@ import type {
10
11
  TranscriptViewPart,
11
12
  } from "../types";
12
13
 
14
+ /** A mailbox row, or a local send that can still carry its own images. */
15
+ type PendingTranscriptInput = ConversationPendingMessage & {
16
+ images?: InputImage[];
17
+ };
18
+
13
19
  type ToolCall =
14
20
  | Extract<
15
21
  ConversationReportEvent["data"],
@@ -67,7 +73,7 @@ function historyMessageIds(
67
73
 
68
74
  /** Project one accepted mailbox row into a pending transcript message. */
69
75
  export function pendingTranscriptMessage(
70
- message: ConversationPendingMessage,
76
+ message: PendingTranscriptInput,
71
77
  index: number,
72
78
  ): TranscriptViewMessage {
73
79
  return {
@@ -77,6 +83,7 @@ export function pendingTranscriptMessage(
77
83
  ? [{ type: "text", redacted: true }]
78
84
  : [{ type: "text", text: message.text ?? "" }],
79
85
  ...(message.attachments ? { attachments: message.attachments } : undefined),
86
+ ...(message.images?.length ? { images: message.images } : undefined),
80
87
  pending: true,
81
88
  role: "user",
82
89
  source: message.source,
@@ -97,7 +104,7 @@ export function pendingTranscriptMessage(
97
104
  */
98
105
  export function mergePendingTranscriptMessages(
99
106
  messages: TranscriptViewMessage[],
100
- pending: readonly ConversationPendingMessage[] | undefined,
107
+ pending: readonly PendingTranscriptInput[] | undefined,
101
108
  ): TranscriptViewMessage[] {
102
109
  if (!pending?.length) return messages;
103
110
  const extras = unresolvedPendingTranscriptMessages(messages, pending);
@@ -112,7 +119,7 @@ export function mergePendingTranscriptMessages(
112
119
  */
113
120
  export function unresolvedPendingTranscriptMessages(
114
121
  messages: readonly TranscriptViewMessage[],
115
- pending: readonly ConversationPendingMessage[] | undefined,
122
+ pending: readonly PendingTranscriptInput[] | undefined,
116
123
  ): TranscriptViewMessage[] {
117
124
  if (!pending?.length) return [];
118
125
  const committedIds = historyMessageIds(messages);
@@ -124,7 +131,7 @@ export function unresolvedPendingTranscriptMessages(
124
131
  /** Reduce the ordered reporting event API into dashboard-only transcript rows. */
125
132
  export function conversationTranscriptMessages(
126
133
  conversation: ConversationTranscript,
127
- pendingMessages?: readonly ConversationPendingMessage[],
134
+ pendingMessages?: readonly PendingTranscriptInput[],
128
135
  ): TranscriptViewMessage[] {
129
136
  return transcriptMessagesFromEvents(
130
137
  conversation.events,
@@ -137,7 +144,7 @@ export function conversationTranscriptMessages(
137
144
  export function transcriptMessagesFromEvents(
138
145
  events: ConversationReportEvent[],
139
146
  annotations: ConversationTranscript["annotations"],
140
- pendingMessages?: readonly ConversationPendingMessage[],
147
+ pendingMessages?: readonly PendingTranscriptInput[],
141
148
  ): TranscriptViewMessage[] {
142
149
  const replacedToolIds = specialToolIds(events);
143
150
  const tools = new Map<