@sentry/junior-dashboard 0.235.0 → 0.237.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.
- package/dist/app.js +14 -7
- package/dist/client.js +37 -36
- package/dist/index.d.ts +3 -1
- package/dist/index.js +14 -7
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/app.ts +7 -1
- package/src/assets/junior-avatar.png +0 -0
- package/src/assets/junior-favicon.png +0 -0
- package/src/client/App.tsx +22 -0
- package/src/client/api.ts +20 -0
- package/src/client/conversations/ConversationEventLog.tsx +45 -5
- package/src/client/conversations/ConversationForkLinks.tsx +51 -0
- package/src/client/conversations/ConversationHeader.tsx +12 -1
- package/src/client/conversations/ConversationPage.tsx +72 -2
- package/src/client/conversations/ForkConversationButton.tsx +125 -0
- package/src/client/conversations/TranscriptMessageView.tsx +28 -7
- package/src/client/conversations/TranscriptView.tsx +4 -0
- package/src/client/conversations/conversationRoutes.ts +10 -0
- package/src/client/conversations/queries.ts +45 -0
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +14 -6
- package/src/client/pages/automations/AutomationEditPage.tsx +20 -20
- package/src/client/pages/automations/AutomationEditor.tsx +65 -12
- package/src/client/pages/automations/AutomationLifecycle.tsx +6 -4
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +18 -0
- package/src/client/pages/automations/AutomationVersionsPage.tsx +356 -0
- package/src/client/pages/automations/README.md +14 -3
- package/src/mock-reporting/fixtures.ts +8 -2
package/src/client/App.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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 [
|
|
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={() =>
|
|
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 ||
|
|
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 [
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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();
|