@sentry/junior-dashboard 0.270.0 → 0.272.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/app.js +44 -3
  2. package/dist/client.js +77 -77
  3. package/dist/index.js +40 -3
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/app.ts +2 -0
  7. package/src/client/App.tsx +89 -61
  8. package/src/client/components/ProfileMenu.tsx +0 -24
  9. package/src/client/components/UserInputForm.tsx +38 -7
  10. package/src/client/components/layout/PageHeader.tsx +25 -10
  11. package/src/client/conversations/ConversationPage.tsx +55 -8
  12. package/src/client/conversations/ConversationTranscript.tsx +74 -5
  13. package/src/client/conversations/TranscriptRows.tsx +23 -0
  14. package/src/client/conversations/TranscriptView.tsx +22 -0
  15. package/src/client/conversations/conversationRoutes.ts +30 -3
  16. package/src/client/conversations/queries.ts +30 -5
  17. package/src/client/conversations/transcriptBottomPinning.ts +84 -13
  18. package/src/client/conversations/transcriptReadPosition.ts +267 -0
  19. package/src/client/conversations/transcriptRenderModel.ts +16 -2
  20. package/src/client/pages/automations/AutomationActions.tsx +2 -3
  21. package/src/client/pages/automations/AutomationEditor.tsx +18 -30
  22. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  23. package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
  24. package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
  25. package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
  26. package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
  27. package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +115 -61
  28. package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
  29. package/src/client/primaryNav.ts +1 -1
  30. package/src/client/review-desk/ReviewDeskPage.tsx +185 -242
  31. package/src/client/review-desk/ReviewDeskSidebar.tsx +259 -0
  32. package/src/client/review-desk/ReviewItemActions.tsx +1 -10
  33. package/src/client/review-desk/ReviewItemContext.tsx +108 -0
  34. package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
  35. package/src/client/review-desk/ReviewItemView.tsx +159 -0
  36. package/src/client/review-desk/queries.ts +3 -3
  37. package/src/client/review-desk/reviewItemFormat.ts +52 -0
  38. package/src/mock-reporting/read-positions.ts +39 -0
  39. package/src/mock-reporting/routes.ts +26 -1
  40. package/src/tailwind.css +1 -1
  41. package/src/client/components/AGENTS.md +0 -65
  42. package/src/client/components/charts/AGENTS.md +0 -15
  43. package/src/client/conversations/AGENTS.md +0 -38
  44. package/src/client/pages/ConnectionsPage.tsx +0 -207
  45. package/src/client/pages/PersonalTokensPage.tsx +0 -205
  46. package/src/client/pages/automations/AGENTS.md +0 -34
  47. package/src/client/pages/system/AGENTS.md +0 -12
  48. package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
