@sentry/junior-dashboard 0.269.0 → 0.271.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 +2 -2
- package/dist/client.js +78 -75
- package/dist/index.js +2 -2
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/client/App.tsx +103 -65
- package/src/client/components/AGENTS.md +30 -59
- package/src/client/components/AutomationCard.tsx +2 -1
- package/src/client/components/ProfileMenu.tsx +0 -24
- package/src/client/components/UserInputForm.tsx +143 -39
- package/src/client/components/layout/PageHeader.tsx +25 -10
- package/src/client/conversations/AGENTS.md +12 -37
- package/src/client/conversations/ConversationBrief.tsx +55 -36
- package/src/client/conversations/ConversationComposer.tsx +27 -8
- package/src/client/conversations/ConversationEventLog.tsx +1 -0
- package/src/client/conversations/ConversationHomeList.tsx +43 -26
- package/src/client/conversations/ConversationPage.tsx +137 -40
- package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
- package/src/client/conversations/ConversationSidebar.tsx +55 -25
- package/src/client/conversations/ConversationTranscript.tsx +3 -2
- package/src/client/conversations/ConversationWatches.tsx +4 -1
- package/src/client/conversations/ConversationWorkspace.tsx +43 -25
- package/src/client/conversations/EventDetails.tsx +6 -9
- package/src/client/conversations/PendingMailboxStack.tsx +5 -2
- package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
- package/src/client/conversations/TranscriptEventView.tsx +9 -3
- package/src/client/conversations/TranscriptMessageView.tsx +6 -5
- package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
- package/src/client/conversations/TranscriptRows.tsx +6 -1
- package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
- package/src/client/conversations/TranscriptToolView.tsx +2 -1
- package/src/client/conversations/TranscriptView.tsx +6 -6
- package/src/client/conversations/conversationOutbox.ts +13 -3
- package/src/client/conversations/conversationRoutes.ts +30 -3
- package/src/client/conversations/conversationSections.ts +36 -8
- package/src/client/conversations/eventTranscript.ts +1 -0
- package/src/client/conversations/transcriptBottomPinning.ts +171 -81
- package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
- package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
- package/src/client/display-text/DisplayText.tsx +68 -0
- package/src/client/display-text/emoji.ts +54 -0
- package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
- package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
- package/src/client/display-text/mentions.tsx +148 -0
- package/src/client/display-text/plainText.ts +29 -0
- package/src/client/pages/automations/AGENTS.md +6 -25
- package/src/client/pages/automations/AutomationFilters.tsx +6 -6
- package/src/client/pages/automations/AutomationOverview.tsx +2 -2
- package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
- package/src/client/pages/code/RepositoryTabs.tsx +6 -1
- package/src/client/pages/memory/MemoryDetails.tsx +5 -13
- package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
- package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
- package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
- package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +54 -62
- package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
- package/src/client/primaryNav.ts +1 -1
- package/src/client/review-desk/ConversationReviewBar.tsx +0 -1
- package/src/client/review-desk/ReviewDeskPage.tsx +202 -241
- package/src/client/review-desk/ReviewDeskSidebar.tsx +253 -0
- package/src/client/review-desk/ReviewItemActions.tsx +6 -2
- package/src/client/review-desk/ReviewItemCard.tsx +58 -17
- package/src/client/review-desk/ReviewItemContext.tsx +108 -0
- package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
- package/src/client/review-desk/ReviewItemView.tsx +159 -0
- package/src/client/review-desk/UnsentAnswers.tsx +148 -0
- package/src/client/review-desk/queries.ts +4 -2
- package/src/client/review-desk/reviewItemFormat.ts +83 -1
- package/src/client/components/charts/AGENTS.md +0 -15
- package/src/client/conversations/TranscriptText.tsx +0 -47
- package/src/client/conversations/conversationActivityPreview.ts +0 -16
- package/src/client/conversations/transcriptMentions.tsx +0 -134
- package/src/client/pages/ConnectionsPage.tsx +0 -207
- package/src/client/pages/PersonalTokensPage.tsx +0 -205
- package/src/client/pages/system/AGENTS.md +0 -12
- package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
|
@@ -0,0 +1,198 @@
|
|
|
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 { getDashboardAgentName } from "../../agentName";
|
|
12
|
+
import { Button } from "../../components/ui/button";
|
|
13
|
+
import { InlineError } from "../../components/InlineError";
|
|
14
|
+
import { Card } from "../../components/layout/Card";
|
|
15
|
+
import { SettingsPageLayout } from "./SettingsPageLayout";
|
|
16
|
+
|
|
17
|
+
type Connection = z.infer<typeof connectionSchema>;
|
|
18
|
+
|
|
19
|
+
const connectionsQueryKey = ["dashboard", "connections"] as const;
|
|
20
|
+
|
|
21
|
+
function connectionPath(provider: string): string {
|
|
22
|
+
return `/api/connections/${encodeURIComponent(provider)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Connect and disconnect the viewer's plugin accounts. */
|
|
26
|
+
export function ConnectionsPage() {
|
|
27
|
+
const queryClient = useQueryClient();
|
|
28
|
+
const connectionsQuery = useQuery({
|
|
29
|
+
queryKey: connectionsQueryKey,
|
|
30
|
+
queryFn: ({ signal }) =>
|
|
31
|
+
fetchDashboardJson(connectionListSchema, "/api/connections", signal),
|
|
32
|
+
// OAuth finishes in another tab. Refresh when the viewer returns.
|
|
33
|
+
refetchOnWindowFocus: true,
|
|
34
|
+
retry: false,
|
|
35
|
+
});
|
|
36
|
+
const connectMutation = useMutation({
|
|
37
|
+
mutationFn: (input: { provider: string; popup: Window | null }) =>
|
|
38
|
+
post(startConnectionResponseSchema, connectionPath(input.provider), {}),
|
|
39
|
+
onSuccess: ({ authorizationUrl }, { popup }) => {
|
|
40
|
+
// A blocked popup returns null, so use this tab. A tab the viewer
|
|
41
|
+
// closed means they cancelled; keep them on the dashboard.
|
|
42
|
+
if (!popup) {
|
|
43
|
+
window.location.assign(authorizationUrl);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (popup.closed) return;
|
|
47
|
+
popup.opener = null;
|
|
48
|
+
popup.location.href = authorizationUrl;
|
|
49
|
+
},
|
|
50
|
+
onError: (_error, { popup }) => {
|
|
51
|
+
popup?.close();
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const disconnectMutation = useMutation({
|
|
55
|
+
mutationFn: (connection: Connection) =>
|
|
56
|
+
del(
|
|
57
|
+
disconnectConnectionResponseSchema,
|
|
58
|
+
connectionPath(connection.provider),
|
|
59
|
+
),
|
|
60
|
+
onSuccess: (_result, connection) => {
|
|
61
|
+
queryClient.setQueryData<{ connections: Connection[] }>(
|
|
62
|
+
connectionsQueryKey,
|
|
63
|
+
(current) =>
|
|
64
|
+
current && {
|
|
65
|
+
connections: current.connections.map((item) =>
|
|
66
|
+
item.provider === connection.provider
|
|
67
|
+
? {
|
|
68
|
+
provider: item.provider,
|
|
69
|
+
displayName: item.displayName,
|
|
70
|
+
description: item.description,
|
|
71
|
+
connected: false,
|
|
72
|
+
}
|
|
73
|
+
: item,
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const connections = connectionsQuery.data?.connections ?? [];
|
|
80
|
+
const busy = connectMutation.isPending || disconnectMutation.isPending;
|
|
81
|
+
const pendingProvider = connectMutation.isPending
|
|
82
|
+
? connectMutation.variables?.provider
|
|
83
|
+
: disconnectMutation.isPending
|
|
84
|
+
? disconnectMutation.variables?.provider
|
|
85
|
+
: undefined;
|
|
86
|
+
const listFailed = !connectionsQuery.data && connectionsQuery.isError;
|
|
87
|
+
const displayedError = connectMutation.isError
|
|
88
|
+
? "Could not start the connection. Try again."
|
|
89
|
+
: disconnectMutation.isError
|
|
90
|
+
? "Could not disconnect the account. Try again."
|
|
91
|
+
: listFailed
|
|
92
|
+
? "Could not load identities. Try again."
|
|
93
|
+
: undefined;
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<SettingsPageLayout
|
|
97
|
+
description={
|
|
98
|
+
<>
|
|
99
|
+
Connect an account so {getDashboardAgentName()} can act as you in
|
|
100
|
+
conversations you start here.
|
|
101
|
+
</>
|
|
102
|
+
}
|
|
103
|
+
title="Identities"
|
|
104
|
+
>
|
|
105
|
+
{displayedError ? <InlineError>{displayedError}</InlineError> : null}
|
|
106
|
+
{listFailed ? null : (
|
|
107
|
+
<Card className="mb-0" variant="raised">
|
|
108
|
+
{connectionsQuery.isPending ? (
|
|
109
|
+
<p className="m-0 p-5 text-sm text-dashboard-text-muted">
|
|
110
|
+
Loading identities…
|
|
111
|
+
</p>
|
|
112
|
+
) : connections.length === 0 ? (
|
|
113
|
+
<p className="m-0 p-5 text-sm text-dashboard-text-muted">
|
|
114
|
+
No installed plugins support account connections.
|
|
115
|
+
</p>
|
|
116
|
+
) : (
|
|
117
|
+
<ul className="m-0 list-none divide-y divide-dashboard-border-subtle p-0">
|
|
118
|
+
{connections.map((connection) => (
|
|
119
|
+
<li
|
|
120
|
+
className="flex items-center gap-3 px-5 py-4"
|
|
121
|
+
key={connection.provider}
|
|
122
|
+
>
|
|
123
|
+
<Plug className="shrink-0 text-dashboard-accent" size={16} />
|
|
124
|
+
<div className="min-w-0 flex-1">
|
|
125
|
+
<p className="m-0 truncate text-sm font-semibold">
|
|
126
|
+
{connection.displayName}
|
|
127
|
+
</p>
|
|
128
|
+
<p className="mt-1 mb-0 line-clamp-2 break-words text-xs text-dashboard-text-muted">
|
|
129
|
+
{connection.connected ? (
|
|
130
|
+
connection.account ? (
|
|
131
|
+
<>
|
|
132
|
+
Connected as{" "}
|
|
133
|
+
{connection.account.url ? (
|
|
134
|
+
<a
|
|
135
|
+
className="text-dashboard-text"
|
|
136
|
+
href={connection.account.url}
|
|
137
|
+
rel="noreferrer"
|
|
138
|
+
target="_blank"
|
|
139
|
+
>
|
|
140
|
+
{connection.account.label}
|
|
141
|
+
</a>
|
|
142
|
+
) : (
|
|
143
|
+
connection.account.label
|
|
144
|
+
)}
|
|
145
|
+
</>
|
|
146
|
+
) : (
|
|
147
|
+
"Connected"
|
|
148
|
+
)
|
|
149
|
+
) : (
|
|
150
|
+
connection.description
|
|
151
|
+
)}
|
|
152
|
+
</p>
|
|
153
|
+
</div>
|
|
154
|
+
{connection.connected ? (
|
|
155
|
+
<Button
|
|
156
|
+
aria-busy={pendingProvider === connection.provider}
|
|
157
|
+
aria-label={`Disconnect ${connection.displayName}`}
|
|
158
|
+
disabled={busy}
|
|
159
|
+
onClick={() => {
|
|
160
|
+
connectMutation.reset();
|
|
161
|
+
disconnectMutation.mutate(connection);
|
|
162
|
+
}}
|
|
163
|
+
variant="danger"
|
|
164
|
+
>
|
|
165
|
+
{pendingProvider === connection.provider
|
|
166
|
+
? "Disconnecting…"
|
|
167
|
+
: "Disconnect"}
|
|
168
|
+
</Button>
|
|
169
|
+
) : (
|
|
170
|
+
<Button
|
|
171
|
+
aria-busy={pendingProvider === connection.provider}
|
|
172
|
+
aria-label={`Connect ${connection.displayName}`}
|
|
173
|
+
disabled={busy}
|
|
174
|
+
onClick={() => {
|
|
175
|
+
disconnectMutation.reset();
|
|
176
|
+
// Open the tab during the click so popup blockers
|
|
177
|
+
// allow it, then point it at the provider.
|
|
178
|
+
const popup = window.open("about:blank", "_blank");
|
|
179
|
+
connectMutation.mutate({
|
|
180
|
+
provider: connection.provider,
|
|
181
|
+
popup,
|
|
182
|
+
});
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{pendingProvider === connection.provider
|
|
186
|
+
? "Connecting…"
|
|
187
|
+
: "Connect"}
|
|
188
|
+
</Button>
|
|
189
|
+
)}
|
|
190
|
+
</li>
|
|
191
|
+
))}
|
|
192
|
+
</ul>
|
|
193
|
+
)}
|
|
194
|
+
</Card>
|
|
195
|
+
)}
|
|
196
|
+
</SettingsPageLayout>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getDashboardAgentName } from "../../agentName";
|
|
2
|
+
import { CopyButton } from "../../components/CopyButton";
|
|
3
|
+
import { dashboardInlineLinkClass } from "../../styles";
|
|
4
|
+
import { SettingsPageLayout } from "./SettingsPageLayout";
|
|
5
|
+
|
|
6
|
+
const MCP_DOCS_URL = "https://junior.sentry.dev/operate/mcp-server/";
|
|
7
|
+
|
|
8
|
+
/** Name the MCP server entry after the agent, for example `junior`. */
|
|
9
|
+
function mcpServerName(): string {
|
|
10
|
+
return (
|
|
11
|
+
getDashboardAgentName()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
14
|
+
.replace(/^-+|-+$/g, "") || "junior"
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ConfigBlock(props: { label: string; value: string }) {
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<div className="flex items-center justify-between gap-3">
|
|
22
|
+
<h2 className="m-0 text-sm font-semibold">{props.label}</h2>
|
|
23
|
+
<CopyButton getText={() => props.value} label={`Copy ${props.label}`} />
|
|
24
|
+
</div>
|
|
25
|
+
<pre className="mt-2 mb-0 whitespace-pre-wrap break-words rounded-md border border-dashboard-border-emphasis bg-dashboard-ink p-3 font-mono text-xs text-dashboard-text">
|
|
26
|
+
{props.value}
|
|
27
|
+
</pre>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Show the MCP server URL and client config for this deployment. */
|
|
33
|
+
export function McpSettingsPage() {
|
|
34
|
+
// The dashboard redirects to its canonical origin, which also serves MCP.
|
|
35
|
+
const url = new URL("/api/mcp", window.location.origin).toString();
|
|
36
|
+
const name = mcpServerName();
|
|
37
|
+
const jsonConfig = JSON.stringify(
|
|
38
|
+
{ mcpServers: { [name]: { type: "http", url } } },
|
|
39
|
+
null,
|
|
40
|
+
2,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<SettingsPageLayout
|
|
45
|
+
description={
|
|
46
|
+
<>
|
|
47
|
+
Connect Claude Code or another MCP client to list your{" "}
|
|
48
|
+
{getDashboardAgentName()} conversations. The client signs in through
|
|
49
|
+
this dashboard. Personal API tokens do not work here.
|
|
50
|
+
</>
|
|
51
|
+
}
|
|
52
|
+
title="MCP"
|
|
53
|
+
>
|
|
54
|
+
<div className="grid gap-5">
|
|
55
|
+
<ConfigBlock label="Server URL" value={url} />
|
|
56
|
+
<ConfigBlock
|
|
57
|
+
label="Claude Code"
|
|
58
|
+
value={`claude mcp add --transport http ${name} ${url}`}
|
|
59
|
+
/>
|
|
60
|
+
<ConfigBlock label="JSON config" value={jsonConfig} />
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
64
|
+
The client must support OAuth with Client ID Metadata Documents.{" "}
|
|
65
|
+
<a
|
|
66
|
+
className={dashboardInlineLinkClass}
|
|
67
|
+
href={MCP_DOCS_URL}
|
|
68
|
+
rel="noreferrer"
|
|
69
|
+
target="_blank"
|
|
70
|
+
>
|
|
71
|
+
Read the MCP server docs
|
|
72
|
+
</a>
|
|
73
|
+
.
|
|
74
|
+
</p>
|
|
75
|
+
</SettingsPageLayout>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { KeyRound, Trash2 } from "lucide-react";
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
createdPersonalTokenSchema,
|
|
6
|
+
personalTokenListSchema,
|
|
7
|
+
type PersonalTokenMetadata,
|
|
8
|
+
} from "@sentry/junior/api/schema";
|
|
9
|
+
import { deleteDashboardResource, fetchDashboardJson, post } from "../../http";
|
|
10
|
+
import { getDashboardAgentName } from "../../agentName";
|
|
11
|
+
import { Button } from "../../components/ui/button";
|
|
12
|
+
import { Input } from "../../components/ui/input";
|
|
13
|
+
import { InlineError } from "../../components/InlineError";
|
|
14
|
+
import { Card } from "../../components/layout/Card";
|
|
15
|
+
import { SettingsPageLayout } from "./SettingsPageLayout";
|
|
16
|
+
|
|
17
|
+
const personalTokensQueryKey = ["dashboard", "personal-tokens"] as const;
|
|
18
|
+
|
|
19
|
+
/** Create and revoke personal API tokens for local clients. */
|
|
20
|
+
export function PersonalTokensPage() {
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
const createTokenStarted = useRef(false);
|
|
23
|
+
const [name, setName] = useState("Local agent");
|
|
24
|
+
const cancelTokenListRefetch = () =>
|
|
25
|
+
queryClient.cancelQueries({
|
|
26
|
+
exact: true,
|
|
27
|
+
queryKey: personalTokensQueryKey,
|
|
28
|
+
});
|
|
29
|
+
const tokensQuery = useQuery({
|
|
30
|
+
queryKey: personalTokensQueryKey,
|
|
31
|
+
queryFn: ({ signal }) =>
|
|
32
|
+
fetchDashboardJson(
|
|
33
|
+
personalTokenListSchema,
|
|
34
|
+
"/api/personal-tokens",
|
|
35
|
+
signal,
|
|
36
|
+
),
|
|
37
|
+
retry: false,
|
|
38
|
+
});
|
|
39
|
+
const createTokenMutation = useMutation({
|
|
40
|
+
gcTime: 0,
|
|
41
|
+
mutationFn: (tokenName: string) =>
|
|
42
|
+
post(createdPersonalTokenSchema, "/api/personal-tokens", {
|
|
43
|
+
name: tokenName,
|
|
44
|
+
}),
|
|
45
|
+
onMutate: () => cancelTokenListRefetch(),
|
|
46
|
+
onSuccess: async (created) => {
|
|
47
|
+
const { token: _token, ...metadata } = created;
|
|
48
|
+
await cancelTokenListRefetch();
|
|
49
|
+
queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>(
|
|
50
|
+
personalTokensQueryKey,
|
|
51
|
+
(current) => ({
|
|
52
|
+
tokens: [
|
|
53
|
+
metadata,
|
|
54
|
+
...(current?.tokens ?? []).filter(
|
|
55
|
+
(token) => token.id !== metadata.id,
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
onSettled: () => {
|
|
62
|
+
createTokenStarted.current = false;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const revokeTokenMutation = useMutation({
|
|
66
|
+
mutationFn: (token: PersonalTokenMetadata) =>
|
|
67
|
+
deleteDashboardResource(
|
|
68
|
+
`/api/personal-tokens/${encodeURIComponent(token.id)}`,
|
|
69
|
+
),
|
|
70
|
+
onMutate: () => cancelTokenListRefetch(),
|
|
71
|
+
onSuccess: async (_result, token) => {
|
|
72
|
+
await cancelTokenListRefetch();
|
|
73
|
+
queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>(
|
|
74
|
+
personalTokensQueryKey,
|
|
75
|
+
(current) => ({
|
|
76
|
+
tokens: (current?.tokens ?? []).filter(
|
|
77
|
+
(item) => item.id !== token.id,
|
|
78
|
+
),
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
if (createTokenMutation.data?.id === token.id) {
|
|
82
|
+
createTokenMutation.reset();
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const createdToken = createTokenMutation.data;
|
|
87
|
+
const tokens = tokensQuery.data?.tokens ?? [];
|
|
88
|
+
const loading = tokensQuery.isPending;
|
|
89
|
+
const busy = createTokenMutation.isPending || revokeTokenMutation.isPending;
|
|
90
|
+
const displayedError =
|
|
91
|
+
(createTokenMutation.isError
|
|
92
|
+
? "Could not create the API token. Try again."
|
|
93
|
+
: revokeTokenMutation.isError
|
|
94
|
+
? "Could not revoke the API token. Try again."
|
|
95
|
+
: undefined) ??
|
|
96
|
+
(!tokensQuery.data && tokensQuery.isError
|
|
97
|
+
? "Could not load API tokens. Try again."
|
|
98
|
+
: undefined);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<SettingsPageLayout
|
|
102
|
+
description={
|
|
103
|
+
<>
|
|
104
|
+
Use a token to read {getDashboardAgentName()} APIs from a local agent
|
|
105
|
+
or script. Tokens expire after 90 days.
|
|
106
|
+
</>
|
|
107
|
+
}
|
|
108
|
+
title="API Tokens"
|
|
109
|
+
>
|
|
110
|
+
{createdToken ? (
|
|
111
|
+
<div className="rounded-md border border-emerald-400/40 bg-emerald-400/5 p-3">
|
|
112
|
+
<p className="mt-0 mb-2 text-sm font-semibold">
|
|
113
|
+
Copy this token now. It won't be shown again.
|
|
114
|
+
</p>
|
|
115
|
+
<code className="block overflow-x-auto rounded-md bg-dashboard-ink p-2 text-xs text-emerald-300">
|
|
116
|
+
{createdToken.token}
|
|
117
|
+
</code>
|
|
118
|
+
<Button
|
|
119
|
+
className="mt-3"
|
|
120
|
+
onClick={() => {
|
|
121
|
+
createTokenMutation.reset();
|
|
122
|
+
setName("Local agent");
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
Create another token
|
|
126
|
+
</Button>
|
|
127
|
+
</div>
|
|
128
|
+
) : (
|
|
129
|
+
<div className="flex gap-2">
|
|
130
|
+
<Input
|
|
131
|
+
aria-label="Token name"
|
|
132
|
+
className="min-w-0 flex-1"
|
|
133
|
+
maxLength={80}
|
|
134
|
+
onChange={(event) => setName(event.target.value)}
|
|
135
|
+
value={name}
|
|
136
|
+
/>
|
|
137
|
+
<Button
|
|
138
|
+
disabled={loading || busy || !name.trim()}
|
|
139
|
+
onClick={() => {
|
|
140
|
+
if (createTokenStarted.current) return;
|
|
141
|
+
revokeTokenMutation.reset();
|
|
142
|
+
createTokenStarted.current = true;
|
|
143
|
+
createTokenMutation.mutate(name);
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
Create token
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{displayedError ? <InlineError>{displayedError}</InlineError> : null}
|
|
152
|
+
|
|
153
|
+
<Card className="mb-0" variant="raised">
|
|
154
|
+
{loading ? (
|
|
155
|
+
<p className="m-0 p-5 text-sm text-dashboard-text-muted">
|
|
156
|
+
Loading API tokens…
|
|
157
|
+
</p>
|
|
158
|
+
) : tokens.length === 0 ? (
|
|
159
|
+
<p className="m-0 p-5 text-sm text-dashboard-text-muted">
|
|
160
|
+
No active API tokens.
|
|
161
|
+
</p>
|
|
162
|
+
) : (
|
|
163
|
+
<ul className="m-0 list-none divide-y divide-dashboard-border-subtle p-0">
|
|
164
|
+
{tokens.map((token) => (
|
|
165
|
+
<li className="flex items-center gap-3 px-5 py-4" key={token.id}>
|
|
166
|
+
<KeyRound className="text-dashboard-accent" size={16} />
|
|
167
|
+
<div className="min-w-0 flex-1">
|
|
168
|
+
<p className="m-0 truncate text-sm font-semibold">
|
|
169
|
+
{token.name}
|
|
170
|
+
</p>
|
|
171
|
+
<p className="mt-1 mb-0 text-xs text-dashboard-text-muted">
|
|
172
|
+
Ends in {token.tokenSuffix} · Expires{" "}
|
|
173
|
+
{new Date(token.expiresAt).toLocaleDateString()}
|
|
174
|
+
</p>
|
|
175
|
+
</div>
|
|
176
|
+
<Button
|
|
177
|
+
aria-label={`Revoke ${token.name}`}
|
|
178
|
+
disabled={busy}
|
|
179
|
+
onClick={() => {
|
|
180
|
+
if (createTokenMutation.isError) {
|
|
181
|
+
createTokenMutation.reset();
|
|
182
|
+
}
|
|
183
|
+
revokeTokenMutation.mutate(token);
|
|
184
|
+
}}
|
|
185
|
+
size="icon"
|
|
186
|
+
variant="danger"
|
|
187
|
+
type="button"
|
|
188
|
+
>
|
|
189
|
+
<Trash2 size={16} />
|
|
190
|
+
</Button>
|
|
191
|
+
</li>
|
|
192
|
+
))}
|
|
193
|
+
</ul>
|
|
194
|
+
)}
|
|
195
|
+
</Card>
|
|
196
|
+
</SettingsPageLayout>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -8,17 +8,16 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
dashboardIdentitySchema,
|
|
10
10
|
type DashboardIdentity,
|
|
11
|
-
} from "
|
|
12
|
-
import { Button } from "
|
|
13
|
-
import { Input } from "
|
|
14
|
-
import { Textarea } from "
|
|
15
|
-
import { Field, FieldDescription, FieldLabel } from "
|
|
16
|
-
import { InlineError } from "
|
|
17
|
-
import { Card } from "
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import type { DashboardCoreData } from "../types";
|
|
11
|
+
} from "../../../api/schema";
|
|
12
|
+
import { Button } from "../../components/ui/button";
|
|
13
|
+
import { Input } from "../../components/ui/input";
|
|
14
|
+
import { Textarea } from "../../components/ui/textarea";
|
|
15
|
+
import { Field, FieldDescription, FieldLabel } from "../../components/ui/field";
|
|
16
|
+
import { InlineError } from "../../components/InlineError";
|
|
17
|
+
import { Card } from "../../components/layout/Card";
|
|
18
|
+
import { DashboardApiError, fetchDashboardJson, patch, put } from "../../http";
|
|
19
|
+
import type { DashboardCoreData } from "../../types";
|
|
20
|
+
import { SettingsPageLayout } from "./SettingsPageLayout";
|
|
22
21
|
|
|
23
22
|
const dashboardCoreQueryKey = ["dashboard", "core"] as const;
|
|
24
23
|
const userCustomizationQueryKey = ["dashboard", "user-customization"] as const;
|
|
@@ -30,7 +29,6 @@ type SettingsPageProps = {
|
|
|
30
29
|
|
|
31
30
|
/** Let the signed-in user manage their dashboard profile. */
|
|
32
31
|
export function SettingsPage({ identity }: SettingsPageProps) {
|
|
33
|
-
useDocumentTitle("Settings");
|
|
34
32
|
const queryClient = useQueryClient();
|
|
35
33
|
const [displayName, setDisplayName] = useState(identity.user.name ?? "");
|
|
36
34
|
useEffect(() => {
|
|
@@ -61,52 +59,48 @@ export function SettingsPage({ identity }: SettingsPageProps) {
|
|
|
61
59
|
}
|
|
62
60
|
|
|
63
61
|
return (
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
</form>
|
|
107
|
-
<CustomizationForm />
|
|
108
|
-
</section>
|
|
109
|
-
</div>
|
|
62
|
+
<SettingsPageLayout
|
|
63
|
+
description="Manage your profile and preferences."
|
|
64
|
+
title="General"
|
|
65
|
+
>
|
|
66
|
+
<form onSubmit={submit}>
|
|
67
|
+
<Card className="mb-0" padding="md" variant="raised">
|
|
68
|
+
<h2 className="m-0 text-lg font-bold">Profile</h2>
|
|
69
|
+
<Field className="mt-5">
|
|
70
|
+
<FieldLabel htmlFor="display-name">Display name</FieldLabel>
|
|
71
|
+
<Input
|
|
72
|
+
autoComplete="name"
|
|
73
|
+
id="display-name"
|
|
74
|
+
maxLength={80}
|
|
75
|
+
onChange={(event) => {
|
|
76
|
+
setDisplayName(event.target.value);
|
|
77
|
+
if (updateProfile.isError || updateProfile.isSuccess) {
|
|
78
|
+
updateProfile.reset();
|
|
79
|
+
}
|
|
80
|
+
}}
|
|
81
|
+
value={displayName}
|
|
82
|
+
/>
|
|
83
|
+
<FieldDescription id="display-name-help">
|
|
84
|
+
Your display name is shown with your conversations and activity.
|
|
85
|
+
</FieldDescription>
|
|
86
|
+
</Field>
|
|
87
|
+
<div className="mt-5 flex items-center gap-3">
|
|
88
|
+
<Button disabled={!canSave} type="submit">
|
|
89
|
+
{updateProfile.isPending ? "Saving…" : "Save changes"}
|
|
90
|
+
</Button>
|
|
91
|
+
{updateProfile.isSuccess ? (
|
|
92
|
+
<p className="m-0 text-sm text-emerald-300">Changes saved.</p>
|
|
93
|
+
) : null}
|
|
94
|
+
{updateProfile.isError ? (
|
|
95
|
+
<InlineError>
|
|
96
|
+
Could not save your display name. Try again.
|
|
97
|
+
</InlineError>
|
|
98
|
+
) : null}
|
|
99
|
+
</div>
|
|
100
|
+
</Card>
|
|
101
|
+
</form>
|
|
102
|
+
<CustomizationForm />
|
|
103
|
+
</SettingsPageLayout>
|
|
110
104
|
);
|
|
111
105
|
}
|
|
112
106
|
|
|
@@ -146,9 +140,7 @@ function CustomizationForm() {
|
|
|
146
140
|
}
|
|
147
141
|
if (savedText === undefined) {
|
|
148
142
|
return customizationQuery.isError ? (
|
|
149
|
-
<InlineError
|
|
150
|
-
Could not load your customization.
|
|
151
|
-
</InlineError>
|
|
143
|
+
<InlineError>Could not load your customization.</InlineError>
|
|
152
144
|
) : null;
|
|
153
145
|
}
|
|
154
146
|
const canSave =
|
|
@@ -162,7 +154,7 @@ function CustomizationForm() {
|
|
|
162
154
|
}
|
|
163
155
|
|
|
164
156
|
return (
|
|
165
|
-
<form
|
|
157
|
+
<form onSubmit={submit}>
|
|
166
158
|
<Card className="mb-0" padding="md" variant="raised">
|
|
167
159
|
<h2 className="m-0 text-lg font-bold">Customization</h2>
|
|
168
160
|
<Field className="mt-5">
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { PageHeader } from "../../components/layout/PageHeader";
|
|
4
|
+
import { PageLayout } from "../../components/layout/PageLayout";
|
|
5
|
+
import { SectionIntro } from "../../components/layout/SectionIntro";
|
|
6
|
+
import { SegmentedNav } from "../../components/SegmentedNav";
|
|
7
|
+
|
|
8
|
+
const settingsNavigationItems = [
|
|
9
|
+
{ end: true, label: "General", to: "/settings" },
|
|
10
|
+
{ label: "API Tokens", to: "/settings/api-tokens" },
|
|
11
|
+
{ label: "Identities", to: "/settings/connections" },
|
|
12
|
+
{ label: "MCP", to: "/settings/mcp" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Place one Settings section under the Settings heading and its page links.
|
|
17
|
+
* Settings opens from the profile menu, not the app navigation, so its page
|
|
18
|
+
* links sit in the page instead of the header chrome.
|
|
19
|
+
*/
|
|
20
|
+
export function SettingsPageLayout(props: {
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
description: ReactNode;
|
|
23
|
+
title: string;
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<PageLayout>
|
|
27
|
+
<div className="grid w-full max-w-3xl min-w-0 gap-8">
|
|
28
|
+
<PageHeader
|
|
29
|
+
documentTitle={props.title}
|
|
30
|
+
navigation={
|
|
31
|
+
<SegmentedNav
|
|
32
|
+
ariaLabel="Settings navigation"
|
|
33
|
+
items={settingsNavigationItems}
|
|
34
|
+
/>
|
|
35
|
+
}
|
|
36
|
+
title="Settings"
|
|
37
|
+
/>
|
|
38
|
+
<section className="grid min-w-0 gap-5">
|
|
39
|
+
<SectionIntro description={props.description} title={props.title} />
|
|
40
|
+
{props.children}
|
|
41
|
+
</section>
|
|
42
|
+
</div>
|
|
43
|
+
</PageLayout>
|
|
44
|
+
);
|
|
45
|
+
}
|