@sentry/junior-dashboard 0.249.0 → 0.251.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/README.md +3 -0
- package/dist/app.js +70 -2
- package/dist/client.js +41 -40
- package/dist/index.js +70 -2
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/client/App.tsx +18 -0
- package/src/client/components/ProfileMenu.tsx +12 -0
- package/src/client/conversations/ConversationMeta.tsx +4 -31
- package/src/client/conversations/ConversationTranscript.tsx +18 -2
- package/src/client/conversations/ConversationUsageBreakdown.tsx +75 -34
- package/src/client/conversations/README.md +7 -0
- package/src/client/conversations/TranscriptAutomationTriggerView.tsx +117 -0
- package/src/client/pages/ConnectionsPage.tsx +207 -0
- package/src/client/pages/dev/ChartFixtures.tsx +265 -21
- package/src/mock-reporting/fixtures.ts +72 -0
|
@@ -486,45 +486,18 @@ function LocationLink(props: { label: string; locationUrl?: string }) {
|
|
|
486
486
|
function SourceTask(props: {
|
|
487
487
|
sourceTask: NonNullable<ConversationDetailReport["sourceTask"]>;
|
|
488
488
|
}) {
|
|
489
|
-
const kindLabel =
|
|
490
|
-
props.sourceTask.kind === "scheduled"
|
|
491
|
-
? "Scheduled automation"
|
|
492
|
-
: "Event automation";
|
|
493
489
|
const automationId = props.sourceTask.id?.trim();
|
|
494
490
|
const title = props.sourceTask.title?.trim();
|
|
495
|
-
const
|
|
491
|
+
const label = title ? `Automation: ${title}` : "Automation";
|
|
492
|
+
return automationId ? (
|
|
496
493
|
<Link
|
|
497
494
|
className={dashboardInlineLinkClass}
|
|
498
495
|
to={automationPath(automationId)}
|
|
499
496
|
>
|
|
500
|
-
|
|
497
|
+
{label}
|
|
501
498
|
</Link>
|
|
502
499
|
) : (
|
|
503
|
-
<span>
|
|
504
|
-
);
|
|
505
|
-
// Keep tooltips to short identity fields. Full task prompts belong on the
|
|
506
|
-
// task details page, not in hover chrome.
|
|
507
|
-
if (!title) return link;
|
|
508
|
-
return (
|
|
509
|
-
<Tooltip
|
|
510
|
-
align="left"
|
|
511
|
-
content={
|
|
512
|
-
<span className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
|
|
513
|
-
<span>Title</span>
|
|
514
|
-
<span className="text-dashboard-text">{title}</span>
|
|
515
|
-
{automationId ? (
|
|
516
|
-
<>
|
|
517
|
-
<span>ID</span>
|
|
518
|
-
<span className="break-all text-dashboard-text">
|
|
519
|
-
{automationId}
|
|
520
|
-
</span>
|
|
521
|
-
</>
|
|
522
|
-
) : null}
|
|
523
|
-
</span>
|
|
524
|
-
}
|
|
525
|
-
>
|
|
526
|
-
{link}
|
|
527
|
-
</Tooltip>
|
|
500
|
+
<span>{label}</span>
|
|
528
501
|
);
|
|
529
502
|
}
|
|
530
503
|
|
|
@@ -30,6 +30,10 @@ import {
|
|
|
30
30
|
TranscriptMessageView,
|
|
31
31
|
} from "./TranscriptMessageView";
|
|
32
32
|
import { TranscriptEventView } from "./TranscriptEventView";
|
|
33
|
+
import {
|
|
34
|
+
isAutomationTriggerMessage,
|
|
35
|
+
TranscriptAutomationTriggerView,
|
|
36
|
+
} from "./TranscriptAutomationTriggerView";
|
|
33
37
|
import { TranscriptTypingIndicator } from "./TranscriptTypingIndicator";
|
|
34
38
|
import {
|
|
35
39
|
groupTranscriptMessages,
|
|
@@ -196,7 +200,13 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
|
|
|
196
200
|
/>
|
|
197
201
|
)}
|
|
198
202
|
renderMessage={(entry) =>
|
|
199
|
-
|
|
203
|
+
props.conversation.sourceTask &&
|
|
204
|
+
isAutomationTriggerMessage(props.conversation, entry.message) ? (
|
|
205
|
+
<TranscriptAutomationTriggerView
|
|
206
|
+
message={entry.message}
|
|
207
|
+
sourceTask={props.conversation.sourceTask}
|
|
208
|
+
/>
|
|
209
|
+
) : entry.message.eventType ? (
|
|
200
210
|
<TranscriptRailEvent
|
|
201
211
|
kind="event"
|
|
202
212
|
objectType={entry.message.eventObjectType}
|
|
@@ -401,7 +411,13 @@ function RedactedTranscriptView(props: {
|
|
|
401
411
|
/>
|
|
402
412
|
)}
|
|
403
413
|
renderMessage={(entry) =>
|
|
404
|
-
|
|
414
|
+
props.conversation.sourceTask &&
|
|
415
|
+
isAutomationTriggerMessage(props.conversation, entry.message) ? (
|
|
416
|
+
<TranscriptAutomationTriggerView
|
|
417
|
+
message={entry.message}
|
|
418
|
+
sourceTask={props.conversation.sourceTask}
|
|
419
|
+
/>
|
|
420
|
+
) : entry.message.eventType ? (
|
|
405
421
|
<TranscriptRailEvent
|
|
406
422
|
kind="event"
|
|
407
423
|
objectType={entry.message.eventObjectType}
|
|
@@ -24,33 +24,58 @@ import {
|
|
|
24
24
|
tokenTypeRows,
|
|
25
25
|
} from "./TelemetryMetrics";
|
|
26
26
|
|
|
27
|
+
/** One token type of a model, shown as an indented row under the model. */
|
|
28
|
+
type BreakdownPart = {
|
|
29
|
+
cost?: string;
|
|
30
|
+
count: string;
|
|
31
|
+
label: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
27
34
|
type BreakdownRow = {
|
|
28
35
|
cost: string;
|
|
29
36
|
count: string;
|
|
30
|
-
/** Muted second line, such as the token types of one model. */
|
|
31
|
-
detail?: string;
|
|
32
37
|
key: string;
|
|
33
38
|
label: ReactNode;
|
|
39
|
+
parts?: BreakdownPart[];
|
|
34
40
|
};
|
|
35
41
|
|
|
36
42
|
const NOT_RECORDED = "—";
|
|
37
43
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
44
|
+
/**
|
|
45
|
+
* List each recorded token type of one model with its own count and, when
|
|
46
|
+
* the provider reports it, its own cost. Reasoning tokens are part of the
|
|
47
|
+
* output tokens, so they have no cost of their own.
|
|
48
|
+
*/
|
|
49
|
+
function tokenTypeParts(
|
|
40
50
|
usage: ConversationModelUsage["usage"],
|
|
41
|
-
|
|
51
|
+
formatCost: (value: number) => string,
|
|
52
|
+
): BreakdownPart[] | undefined {
|
|
42
53
|
const summary = summarizeUsage(usage);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
:
|
|
48
|
-
|
|
54
|
+
if (!summary) return undefined;
|
|
55
|
+
const cost = summarizeCost(usage);
|
|
56
|
+
const costs: Readonly<Record<string, number | undefined>> = {
|
|
57
|
+
cached: cost?.cacheRead,
|
|
58
|
+
"cache write": cost?.cacheWrite,
|
|
59
|
+
input: cost?.input,
|
|
60
|
+
output: cost?.output,
|
|
61
|
+
};
|
|
62
|
+
const parts = tokenTypeRows(summary).map((row) => {
|
|
63
|
+
const value = costs[row.label];
|
|
64
|
+
return {
|
|
65
|
+
cost: value === undefined ? undefined : formatCost(value),
|
|
66
|
+
count: row.value,
|
|
67
|
+
label: row.label === "reasoning" ? "reasoning (in output)" : row.label,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
return parts.length ? parts : undefined;
|
|
49
71
|
}
|
|
50
72
|
|
|
73
|
+
const CELL = "text-right align-top font-sans tabular-nums";
|
|
74
|
+
|
|
51
75
|
/**
|
|
52
76
|
* One usage table. Every table in the breakdown shares column widths, so the
|
|
53
|
-
* cost column lines up from table to table.
|
|
77
|
+
* cost column lines up from table to table. Each row is its own `tbody`, so
|
|
78
|
+
* the token types of a model stay grouped under it.
|
|
54
79
|
*/
|
|
55
80
|
function BreakdownTable(props: {
|
|
56
81
|
/** Header of the count column, such as Tokens or Calls. */
|
|
@@ -83,31 +108,47 @@ function BreakdownTable(props: {
|
|
|
83
108
|
</th>
|
|
84
109
|
</tr>
|
|
85
110
|
</thead>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
>
|
|
92
|
-
<th
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
{row.
|
|
97
|
-
<span className="mt-0.5 block text-dashboard-text-faint">
|
|
98
|
-
{row.detail}
|
|
99
|
-
</span>
|
|
100
|
-
) : null}
|
|
111
|
+
{props.rows.map((row) => (
|
|
112
|
+
<tbody
|
|
113
|
+
className="border-b border-dashboard-border-subtle last-of-type:border-b-0"
|
|
114
|
+
key={row.key}
|
|
115
|
+
>
|
|
116
|
+
<tr className="text-dashboard-text">
|
|
117
|
+
<th
|
|
118
|
+
scope="row"
|
|
119
|
+
className={`pr-3 text-left font-normal ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}
|
|
120
|
+
>
|
|
121
|
+
<span className="block truncate">{row.label}</span>
|
|
101
122
|
</th>
|
|
102
|
-
<td className="
|
|
123
|
+
<td className={`${CELL} ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}>
|
|
103
124
|
{row.count}
|
|
104
125
|
</td>
|
|
105
|
-
<td className="
|
|
126
|
+
<td className={`${CELL} ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}>
|
|
106
127
|
{row.cost}
|
|
107
128
|
</td>
|
|
108
129
|
</tr>
|
|
109
|
-
|
|
110
|
-
|
|
130
|
+
{row.parts?.map((part, index, parts) => {
|
|
131
|
+
const padding = index === parts.length - 1 ? "pb-2" : "";
|
|
132
|
+
return (
|
|
133
|
+
<tr
|
|
134
|
+
className="text-2xs leading-5 text-dashboard-text-faint"
|
|
135
|
+
key={part.label}
|
|
136
|
+
>
|
|
137
|
+
<th
|
|
138
|
+
scope="row"
|
|
139
|
+
className={`truncate pl-3 pr-3 text-left font-normal ${padding}`}
|
|
140
|
+
>
|
|
141
|
+
{part.label}
|
|
142
|
+
</th>
|
|
143
|
+
<td className={`${CELL} ${padding}`}>{part.count}</td>
|
|
144
|
+
<td className={`${CELL} ${padding}`}>{part.cost}</td>
|
|
145
|
+
</tr>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</tbody>
|
|
149
|
+
))}
|
|
150
|
+
{props.total ? (
|
|
151
|
+
<tfoot>
|
|
111
152
|
<tr className="border-t border-dashboard-border font-semibold text-dashboard-text">
|
|
112
153
|
<th scope="row" className="py-2 text-left font-semibold">
|
|
113
154
|
{props.total.label}
|
|
@@ -119,8 +160,8 @@ function BreakdownTable(props: {
|
|
|
119
160
|
{props.total.cost}
|
|
120
161
|
</td>
|
|
121
162
|
</tr>
|
|
122
|
-
|
|
123
|
-
|
|
163
|
+
</tfoot>
|
|
164
|
+
) : null}
|
|
124
165
|
</table>
|
|
125
166
|
</div>
|
|
126
167
|
);
|
|
@@ -217,7 +258,6 @@ export function ConversationUsageBreakdown(props: {
|
|
|
217
258
|
rows={models.map(({ cost: itemCost, item, tokens: itemTokens }) => ({
|
|
218
259
|
cost: formatMaybeCost(itemCost),
|
|
219
260
|
count: formatTokens(itemTokens),
|
|
220
|
-
detail: tokenTypes(item.usage),
|
|
221
261
|
key: item.modelId,
|
|
222
262
|
label: (
|
|
223
263
|
<span className="flex min-w-0 items-baseline gap-2">
|
|
@@ -234,6 +274,7 @@ export function ConversationUsageBreakdown(props: {
|
|
|
234
274
|
) : null}
|
|
235
275
|
</span>
|
|
236
276
|
),
|
|
277
|
+
parts: tokenTypeParts(item.usage, formatCost),
|
|
237
278
|
}))}
|
|
238
279
|
total={
|
|
239
280
|
models.length > 1
|
|
@@ -25,6 +25,13 @@ shared controls and overlays.
|
|
|
25
25
|
- `TranscriptSubagentView` renders a child agent as a card with its name,
|
|
26
26
|
status, run time, and title. It matches the child agent rows in the details
|
|
27
27
|
drawer and opens the child transcript.
|
|
28
|
+
- `TranscriptAutomationTriggerView` renders the input of an Automation run as
|
|
29
|
+
one card. The report names that message in `sourceTask.triggerMessageId`.
|
|
30
|
+
The card shows the Automation name and a link to it. A second line shows the
|
|
31
|
+
trigger, the run time, and whose credentials the run used. The credential
|
|
32
|
+
part comes from the run's dispatch record, not the Automation's current
|
|
33
|
+
setting. It is left out when that record is gone. The run input is not from
|
|
34
|
+
a person, so it stays collapsed behind "Show prompt".
|
|
28
35
|
- `TranscriptAttachments` owns wrapping for both message attachments and file
|
|
29
36
|
deliveries. `ImageAttachment` owns image previews.
|
|
30
37
|
- `TranscriptTurnContextView` owns context content. The shared `Drawer` owns
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ArrowRight, ChevronRight } from "lucide-react";
|
|
2
|
+
import { useId, useState } from "react";
|
|
3
|
+
import type { ConversationDetailReport } from "@sentry/junior/api/schema";
|
|
4
|
+
|
|
5
|
+
import { ButtonLink } from "../components/Button";
|
|
6
|
+
import { ObjectIcon } from "../components/ObjectIcon";
|
|
7
|
+
import { automationPath, formatRelativeTime } from "../format";
|
|
8
|
+
import { messageRawText } from "./transcriptRenderModel";
|
|
9
|
+
import { HighlightText } from "./transcriptSearch";
|
|
10
|
+
import { RedactedMarker } from "./TranscriptRedacted";
|
|
11
|
+
import { cn } from "../styles";
|
|
12
|
+
import type { ConversationTranscript, TranscriptViewMessage } from "../types";
|
|
13
|
+
|
|
14
|
+
type SourceTask = NonNullable<ConversationDetailReport["sourceTask"]>;
|
|
15
|
+
|
|
16
|
+
/** True when a message is the trigger input of an Automation run. */
|
|
17
|
+
export function isAutomationTriggerMessage(
|
|
18
|
+
conversation: ConversationTranscript,
|
|
19
|
+
message: TranscriptViewMessage,
|
|
20
|
+
): boolean {
|
|
21
|
+
const triggerMessageId = conversation.sourceTask?.triggerMessageId;
|
|
22
|
+
return Boolean(
|
|
23
|
+
triggerMessageId &&
|
|
24
|
+
message.role === "user" &&
|
|
25
|
+
message.messageId === triggerMessageId,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runsAsLabel(sourceTask: SourceTask): string | undefined {
|
|
30
|
+
if (sourceTask.credentialMode === "system") return "Runs as system";
|
|
31
|
+
if (sourceTask.credentialMode === "creator" && sourceTask.credentialUser) {
|
|
32
|
+
return `Runs as ${sourceTask.credentialUser}`;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Show which Automation started this run, what triggered it, and whose
|
|
39
|
+
* credentials it used. The run input is not from a person, so it stays
|
|
40
|
+
* collapsed behind "Show prompt".
|
|
41
|
+
*/
|
|
42
|
+
export function TranscriptAutomationTriggerView(props: {
|
|
43
|
+
message: TranscriptViewMessage;
|
|
44
|
+
sourceTask: SourceTask;
|
|
45
|
+
}) {
|
|
46
|
+
const text = messageRawText(props.message);
|
|
47
|
+
const redacted = props.message.parts.some(
|
|
48
|
+
(part) => part.type === "text" && part.redacted,
|
|
49
|
+
);
|
|
50
|
+
const [open, setOpen] = useState(false);
|
|
51
|
+
const promptId = useId();
|
|
52
|
+
const automationId = props.sourceTask.id?.trim();
|
|
53
|
+
const name = props.sourceTask.title?.trim() || "Automation";
|
|
54
|
+
const details = [
|
|
55
|
+
props.sourceTask.triggerLabel,
|
|
56
|
+
props.message.timestamp
|
|
57
|
+
? formatRelativeTime(new Date(props.message.timestamp).toISOString())
|
|
58
|
+
: undefined,
|
|
59
|
+
runsAsLabel(props.sourceTask),
|
|
60
|
+
].filter(Boolean);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<section
|
|
64
|
+
aria-label={name}
|
|
65
|
+
className="grid w-full min-w-0 max-w-[37.5rem] gap-2 rounded-xl border border-dashboard-border-emphasis bg-dashboard-surface-panel px-4 py-3"
|
|
66
|
+
>
|
|
67
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
68
|
+
<ObjectIcon objectType="automation" size={16} decorative />
|
|
69
|
+
<span className="min-w-0 flex-1 truncate font-sans text-sm font-semibold text-dashboard-text">
|
|
70
|
+
<HighlightText text={name} />
|
|
71
|
+
</span>
|
|
72
|
+
{automationId ? (
|
|
73
|
+
<ButtonLink
|
|
74
|
+
className="h-8 shrink-0 text-xs"
|
|
75
|
+
to={automationPath(automationId)}
|
|
76
|
+
>
|
|
77
|
+
Open automation
|
|
78
|
+
<ArrowRight aria-hidden="true" size={14} />
|
|
79
|
+
</ButtonLink>
|
|
80
|
+
) : null}
|
|
81
|
+
</div>
|
|
82
|
+
{details.length > 0 ? (
|
|
83
|
+
<p className="m-0 min-w-0 break-words text-xs leading-relaxed text-dashboard-text-muted">
|
|
84
|
+
{details.join(" · ")}
|
|
85
|
+
</p>
|
|
86
|
+
) : null}
|
|
87
|
+
{text ? (
|
|
88
|
+
<div className="min-w-0">
|
|
89
|
+
<button
|
|
90
|
+
aria-controls={promptId}
|
|
91
|
+
aria-expanded={open}
|
|
92
|
+
className="-mx-1 inline-flex cursor-pointer items-center gap-1 rounded-sm border-0 bg-transparent px-1 py-0.5 font-sans text-xs font-medium text-dashboard-text-muted transition-colors hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus"
|
|
93
|
+
onClick={() => setOpen((value) => !value)}
|
|
94
|
+
type="button"
|
|
95
|
+
>
|
|
96
|
+
<ChevronRight
|
|
97
|
+
aria-hidden="true"
|
|
98
|
+
className={cn("size-3 transition-transform", open && "rotate-90")}
|
|
99
|
+
strokeWidth={2.2}
|
|
100
|
+
/>
|
|
101
|
+
{open ? "Hide prompt" : "Show prompt"}
|
|
102
|
+
</button>
|
|
103
|
+
{open ? (
|
|
104
|
+
<pre
|
|
105
|
+
className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-dashboard-border-subtle bg-dashboard-bg px-3 py-2 font-mono text-xs leading-relaxed text-dashboard-text-muted"
|
|
106
|
+
id={promptId}
|
|
107
|
+
>
|
|
108
|
+
<HighlightText text={text} />
|
|
109
|
+
</pre>
|
|
110
|
+
) : null}
|
|
111
|
+
</div>
|
|
112
|
+
) : redacted ? (
|
|
113
|
+
<RedactedMarker />
|
|
114
|
+
) : null}
|
|
115
|
+
</section>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { Plug } from "lucide-react";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
connectionListSchema,
|
|
6
|
+
disconnectConnectionResponseSchema,
|
|
7
|
+
startConnectionResponseSchema,
|
|
8
|
+
type connectionSchema,
|
|
9
|
+
} from "@sentry/junior/api/schema";
|
|
10
|
+
import { del, fetchDashboardJson, post } from "../http";
|
|
11
|
+
import { dashboardContainerClass } from "../styles";
|
|
12
|
+
import { getDashboardAgentName } from "../agentName";
|
|
13
|
+
import { useDocumentTitle } from "../documentTitle";
|
|
14
|
+
import { Button } from "../components/Button";
|
|
15
|
+
import { InlineError } from "../components/InlineError";
|
|
16
|
+
import { Card } from "../components/layout/Card";
|
|
17
|
+
|
|
18
|
+
type Connection = z.infer<typeof connectionSchema>;
|
|
19
|
+
|
|
20
|
+
const connectionsQueryKey = ["dashboard", "connections"] as const;
|
|
21
|
+
|
|
22
|
+
function connectionPath(provider: string): string {
|
|
23
|
+
return `/api/connections/${encodeURIComponent(provider)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Connect and disconnect the viewer's plugin accounts. */
|
|
27
|
+
export function ConnectionsPage() {
|
|
28
|
+
useDocumentTitle("Connected accounts");
|
|
29
|
+
const queryClient = useQueryClient();
|
|
30
|
+
const connectionsQuery = useQuery({
|
|
31
|
+
queryKey: connectionsQueryKey,
|
|
32
|
+
queryFn: ({ signal }) =>
|
|
33
|
+
fetchDashboardJson(connectionListSchema, "/api/connections", signal),
|
|
34
|
+
// OAuth finishes in another tab. Refresh when the viewer returns.
|
|
35
|
+
refetchOnWindowFocus: true,
|
|
36
|
+
retry: false,
|
|
37
|
+
});
|
|
38
|
+
const connectMutation = useMutation({
|
|
39
|
+
mutationFn: (input: { provider: string; popup: Window | null }) =>
|
|
40
|
+
post(startConnectionResponseSchema, connectionPath(input.provider), {}),
|
|
41
|
+
onSuccess: ({ authorizationUrl }, { popup }) => {
|
|
42
|
+
// A blocked popup returns null, so use this tab. A tab the viewer
|
|
43
|
+
// closed means they cancelled; keep them on the dashboard.
|
|
44
|
+
if (!popup) {
|
|
45
|
+
window.location.assign(authorizationUrl);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (popup.closed) return;
|
|
49
|
+
popup.opener = null;
|
|
50
|
+
popup.location.href = authorizationUrl;
|
|
51
|
+
},
|
|
52
|
+
onError: (_error, { popup }) => {
|
|
53
|
+
popup?.close();
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const disconnectMutation = useMutation({
|
|
57
|
+
mutationFn: (connection: Connection) =>
|
|
58
|
+
del(
|
|
59
|
+
disconnectConnectionResponseSchema,
|
|
60
|
+
connectionPath(connection.provider),
|
|
61
|
+
),
|
|
62
|
+
onSuccess: (_result, connection) => {
|
|
63
|
+
queryClient.setQueryData<{ connections: Connection[] }>(
|
|
64
|
+
connectionsQueryKey,
|
|
65
|
+
(current) =>
|
|
66
|
+
current && {
|
|
67
|
+
connections: current.connections.map((item) =>
|
|
68
|
+
item.provider === connection.provider
|
|
69
|
+
? {
|
|
70
|
+
provider: item.provider,
|
|
71
|
+
displayName: item.displayName,
|
|
72
|
+
description: item.description,
|
|
73
|
+
connected: false,
|
|
74
|
+
}
|
|
75
|
+
: item,
|
|
76
|
+
),
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const connections = connectionsQuery.data?.connections ?? [];
|
|
82
|
+
const busy = connectMutation.isPending || disconnectMutation.isPending;
|
|
83
|
+
const pendingProvider = connectMutation.isPending
|
|
84
|
+
? connectMutation.variables?.provider
|
|
85
|
+
: disconnectMutation.isPending
|
|
86
|
+
? disconnectMutation.variables?.provider
|
|
87
|
+
: undefined;
|
|
88
|
+
const listFailed = !connectionsQuery.data && connectionsQuery.isError;
|
|
89
|
+
const displayedError = connectMutation.isError
|
|
90
|
+
? "Could not start the connection. Try again."
|
|
91
|
+
: disconnectMutation.isError
|
|
92
|
+
? "Could not disconnect the account. Try again."
|
|
93
|
+
: listFailed
|
|
94
|
+
? "Could not load connected accounts. Try again."
|
|
95
|
+
: undefined;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className={`${dashboardContainerClass} px-4 py-8 md:px-8`}>
|
|
99
|
+
<section className="mx-auto w-full max-w-3xl">
|
|
100
|
+
<h1 className="m-0 text-2xl font-bold">Connected accounts</h1>
|
|
101
|
+
<p className="mt-2 mb-0 max-w-2xl text-sm text-dashboard-text-muted">
|
|
102
|
+
Connect an account so {getDashboardAgentName()} can act as you in
|
|
103
|
+
conversations you start here.
|
|
104
|
+
</p>
|
|
105
|
+
|
|
106
|
+
<Card className="mb-0 mt-6" padding="md" variant="raised">
|
|
107
|
+
{displayedError ? (
|
|
108
|
+
<InlineError className={listFailed ? undefined : "mb-4"}>
|
|
109
|
+
{displayedError}
|
|
110
|
+
</InlineError>
|
|
111
|
+
) : null}
|
|
112
|
+
{listFailed ? null : (
|
|
113
|
+
<div className="space-y-2">
|
|
114
|
+
{connectionsQuery.isPending ? (
|
|
115
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
116
|
+
Loading connected accounts…
|
|
117
|
+
</p>
|
|
118
|
+
) : connections.length === 0 ? (
|
|
119
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
120
|
+
No installed plugins support account connections.
|
|
121
|
+
</p>
|
|
122
|
+
) : (
|
|
123
|
+
connections.map((connection) => (
|
|
124
|
+
<div
|
|
125
|
+
className="flex items-center gap-3 rounded-md border border-dashboard-border-strong p-3"
|
|
126
|
+
key={connection.provider}
|
|
127
|
+
>
|
|
128
|
+
<Plug
|
|
129
|
+
className="shrink-0 text-dashboard-accent"
|
|
130
|
+
size={16}
|
|
131
|
+
/>
|
|
132
|
+
<div className="min-w-0 flex-1">
|
|
133
|
+
<p className="m-0 truncate text-sm font-semibold">
|
|
134
|
+
{connection.displayName}
|
|
135
|
+
</p>
|
|
136
|
+
<p className="mt-1 mb-0 line-clamp-2 break-words text-xs text-dashboard-text-muted">
|
|
137
|
+
{connection.connected ? (
|
|
138
|
+
connection.account ? (
|
|
139
|
+
<>
|
|
140
|
+
Connected as{" "}
|
|
141
|
+
{connection.account.url ? (
|
|
142
|
+
<a
|
|
143
|
+
className="text-dashboard-text"
|
|
144
|
+
href={connection.account.url}
|
|
145
|
+
rel="noreferrer"
|
|
146
|
+
target="_blank"
|
|
147
|
+
>
|
|
148
|
+
{connection.account.label}
|
|
149
|
+
</a>
|
|
150
|
+
) : (
|
|
151
|
+
connection.account.label
|
|
152
|
+
)}
|
|
153
|
+
</>
|
|
154
|
+
) : (
|
|
155
|
+
"Connected"
|
|
156
|
+
)
|
|
157
|
+
) : (
|
|
158
|
+
connection.description
|
|
159
|
+
)}
|
|
160
|
+
</p>
|
|
161
|
+
</div>
|
|
162
|
+
{connection.connected ? (
|
|
163
|
+
<Button
|
|
164
|
+
aria-busy={pendingProvider === connection.provider}
|
|
165
|
+
aria-label={`Disconnect ${connection.displayName}`}
|
|
166
|
+
disabled={busy}
|
|
167
|
+
onClick={() => {
|
|
168
|
+
connectMutation.reset();
|
|
169
|
+
disconnectMutation.mutate(connection);
|
|
170
|
+
}}
|
|
171
|
+
tone="danger"
|
|
172
|
+
>
|
|
173
|
+
{pendingProvider === connection.provider
|
|
174
|
+
? "Disconnecting…"
|
|
175
|
+
: "Disconnect"}
|
|
176
|
+
</Button>
|
|
177
|
+
) : (
|
|
178
|
+
<Button
|
|
179
|
+
aria-busy={pendingProvider === connection.provider}
|
|
180
|
+
aria-label={`Connect ${connection.displayName}`}
|
|
181
|
+
disabled={busy}
|
|
182
|
+
onClick={() => {
|
|
183
|
+
disconnectMutation.reset();
|
|
184
|
+
// Open the tab during the click so popup blockers
|
|
185
|
+
// allow it, then point it at the provider.
|
|
186
|
+
const popup = window.open("about:blank", "_blank");
|
|
187
|
+
connectMutation.mutate({
|
|
188
|
+
provider: connection.provider,
|
|
189
|
+
popup,
|
|
190
|
+
});
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
{pendingProvider === connection.provider
|
|
194
|
+
? "Connecting…"
|
|
195
|
+
: "Connect"}
|
|
196
|
+
</Button>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
))
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</Card>
|
|
204
|
+
</section>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|