@@ -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,21 @@ import {
8
8
  import {
9
9
  dashboardIdentitySchema,
10
10
  type DashboardIdentity,
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 { useDocumentTitle } from "../documentTitle";
19
- import { DashboardApiError, fetchDashboardJson, patch, put } from "../http";
20
- import { dashboardContainerClass } from "../styles";
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 { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group";
17
+ import {
18
+ useConversationStartPosition,
19
+ type ConversationStartPosition,
20
+ } from "../../conversations/transcriptReadPosition";
21
+ import { InlineError } from "../../components/InlineError";
22
+ import { Card } from "../../components/layout/Card";
23
+ import { DashboardApiError, fetchDashboardJson, patch, put } from "../../http";
24
+ import type { DashboardCoreData } from "../../types";
25
+ import { SettingsPageLayout } from "./SettingsPageLayout";
22
26
 
23
27
  const dashboardCoreQueryKey = ["dashboard", "core"] as const;
24
28
  const userCustomizationQueryKey = ["dashboard", "user-customization"] as const;
@@ -30,7 +34,6 @@ type SettingsPageProps = {
30
34
 
31
35
  /** Let the signed-in user manage their dashboard profile. */
32
36
  export function SettingsPage({ identity }: SettingsPageProps) {
33
- useDocumentTitle("Settings");
34
37
  const queryClient = useQueryClient();
35
38
  const [displayName, setDisplayName] = useState(identity.user.name ?? "");
36
39
  useEffect(() => {
@@ -61,52 +64,105 @@ export function SettingsPage({ identity }: SettingsPageProps) {
61
64
  }
62
65
 
63
66
  return (
64
- <div className={`${dashboardContainerClass} px-4 py-8 md:px-8`}>
65
- <section className="mx-auto w-full max-w-3xl">
66
- <h1 className="m-0 text-2xl font-bold">Settings</h1>
67
- <p className="mt-2 mb-0 max-w-2xl text-sm text-dashboard-text-muted">
68
- Manage your profile and preferences.
69
- </p>
67
+ <SettingsPageLayout
68
+ description="Manage your profile and preferences."
69
+ title="General"
70
+ >
71
+ <form onSubmit={submit}>
72
+ <Card className="mb-0" padding="md" variant="raised">
73
+ <h2 className="m-0 text-lg font-bold">Profile</h2>
74
+ <Field className="mt-5">
75
+ <FieldLabel htmlFor="display-name">Display name</FieldLabel>
76
+ <Input
77
+ autoComplete="name"
78
+ id="display-name"
79
+ maxLength={80}
80
+ onChange={(event) => {
81
+ setDisplayName(event.target.value);
82
+ if (updateProfile.isError || updateProfile.isSuccess) {
83
+ updateProfile.reset();
84
+ }
85
+ }}
86
+ value={displayName}
87
+ />
88
+ <FieldDescription id="display-name-help">
89
+ Your display name is shown with your conversations and activity.
90
+ </FieldDescription>
91
+ </Field>
92
+ <div className="mt-5 flex items-center gap-3">
93
+ <Button disabled={!canSave} type="submit">
94
+ {updateProfile.isPending ? "Saving…" : "Save changes"}
95
+ </Button>
96
+ {updateProfile.isSuccess ? (
97
+ <p className="m-0 text-sm text-emerald-300">Changes saved.</p>
98
+ ) : null}
99
+ {updateProfile.isError ? (
100
+ <InlineError>
101
+ Could not save your display name. Try again.
102
+ </InlineError>
103
+ ) : null}
104
+ </div>
105
+ </Card>
106
+ </form>
107
+ <ConversationStartSetting />
108
+ <CustomizationForm />
109
+ </SettingsPageLayout>
110
+ );
111
+ }
70
112
 
71
- <form className="mt-6" onSubmit={submit}>
72
- <Card className="mb-0" padding="md" variant="raised">
73
- <h2 className="m-0 text-lg font-bold">Profile</h2>
74
- <Field className="mt-5">
75
- <FieldLabel htmlFor="display-name">Display name</FieldLabel>
76
- <Input
77
- autoComplete="name"
78
- id="display-name"
79
- maxLength={80}
80
- onChange={(event) => {
81
- setDisplayName(event.target.value);
82
- if (updateProfile.isError || updateProfile.isSuccess) {
83
- updateProfile.reset();
84
- }
85
- }}
86
- value={displayName}
87
- />
88
- <FieldDescription id="display-name-help">
89
- Your display name is shown with your conversations and activity.
90
- </FieldDescription>
91
- </Field>
92
- <div className="mt-5 flex items-center gap-3">
93
- <Button disabled={!canSave} type="submit">
94
- {updateProfile.isPending ? "Saving…" : "Save changes"}
95
- </Button>
96
- {updateProfile.isSuccess ? (
97
- <p className="m-0 text-sm text-emerald-300">Changes saved.</p>
98
- ) : null}
99
- {updateProfile.isError ? (
100
- <InlineError>
101
- Could not save your display name. Try again.
102
- </InlineError>
103
- ) : null}
104
- </div>
105
- </Card>
106
- </form>
107
- <CustomizationForm />
108
- </section>
109
- </div>
113
+ const startPositionOptions: {
114
+ description: string;
115
+ label: string;
116
+ value: ConversationStartPosition;
117
+ }[] = [
118
+ {
119
+ description: "Open at the first new message, below a “New” line.",
120
+ label: "Where I left off",
121
+ value: "unread",
122
+ },
123
+ {
124
+ description: "Always open at the newest message.",
125
+ label: "Latest message",
126
+ value: "latest",
127
+ },
128
+ ];
129
+
130
+ /** Let the user choose where a Conversation opens on this browser. */
131
+ function ConversationStartSetting() {
132
+ const [startPosition, setStartPosition] = useConversationStartPosition();
133
+ return (
134
+ <Card className="mt-6 mb-0" padding="md" variant="raised">
135
+ <fieldset className="m-0 min-w-0 border-0 p-0">
136
+ <legend className="m-0 p-0 text-lg font-bold">
137
+ Open conversations at
138
+ </legend>
139
+ <p className="mt-1 mb-4 text-sm text-dashboard-text-muted">
140
+ Junior keeps your place in each conversation on all your devices. This
141
+ choice applies to this browser.
142
+ </p>
143
+ <RadioGroup<ConversationStartPosition>
144
+ aria-label="Open conversations at"
145
+ className="grid gap-3"
146
+ onValueChange={setStartPosition}
147
+ value={startPosition}
148
+ >
149
+ {startPositionOptions.map((option) => (
150
+ <label
151
+ className="flex cursor-pointer items-start gap-3"
152
+ key={option.value}
153
+ >
154
+ <RadioGroupItem className="mt-0.5" value={option.value} />
155
+ <span className="grid gap-0.5">
156
+ <span className="text-sm font-medium">{option.label}</span>
157
+ <span className="text-xs text-dashboard-text-muted">
158
+ {option.description}
159
+ </span>
160
+ </span>
161
+ </label>
162
+ ))}
163
+ </RadioGroup>
164
+ </fieldset>
165
+ </Card>
110
166
  );
111
167
  }
112
168
 
@@ -146,9 +202,7 @@ function CustomizationForm() {
146
202
  }
147
203
  if (savedText === undefined) {
148
204
  return customizationQuery.isError ? (
149
- <InlineError className="mt-6">
150
- Could not load your customization.
151
- </InlineError>
205
+ <InlineError>Could not load your customization.</InlineError>
152
206
  ) : null;
153
207
  }
154
208
  const canSave =
@@ -162,7 +216,7 @@ function CustomizationForm() {
162
216
  }
163
217
 
164
218
  return (
165
- <form className="mt-6" onSubmit={submit}>
219
+ <form onSubmit={submit}>
166
220
  <Card className="mb-0" padding="md" variant="raised">
167
221
  <h2 className="m-0 text-lg font-bold">Customization</h2>
168
222
  <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
+ }
@@ -51,7 +51,7 @@ export function buildPrimaryNavItems(input: {
51
51
  {
52
52
  count: input.reviewCount,
53
53
  key: "review",
54
- label: "Review desk",
54
+ label: "Inbox",
55
55
  to: "/review",
56
56
  },
57
57
  ]