@sentry/junior-dashboard 0.235.0 → 0.236.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.
@@ -50,6 +50,7 @@ import { WorkspacesPage } from "./pages/system/WorkspacesPage";
50
50
  import { MemoryRouteLoading } from "./pages/memory/MemoryPageLayout";
51
51
  import { AutomationExecutionsPage } from "./pages/automations/AutomationExecutionsPage";
52
52
  import { AutomationRunsPage } from "./pages/automations/AutomationRunsPage";
53
+ import { AutomationVersionsPage } from "./pages/automations/AutomationVersionsPage";
53
54
  import { AutomationsPage } from "./pages/automations/AutomationsPage";
54
55
  import {
55
56
  AutomationsPageLayout,
@@ -230,6 +231,27 @@ export function DashboardShell() {
230
231
  }
231
232
  path="/automations/:kind/:automationId/executions"
232
233
  />
234
+ <Route
235
+ element={
236
+ loading ? (
237
+ <AutomationsPageLayout>
238
+ <AutomationsRouteLoading
239
+ description="Saved settings for this automation, newest first."
240
+ label="Loading version history"
241
+ title="Version history"
242
+ variant="list"
243
+ />
244
+ </AutomationsPageLayout>
245
+ ) : loggedIn ? (
246
+ <AutomationsPageLayout>
247
+ <AutomationVersionsPage enabled={loggedIn} />
248
+ </AutomationsPageLayout>
249
+ ) : (
250
+ <Navigate replace to="/" />
251
+ )
252
+ }
253
+ path="/automations/:kind/:automationId/versions"
254
+ />
233
255
  <Route
234
256
  element={
235
257
  loading ? (
package/src/client/api.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  automationListSchema,
20
20
  automationSummarySchema,
21
21
  automationRunListSchema,
22
+ automationVersionListSchema,
22
23
  } from "@sentry/junior/api/schema";
23
24
  import {
24
25
  pluginOperationalReportFeedSchema,
@@ -223,6 +224,25 @@ export function useAutomationExecutionsData(
223
224
  });
224
225
  }
225
226
 
227
+ /** Fetch saved definitions for one viewer-visible Automation, newest first. */
228
+ export function useAutomationVersionsData(
229
+ enabled: boolean,
230
+ kind: "scheduled" | "event" | undefined,
231
+ automationId: string | undefined,
232
+ ) {
233
+ return useQuery({
234
+ enabled: enabled && Boolean(kind && automationId),
235
+ queryKey: ["dashboard", "automations", kind, automationId, "versions"],
236
+ queryFn: ({ signal }) =>
237
+ fetchDashboardJson(
238
+ automationVersionListSchema,
239
+ `/api/automations/${kind}/${encodeURIComponent(automationId!)}/versions`,
240
+ signal,
241
+ ),
242
+ retry: false,
243
+ });
244
+ }
245
+
226
246
  /** Fetch the actor directory used by the People dashboard route. */
227
247
  export function useActorDirectoryData() {
228
248
  return useQuery({
@@ -1,5 +1,5 @@
1
1
  import { ChevronRight } from "lucide-react";
2
- import { memo, useId, useMemo, useState } from "react";
2
+ import { memo, useEffect, useId, useMemo, useRef, useState } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
 
5
5
  import { Drawer } from "../components/Drawer";
@@ -12,18 +12,31 @@ import {
12
12
  eventLogUsage,
13
13
  } from "./eventLog";
14
14
  import { EventDetails } from "./EventDetails";
15
+ import { ForkConversationButton } from "./ForkConversationButton";
15
16
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
16
17
 
17
18
  const rowClass =
18
19
  "grid grid-cols-[12ch_minmax(0,1fr)_1rem] items-center gap-x-3 px-3 @min-[48rem]:grid-cols-[12ch_19ch_minmax(0,1fr)_1rem]";
19
20
 
20
- /** Show every reporting event in sequence order, outside the transcript reducer. */
21
+ /**
22
+ * Show every reporting event in sequence order, outside the transcript reducer.
23
+ * Pass `onSelectedSeqChange` to own the open entry, for example in the URL.
24
+ */
21
25
  export const ConversationEventLog = memo(function ConversationEventLog(props: {
22
26
  conversation: ConversationTranscript;
27
+ onSelectedSeqChange?(seq: number | undefined): void;
28
+ selectedSeq?: number;
23
29
  }) {
24
30
  const { conversation } = props;
25
31
  const search = useTranscriptSearch();
26
- const [selectedSeq, setSelectedSeq] = useState<number>();
32
+ const [localSelectedSeq, setLocalSelectedSeq] = useState<number>();
33
+ const selectedSeq = props.onSelectedSeqChange
34
+ ? props.selectedSeq
35
+ : localSelectedSeq;
36
+ const setSelectedSeq = props.onSelectedSeqChange ?? setLocalSelectedSeq;
37
+ const listRef = useRef<HTMLOListElement>(null);
38
+ // Rows opened by a click are already visible. Scroll only for a linked entry.
39
+ const revealedSeqRef = useRef<number>(undefined);
27
40
  const titleId = useId();
28
41
  const events = conversation.events;
29
42
  const rows = useMemo(
@@ -50,6 +63,17 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
50
63
  );
51
64
  // Resolve the selection from current data so polling cannot leave stale details.
52
65
  const selected = events.find((event) => event.seq === selectedSeq);
66
+ useEffect(() => {
67
+ if (selectedSeq === undefined || revealedSeqRef.current === selectedSeq) {
68
+ return;
69
+ }
70
+ const row = listRef.current?.querySelector(
71
+ `[data-event-seq="${selectedSeq}"]`,
72
+ );
73
+ if (!row) return;
74
+ revealedSeqRef.current = selectedSeq;
75
+ row.scrollIntoView({ block: "center" });
76
+ }, [selectedSeq, visibleRows]);
53
77
 
54
78
  if (conversation.eventHistory.status === "expired") {
55
79
  return (
@@ -85,9 +109,10 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
85
109
  <ol
86
110
  aria-label="Events"
87
111
  className="m-0 list-none divide-y divide-dashboard-border-subtle p-0"
112
+ ref={listRef}
88
113
  >
89
114
  {visibleRows.map(({ event, summary, model, usage }) => (
90
- <li key={event.seq}>
115
+ <li data-event-seq={event.seq} key={event.seq}>
91
116
  <button
92
117
  aria-haspopup="dialog"
93
118
  aria-label={`Event ${event.seq}: ${event.data.type}`}
@@ -96,7 +121,10 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
96
121
  "min-h-11 w-full cursor-pointer border-0 bg-transparent py-2 text-left leading-5 hover:bg-dashboard-fill-hover active:bg-dashboard-fill-strong focus-visible:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus @min-[48rem]:min-h-8 @min-[48rem]:py-1",
97
122
  selectedSeq === event.seq && "bg-dashboard-fill-strong",
98
123
  )}
99
- onClick={() => setSelectedSeq(event.seq)}
124
+ onClick={() => {
125
+ revealedSeqRef.current = event.seq;
126
+ setSelectedSeq(event.seq);
127
+ }}
100
128
  type="button"
101
129
  >
102
130
  <time
@@ -191,6 +219,18 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
191
219
  titleId={titleId}
192
220
  width="wide"
193
221
  >
222
+ {conversation.canFork &&
223
+ selected.data.type === "message" &&
224
+ selected.data.role === "assistant" &&
225
+ !selected.data.redacted ? (
226
+ <div className="mb-3 flex items-center gap-2 text-sm">
227
+ <ForkConversationButton
228
+ conversationId={conversation.conversationId}
229
+ messageId={selected.data.messageId}
230
+ />
231
+ <span>Fork after this reply</span>
232
+ </div>
233
+ ) : null}
194
234
  <EventDetails key={selected.seq} event={selected} />
195
235
  </Drawer>,
196
236
  document.body,
@@ -0,0 +1,51 @@
1
+ import { GitFork } from "lucide-react";
2
+ import { Link } from "react-router";
3
+ import { conversationPath } from "./conversationRoutes";
4
+
5
+ const linkClassName =
6
+ "font-medium text-dashboard-text no-underline underline-offset-2 hover:underline";
7
+
8
+ /** Link a conversation to its fork source and to its forks. */
9
+ export function ConversationForkLinks(props: {
10
+ forkedFromConversationId?: string;
11
+ forkedFromTitle?: string;
12
+ forks?: string[];
13
+ }) {
14
+ return (
15
+ <nav
16
+ aria-label="Conversation forks"
17
+ className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-snug text-dashboard-text-muted"
18
+ >
19
+ <GitFork aria-hidden="true" size={12} className="shrink-0" />
20
+ {props.forkedFromConversationId ? (
21
+ <span>
22
+ Forked from{" "}
23
+ <Link
24
+ className={linkClassName}
25
+ to={conversationPath(props.forkedFromConversationId)}
26
+ >
27
+ {props.forkedFromTitle ?? "source conversation"}
28
+ </Link>
29
+ </span>
30
+ ) : null}
31
+ {props.forkedFromConversationId && props.forks?.length ? (
32
+ <span>·</span>
33
+ ) : null}
34
+ {props.forks?.length ? (
35
+ <span className="inline-flex flex-wrap items-center gap-x-1.5">
36
+ Forks:
37
+ {props.forks.map((id, index) => (
38
+ <Link
39
+ key={id}
40
+ aria-label={`Fork ${index + 1}`}
41
+ className={linkClassName}
42
+ to={conversationPath(id)}
43
+ >
44
+ {index + 1}
45
+ </Link>
46
+ ))}
47
+ </span>
48
+ ) : null}
49
+ </nav>
50
+ );
51
+ }
@@ -32,6 +32,8 @@ export function ConversationHeader(props: {
32
32
  archive: ConversationArchiveAction;
33
33
  brief?: ReactNode;
34
34
  conversationId: string;
35
+ /** Links to the fork source and to forks of this conversation. */
36
+ forks?: ReactNode;
35
37
  identity: ReactNode;
36
38
  linkedWork?: ReactNode;
37
39
  lastActivityAt?: string;
@@ -149,7 +151,10 @@ export function ConversationHeader(props: {
149
151
  );
150
152
 
151
153
  const showMobileHeader =
152
- searchOpenVisible || props.archive.error || Boolean(props.linkedWork);
154
+ searchOpenVisible ||
155
+ props.archive.error ||
156
+ Boolean(props.linkedWork) ||
157
+ Boolean(props.forks);
153
158
 
154
159
  const liveIndicator = props.live ? (
155
160
  <span
@@ -183,6 +188,9 @@ export function ConversationHeader(props: {
183
188
  {props.meta}
184
189
  </div>
185
190
  ) : null}
191
+ {props.forks ? (
192
+ <div className="mt-1.5 min-w-0">{props.forks}</div>
193
+ ) : null}
186
194
  </div>
187
195
  <ConversationHeaderActions
188
196
  archive={props.archive}
@@ -196,6 +204,9 @@ export function ConversationHeader(props: {
196
204
  view={props.view}
197
205
  />
198
206
  </div>
207
+ {props.forks ? (
208
+ <div className="px-4 py-2 md:hidden">{props.forks}</div>
209
+ ) : null}
199
210
  {props.linkedWork ? (
200
211
  <div
201
212
  aria-label="Linked work"
@@ -1,4 +1,5 @@
1
1
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useSearchParams } from "react-router";
2
3
  import type {
3
4
  ConversationDetailReport,
4
5
  ConversationFeed,
@@ -18,8 +19,13 @@ import type { ConversationMailboxMessage } from "./conversationOutbox";
18
19
  import { buildConversationMarkdown } from "../markdownExport";
19
20
  import { CopyMarkdownButton } from "./CopyMarkdownButton";
20
21
  import { ConversationBrief } from "./ConversationBrief";
22
+ import {
23
+ CONVERSATION_EVENT_PARAM,
24
+ parseConversationEventSeq,
25
+ } from "./conversationRoutes";
21
26
  import { ConversationComposer } from "./ConversationComposer";
22
27
  import { ConversationHeader } from "./ConversationHeader";
28
+ import { ConversationForkLinks } from "./ConversationForkLinks";
23
29
  import { ConversationHeaderMeta } from "./ConversationHeaderMeta";
24
30
  import {
25
31
  ConversationAnnotations,
@@ -67,7 +73,15 @@ export function ConversationPage(props: {
67
73
  }) {
68
74
  const [subagentTarget, setSubagentTarget] =
69
75
  useState<SubagentTranscriptTarget>();
70
- const [view, setView] = useState<TranscriptViewMode>("rich");
76
+ const [searchParams, setSearchParams] = useSearchParams();
77
+ // The URL owns the open event log entry so readers can share that link.
78
+ const selectedEventSeq = parseConversationEventSeq(
79
+ searchParams.get(CONVERSATION_EVENT_PARAM),
80
+ );
81
+ const [viewState, setView] = useState<TranscriptViewMode>(() =>
82
+ selectedEventSeq === undefined ? "rich" : "raw",
83
+ );
84
+ const view = selectedEventSeq === undefined ? viewState : "raw";
71
85
  const [search, setSearch] = useState("");
72
86
  const [pinRequestVersion, setPinRequestVersion] = useState(0);
73
87
  const conversationId = props.conversationId;
@@ -109,6 +123,48 @@ export function ConversationPage(props: {
109
123
  // polls refresh generatedAt every 2s even when the queue is unchanged.
110
124
  const pendingGeneratedAtRef = useRef(detail.pendingGeneratedAt);
111
125
  pendingGeneratedAtRef.current = detail.pendingGeneratedAt;
126
+ const setSelectedEventSeq = useCallback(
127
+ (seq: number | undefined) => {
128
+ // Keep the event log after close, even when a link opened the page.
129
+ setView("raw");
130
+ setSearchParams(
131
+ (current) => {
132
+ const next = new URLSearchParams(current);
133
+ if (seq === undefined) next.delete(CONVERSATION_EVENT_PARAM);
134
+ else next.set(CONVERSATION_EVENT_PARAM, String(seq));
135
+ return next;
136
+ },
137
+ { replace: true },
138
+ );
139
+ },
140
+ [setSearchParams],
141
+ );
142
+ const onViewChange = useCallback(
143
+ (value: TranscriptViewMode) => {
144
+ if (value !== "raw" && selectedEventSeq !== undefined) {
145
+ setSelectedEventSeq(undefined);
146
+ }
147
+ setView(value);
148
+ },
149
+ [selectedEventSeq, setSelectedEventSeq],
150
+ );
151
+ // A linked event can be older than the first page. Load earlier pages until
152
+ // it is present or history ends.
153
+ const loadPreviousPageRef = useRef(detail.loadPreviousPage);
154
+ loadPreviousPageRef.current = detail.loadPreviousPage;
155
+ const oldestLoadedSeq = transcript?.events[0]?.seq;
156
+ const linkedEventMissing =
157
+ selectedEventSeq !== undefined &&
158
+ oldestLoadedSeq !== undefined &&
159
+ selectedEventSeq < oldestLoadedSeq;
160
+ const loadLinkedEvent =
161
+ linkedEventMissing &&
162
+ detail.hasPreviousPage &&
163
+ !detail.isLoadingPreviousPage &&
164
+ !detail.historyError;
165
+ useEffect(() => {
166
+ if (loadLinkedEvent) loadPreviousPageRef.current();
167
+ }, [loadLinkedEvent, oldestLoadedSeq]);
112
168
  const requestPin = useCallback(() => {
113
169
  setPinRequestVersion((version) => version + 1);
114
170
  }, []);
@@ -137,6 +193,18 @@ export function ConversationPage(props: {
137
193
  <section className="min-w-0">
138
194
  <ConversationHeader
139
195
  conversationId={conversationId}
196
+ forks={
197
+ detail.data?.forkedFromConversationId ||
198
+ detail.data?.forks?.length ? (
199
+ <ConversationForkLinks
200
+ forkedFromConversationId={
201
+ detail.data.forkedFromConversationId
202
+ }
203
+ forkedFromTitle={detail.data.forkedFromTitle}
204
+ forks={detail.data.forks}
205
+ />
206
+ ) : null
207
+ }
140
208
  lastActivityAt={conversation?.lastSeenAt}
141
209
  sentryConversationUrl={detail.data?.sentryConversationUrl}
142
210
  copyAction={
@@ -206,7 +274,7 @@ export function ConversationPage(props: {
206
274
  />
207
275
  }
208
276
  onSearchChange={setSearch}
209
- onViewChange={setView}
277
+ onViewChange={onViewChange}
210
278
  privacy={
211
279
  <ConversationPrivacyChip
212
280
  visibility={conversation?.visibility}
@@ -256,12 +324,14 @@ export function ConversationPage(props: {
256
324
  live={live}
257
325
  loadingPreviousPage={detail.isLoadingPreviousPage}
258
326
  onLoadPreviousPage={detail.loadPreviousPage}
327
+ onSelectedEventSeqChange={setSelectedEventSeq}
259
328
  pinRequestVersion={pinRequestVersion}
260
329
  responding={
261
330
  !detail.error && conversationIsResponding(transcript)
262
331
  }
263
332
  onOpenSubagentTranscript={onOpenSubagentTranscript}
264
333
  search={search}
334
+ selectedEventSeq={selectedEventSeq}
265
335
  transcript={transcript}
266
336
  view={view}
267
337
  />
@@ -0,0 +1,125 @@
1
+ import { GitFork } from "lucide-react";
2
+ import { useId, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { useNavigate } from "react-router";
5
+ import { Button } from "../components/Button";
6
+ import { Drawer } from "../components/Drawer";
7
+ import { Field } from "../components/Field";
8
+ import { TextArea } from "../components/TextInput";
9
+ import { DashboardApiError } from "../http";
10
+ import { conversationPath } from "./conversationRoutes";
11
+ import { useForkConversation } from "./queries";
12
+
13
+ type ForkTarget = { conversationId: string; messageId: string };
14
+
15
+ /** Open the fork dialog for one assistant reply. */
16
+ export function ForkConversationButton(props: ForkTarget) {
17
+ const [open, setOpen] = useState(false);
18
+ return (
19
+ <>
20
+ <Button
21
+ size="icon"
22
+ aria-label="Fork after this message"
23
+ title="Fork after this message"
24
+ onClick={() => setOpen(true)}
25
+ >
26
+ <GitFork size={14} aria-hidden="true" />
27
+ </Button>
28
+ {open
29
+ ? createPortal(
30
+ <ForkConversationDialog
31
+ {...props}
32
+ onClose={() => setOpen(false)}
33
+ />,
34
+ document.body,
35
+ )
36
+ : null}
37
+ </>
38
+ );
39
+ }
40
+
41
+ function ForkConversationDialog(props: ForkTarget & { onClose(): void }) {
42
+ const titleId = useId();
43
+ const inputId = useId();
44
+ const [idempotencyKey] = useState(() => crypto.randomUUID());
45
+ const [message, setMessage] = useState("");
46
+ const navigate = useNavigate();
47
+ const fork = useForkConversation(props.conversationId);
48
+ const submit = () => {
49
+ if (!message.trim() || fork.isPending) return;
50
+ fork.mutate(
51
+ { idempotencyKey, message, messageId: props.messageId },
52
+ {
53
+ onSuccess: (accepted) => {
54
+ props.onClose();
55
+ navigate(conversationPath(accepted.conversationId));
56
+ },
57
+ },
58
+ );
59
+ };
60
+ return (
61
+ <Drawer
62
+ closeLabel="Close fork dialog"
63
+ dismissLabel="Dismiss fork dialog"
64
+ header={
65
+ <h2 id={titleId} className="m-0 text-lg font-semibold">
66
+ Fork Conversation
67
+ </h2>
68
+ }
69
+ titleId={titleId}
70
+ openKey={idempotencyKey}
71
+ onClose={() => {
72
+ if (!fork.isPending) props.onClose();
73
+ }}
74
+ width="narrow"
75
+ >
76
+ <form
77
+ className="grid gap-4 text-sm"
78
+ onSubmit={(event) => {
79
+ event.preventDefault();
80
+ submit();
81
+ }}
82
+ >
83
+ <p className="m-0">
84
+ Creates a fork of the conversation from this point.
85
+ </p>
86
+ <Field htmlFor={inputId} label="Message">
87
+ <TextArea
88
+ id={inputId}
89
+ prose
90
+ autoFocus
91
+ className="min-h-28"
92
+ placeholder="Message Junior…"
93
+ value={message}
94
+ disabled={fork.isPending}
95
+ onChange={(event) => setMessage(event.target.value)}
96
+ onKeyDown={(event) => {
97
+ // Same keys as the composer: Enter sends, Shift+Enter adds a line.
98
+ if (
99
+ event.key === "Enter" &&
100
+ !event.shiftKey &&
101
+ !event.nativeEvent.isComposing
102
+ ) {
103
+ event.preventDefault();
104
+ submit();
105
+ }
106
+ }}
107
+ />
108
+ </Field>
109
+ {fork.error ? (
110
+ <p role="alert" className="m-0 text-red-300">
111
+ {(fork.error instanceof DashboardApiError && fork.error.apiError) ||
112
+ "Could not fork the conversation. Try again."}
113
+ </p>
114
+ ) : null}
115
+ <Button
116
+ tone="primary"
117
+ type="submit"
118
+ disabled={!message.trim() || fork.isPending}
119
+ >
120
+ {fork.isPending ? "Forking…" : "Fork and send"}
121
+ </Button>
122
+ </form>
123
+ </Drawer>
124
+ );
125
+ }
@@ -1,3 +1,4 @@
1
+ import { ForkConversationButton } from "./ForkConversationButton";
1
2
  import { TranscriptAttachments } from "./TranscriptAttachment";
2
3
  import { ObjectCard } from "./ObjectCard";
3
4
  import { AutomationCard } from "../components/AutomationCard";
@@ -46,11 +47,7 @@ export const TranscriptMessageView = memo(
46
47
  }}
47
48
  >
48
49
  <TranscriptMessageHeader
49
- contextAction={
50
- props.message.role === "user" && props.message.contexts?.length ? (
51
- <TranscriptTurnContextView contexts={props.message.contexts} />
52
- ) : undefined
53
- }
50
+ action={messageAction(props.message, props.conversation)}
54
51
  message={props.message}
55
52
  conversation={props.conversation}
56
53
  />
@@ -86,6 +83,7 @@ export const TranscriptMessageView = memo(
86
83
  previous.message === next.message &&
87
84
  previous.conversation.conversationId === next.conversation.conversationId &&
88
85
  previous.conversation.surface === next.conversation.surface &&
86
+ previous.conversation.canFork === next.conversation.canFork &&
89
87
  previous.conversation.actorIdentity === next.conversation.actorIdentity,
90
88
  );
91
89
 
@@ -114,8 +112,31 @@ export function RedactedMessageView(props: {
114
112
  );
115
113
  }
116
114
 
115
+ function messageAction(
116
+ message: TranscriptViewMessage,
117
+ conversation: ConversationTranscript,
118
+ ): ReactNode {
119
+ if (message.role === "user" && message.contexts?.length) {
120
+ return <TranscriptTurnContextView contexts={message.contexts} />;
121
+ }
122
+ if (
123
+ conversation.canFork &&
124
+ message.role === "assistant" &&
125
+ message.messageId &&
126
+ !message.pending
127
+ ) {
128
+ return (
129
+ <ForkConversationButton
130
+ conversationId={conversation.conversationId}
131
+ messageId={message.messageId}
132
+ />
133
+ );
134
+ }
135
+ return undefined;
136
+ }
137
+
117
138
  function TranscriptMessageHeader(props: {
118
- contextAction?: ReactNode;
139
+ action?: ReactNode;
119
140
  message: TranscriptViewMessage;
120
141
  conversation: ConversationTranscript;
121
142
  }) {
@@ -127,7 +148,7 @@ function TranscriptMessageHeader(props: {
127
148
  );
128
149
 
129
150
  return (
130
- <TranscriptMessageHeading action={props.contextAction}>
151
+ <TranscriptMessageHeading action={props.action}>
131
152
  <span className={transcriptRoleLabelClass(props.message.role)}>
132
153
  {roleLabel}
133
154
  </span>
@@ -25,8 +25,10 @@ export function Transcript(props: {
25
25
  loadingPreviousPage?: boolean;
26
26
  pinRequestVersion?: number;
27
27
  onLoadPreviousPage?: () => void;
28
+ onSelectedEventSeqChange?(seq: number | undefined): void;
28
29
  responding?: boolean;
29
30
  search?: string;
31
+ selectedEventSeq?: number;
30
32
  onOpenSubagentTranscript?: (args: {
31
33
  part: TranscriptViewSubagentPart;
32
34
  conversation: ConversationTranscript;
@@ -101,6 +103,8 @@ export function Transcript(props: {
101
103
  <ConversationEventLog
102
104
  key={props.transcript.conversationId}
103
105
  conversation={props.transcript}
106
+ onSelectedSeqChange={props.onSelectedEventSeqChange}
107
+ selectedSeq={props.selectedEventSeq}
104
108
  />
105
109
  ) : (
106
110
  <ConversationTranscriptView
@@ -10,3 +10,13 @@ export const NEW_CONVERSATION_PATH = "/";
10
10
  export function isNewConversationPath(pathname: string): boolean {
11
11
  return pathname === "/" || pathname === "/conversations/new";
12
12
  }
13
+
14
+ /** Query param that opens one event log entry by its sequence number. */
15
+ export const CONVERSATION_EVENT_PARAM = "event";
16
+
17
+ /** Read a linked event sequence number. Ignore values that are not one. */
18
+ export function parseConversationEventSeq(
19
+ value: string | null | undefined,
20
+ ): number | undefined {
21
+ return value && /^\d+$/.test(value) ? Number(value) : undefined;
22
+ }
@@ -25,6 +25,7 @@ import {
25
25
  conversationDetailReportSchema,
26
26
  conversationEventPageSchema,
27
27
  conversationPendingMessagesReportSchema,
28
+ forkConversationResponseSchema,
28
29
  } from "@sentry/junior/api/schema";
29
30
 
30
31
  import type { DashboardCoreData } from "../types";
@@ -227,6 +228,50 @@ export function useCreateConversation() {
227
228
  });
228
229
  }
229
230
 
231
+ /**
232
+ * Fork a conversation at one assistant reply and send the first message to
233
+ * the fork. A retry with the same key returns the same fork and message.
234
+ */
235
+ export function useForkConversation(sourceConversationId: string) {
236
+ const queryClient = useQueryClient();
237
+ return useMutation({
238
+ mutationFn: async (args: {
239
+ idempotencyKey: string;
240
+ message: string;
241
+ messageId: string;
242
+ }) => {
243
+ const fork = await post(
244
+ forkConversationResponseSchema,
245
+ `/api/conversations/${encodeURIComponent(sourceConversationId)}/forks`,
246
+ { messageId: args.messageId, idempotencyKey: args.idempotencyKey },
247
+ );
248
+ return post(
249
+ acceptedConversationMessageSchema,
250
+ `/api/conversations/${encodeURIComponent(fork.conversationId)}/messages`,
251
+ { idempotencyKey: args.idempotencyKey, message: args.message },
252
+ );
253
+ },
254
+ onSuccess: (accepted, args) => {
255
+ queryClient.setQueryData<ConversationOutboxMessage[]>(
256
+ conversationOutboxQueryKey(accepted.conversationId),
257
+ (current) =>
258
+ upsertConversationOutboxMessage(current, {
259
+ ...conversationOutboxMessageForSubmit({
260
+ idempotencyKey: args.idempotencyKey,
261
+ message: args.message,
262
+ actorIdentity: outboxActorIdentity(queryClient),
263
+ messageId: accepted.messageId,
264
+ }),
265
+ status: "accepted",
266
+ }),
267
+ );
268
+ void queryClient.invalidateQueries({
269
+ queryKey: ["dashboard", "conversations"],
270
+ });
271
+ },
272
+ });
273
+ }
274
+
230
275
  /** Add one dashboard message and refresh the shared transcript. */
231
276
  export function useAppendConversationMessage(conversationId: string) {
232
277
  const queryClient = useQueryClient();