@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,159 @@
1
+ import { ArrowRight, ChevronLeft, PanelRight } from "lucide-react";
2
+ import { useEffect, useRef } from "react";
3
+ import { Link } from "react-router";
4
+ import type { ReviewItemPayload } from "@sentry/junior/api/schema";
5
+
6
+ import { StatusChip } from "../components/StatusChip";
7
+ import { Button } from "../components/ui/button";
8
+ import { ChatLayout } from "../conversations/ChatLayout";
9
+ import { conversationPath } from "../conversations/conversationRoutes";
10
+ import { DisplayText } from "../display-text/DisplayText";
11
+ import { formatRelativeTime } from "../format";
12
+ import { ReviewItemActions } from "./ReviewItemActions";
13
+ import { ReviewItemContext } from "./ReviewItemContext";
14
+ import { reviewItemStatusLabel } from "./reviewItemFormat";
15
+
16
+ /** Space above the card when the view starts at its top. */
17
+ const CARD_TOP_MARGIN_PX = 24;
18
+
19
+ const textLinkClass =
20
+ "inline-flex items-center gap-1.5 font-sans text-sm font-medium text-dashboard-text no-underline hover:underline focus-visible:outline-dashboard-focus";
21
+
22
+ /**
23
+ * Show one Review item where a person decides it: the end of the
24
+ * Conversation that added it, then its question and actions.
25
+ */
26
+ export function ReviewItemView(props: {
27
+ backTo: string;
28
+ detailsOpen: boolean;
29
+ item: ReviewItemPayload;
30
+ next?: { title: string; to: string };
31
+ onDetailsClick(): void;
32
+ /** The title of the Conversation that added the item, when it is known. */
33
+ sourceTitle?: string;
34
+ }) {
35
+ const { item } = props;
36
+ const open = item.status === "open";
37
+ const articleRef = useRef<HTMLElement>(null);
38
+ const cardRef = useRef<HTMLElement>(null);
39
+ // Open at the decision: the messages above it are context. Show the end
40
+ // of the page, or the top of the card when the card is taller than the
41
+ // view. Messages load and grow after the first paint, so keep the place
42
+ // until the reader scrolls or uses the page.
43
+ useEffect(() => {
44
+ const article = articleRef.current;
45
+ const card = cardRef.current;
46
+ const scroll = article?.closest<HTMLElement>("[data-chat-scroll]");
47
+ if (!article || !card || !scroll) return;
48
+ const observer = new ResizeObserver(() => {
49
+ const cardTop =
50
+ card.getBoundingClientRect().top -
51
+ scroll.getBoundingClientRect().top +
52
+ scroll.scrollTop;
53
+ scroll.scrollTop = Math.min(
54
+ scroll.scrollHeight - scroll.clientHeight,
55
+ cardTop - CARD_TOP_MARGIN_PX,
56
+ );
57
+ });
58
+ const stop = () => observer.disconnect();
59
+ const events = ["keydown", "pointerdown", "touchstart", "wheel"] as const;
60
+ observer.observe(article);
61
+ for (const name of events) {
62
+ scroll.addEventListener(name, stop, { passive: true });
63
+ }
64
+ return () => {
65
+ stop();
66
+ for (const name of events) scroll.removeEventListener(name, stop);
67
+ };
68
+ }, []);
69
+ const source = item.automationId ? "Automation" : props.sourceTitle;
70
+ // A notice has no question, so its text is what the card shows.
71
+ const bodyInCard = item.kind === "notice" || !item.request;
72
+ const conversationLink = (
73
+ <Link
74
+ className={textLinkClass}
75
+ to={conversationPath(item.addedByConversationId, {
76
+ turnId: item.addedByTurnId,
77
+ })}
78
+ >
79
+ Open conversation
80
+ <ArrowRight aria-hidden="true" size={14} />
81
+ </Link>
82
+ );
83
+ return (
84
+ <ChatLayout
85
+ header={
86
+ <header className="border-b border-dashboard-border bg-dashboard-bg">
87
+ <div className="flex min-w-0 items-start justify-between gap-4 px-4 py-3 md:px-7 md:py-5">
88
+ <div className="grid min-w-0 gap-1.5">
89
+ <Link
90
+ className="inline-flex items-center gap-1 font-sans text-xs text-dashboard-text-muted no-underline hover:text-dashboard-text md:hidden"
91
+ to={props.backTo}
92
+ >
93
+ <ChevronLeft aria-hidden="true" size={14} />
94
+ Inbox
95
+ </Link>
96
+ <h2 className="m-0 min-w-0 break-words font-display text-xl font-medium leading-tight tracking-tight">
97
+ {item.title}
98
+ </h2>
99
+ <p className="m-0 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-dashboard-text-muted">
100
+ <StatusChip size="compact" tone={open ? "accent" : "neutral"}>
101
+ {reviewItemStatusLabel(item)}
102
+ </StatusChip>
103
+ <span>
104
+ {source ? `${source} · ` : null}
105
+ {formatRelativeTime(item.createdAt)}
106
+ {item.visibility === "private" ? " · Private" : null}
107
+ </span>
108
+ </p>
109
+ </div>
110
+ <Button
111
+ aria-label="Review item details"
112
+ aria-pressed={props.detailsOpen}
113
+ className="shrink-0"
114
+ onClick={props.onDetailsClick}
115
+ >
116
+ <PanelRight aria-hidden="true" size={15} strokeWidth={2} />
117
+ Details
118
+ </Button>
119
+ </div>
120
+ </header>
121
+ }
122
+ scrollAriaLabel="Review item"
123
+ scrollClassName="px-4 pb-6 md:px-7"
124
+ scroll={
125
+ <article
126
+ aria-label={item.title}
127
+ ref={articleRef}
128
+ className="mx-auto grid w-full min-w-0 max-w-[52.5rem] gap-5 pt-5 md:pt-7"
129
+ >
130
+ <ReviewItemContext
131
+ fallback={
132
+ <div className="grid gap-3 font-sans text-sm leading-relaxed">
133
+ {bodyInCard ? null : <DisplayText text={item.body} />}
134
+ <div>{conversationLink}</div>
135
+ </div>
136
+ }
137
+ item={item}
138
+ />
139
+ <section
140
+ aria-label={open ? "Answer" : "Outcome"}
141
+ ref={cardRef}
142
+ className="grid min-w-0 gap-4 rounded-xl border border-dashboard-border-emphasis bg-dashboard-surface-panel p-4 font-sans text-sm md:p-5"
143
+ >
144
+ {bodyInCard ? <DisplayText text={item.body} /> : null}
145
+ <ReviewItemActions item={item} />
146
+ </section>
147
+ {props.next ? (
148
+ <div className="flex justify-end">
149
+ <Link className={textLinkClass} replace to={props.next.to}>
150
+ Next: {props.next.title}
151
+ <ArrowRight aria-hidden="true" size={14} />
152
+ </Link>
153
+ </div>
154
+ ) : null}
155
+ </article>
156
+ }
157
+ />
158
+ );
159
+ }
@@ -25,8 +25,8 @@ const COUNT_REFRESH_MS = 60_000;
25
25
 
26
26
  /**
27
27
  * List the open Review items that the viewer can see, one page at a time.
28
- * A search also finds archived items, with the first page. `items` holds
29
- * the items of every loaded page, and `query` loads more.
28
+ * A search also finds archived items, with the first page. `items` holds the
29
+ * items of every loaded page, and `query` loads more.
30
30
  */
31
31
  export function useReviewItemsData(filters: {
32
32
  conversationId?: string;
@@ -135,7 +135,7 @@ export function useBlockedAutomationsData(enabled: boolean) {
135
135
  });
136
136
  }
137
137
 
138
- /** Take, submit, complete, or dismiss one Review item. */
138
+ /** Take, submit, or dismiss one Review item. */
139
139
  export function useReviewItemAction() {
140
140
  const queryClient = useQueryClient();
141
141
  return useMutation({
@@ -9,6 +9,19 @@ export function reviewItemPath(id: string): string {
9
9
  return `/review?item=${encodeURIComponent(id)}`;
10
10
  }
11
11
 
12
+ /**
13
+ * Return the subject URL when it is safe to link. Agents and plugins supply
14
+ * it, and URL validation also accepts `javascript:` and `data:` URLs.
15
+ */
16
+ export function reviewItemSubjectHref(url: string): string | undefined {
17
+ try {
18
+ const { protocol } = new URL(url);
19
+ return protocol === "https:" || protocol === "http:" ? url : undefined;
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
24
+
12
25
  /** Name the state of an item: what it needs while open, or how it closed. */
13
26
  export function reviewItemStatusLabel(item: ReviewItemPayload): string {
14
27
  if (item.status === "open") {
@@ -87,3 +100,42 @@ export function reviewItemWaitsForAnswer(item: ReviewItemPayload): boolean {
87
100
  item.canAct
88
101
  );
89
102
  }
103
+
104
+ /** The open Review items of the inbox, in the groups and order it shows. */
105
+ export type InboxGroups = {
106
+ fyi: ReviewItemPayload[];
107
+ needsYou: ReviewItemPayload[];
108
+ };
109
+
110
+ /** Inbox order: action items first, then the oldest, then the item id. */
111
+ function inboxOrder(a: ReviewItemPayload, b: ReviewItemPayload): number {
112
+ if (a.kind !== b.kind) return a.kind === "action" ? -1 : 1;
113
+ const age = Date.parse(a.createdAt) - Date.parse(b.createdAt);
114
+ if (age !== 0) return age;
115
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
116
+ }
117
+
118
+ /**
119
+ * Split open items into inbox groups, oldest first, so the item that waited
120
+ * longest is at the top. Archived items are in no group.
121
+ */
122
+ export function inboxGroups(items: readonly ReviewItemPayload[]): InboxGroups {
123
+ const open = items.filter((item) => item.status === "open");
124
+ return {
125
+ fyi: open.filter((item) => item.kind === "notice").sort(inboxOrder),
126
+ needsYou: open.filter((item) => item.kind === "action").sort(inboxOrder),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * The open item that follows `current` in inbox order. `current` can be an
132
+ * item that closed and left the open groups.
133
+ */
134
+ export function nextInboxItem(
135
+ groups: InboxGroups,
136
+ current: ReviewItemPayload,
137
+ ): ReviewItemPayload | undefined {
138
+ return [...groups.needsYou, ...groups.fyi].find(
139
+ (item) => inboxOrder(current, item) < 0,
140
+ );
141
+ }
@@ -0,0 +1,39 @@
1
+ import type {
2
+ ConversationDetailReport,
3
+ ConversationReadPosition,
4
+ } from "@sentry/junior/api/schema";
5
+ import { readMockConversationDetail } from "./fixtures";
6
+
7
+ /** Process-local read positions so mock QA can reopen at unread rows. */
8
+ const mockReadPositions = new Map<string, number>();
9
+
10
+ /** Clear mock read positions so tests sharing a worker do not leak state. */
11
+ export function resetMockReadPositions(): void {
12
+ mockReadPositions.clear();
13
+ }
14
+
15
+ /** Move a mock read position forward, like the API. */
16
+ export function markMockConversationRead(
17
+ conversationId: string,
18
+ lastReadSeq: number,
19
+ ): ConversationReadPosition | undefined {
20
+ const detail = readMockConversationDetail(conversationId);
21
+ if (!detail) return undefined;
22
+ const lastSeq = detail.events.at(-1)?.seq ?? 0;
23
+ const stored = Math.max(
24
+ mockReadPositions.get(conversationId) ?? -1,
25
+ Math.min(lastReadSeq, lastSeq),
26
+ );
27
+ mockReadPositions.set(conversationId, stored);
28
+ return { lastReadSeq: stored };
29
+ }
30
+
31
+ /** Add the mock read position to a detail report. */
32
+ export function withMockReadPosition(
33
+ report: ConversationDetailReport,
34
+ ): ConversationDetailReport {
35
+ const lastReadSeq = mockReadPositions.get(report.conversationId);
36
+ return lastReadSeq === undefined
37
+ ? report
38
+ : { ...report, readPosition: { lastReadSeq } };
39
+ }
@@ -15,6 +15,7 @@ import {
15
15
  conversationFeedQuerySchema,
16
16
  conversationFeedSchema,
17
17
  conversationParamsSchema,
18
+ conversationReadPositionSchema,
18
19
  conversationPendingMessagesReportSchema,
19
20
  conversationStatsReportSchema,
20
21
  conversationWatchesReportSchema,
@@ -38,6 +39,10 @@ import {
38
39
  automationRunListSchema,
39
40
  } from "@sentry/junior/api/schema";
40
41
  import { mockChartPng } from "./chart-png";
42
+ import {
43
+ markMockConversationRead,
44
+ withMockReadPosition,
45
+ } from "./read-positions";
41
46
  import {
42
47
  filterMockConversationsByRepository,
43
48
  readMockCodeRepository,
@@ -244,7 +249,27 @@ export function createMockReportingApi(): Hono<{
244
249
  const { conversationId } = params.data;
245
250
  const report = readMockConversationDetail(conversationId, query.data.limit);
246
251
  return report
247
- ? jsonResponse(conversationDetailReportSchema, report)
252
+ ? jsonResponse(
253
+ conversationDetailReportSchema,
254
+ withMockReadPosition(report),
255
+ )
256
+ : errorResponse("Conversation not found.", 404);
257
+ });
258
+ app.put("/conversations/:conversationId/read-position", async (c) => {
259
+ const params = conversationParamsSchema.safeParse(c.req.param());
260
+ if (!params.success) {
261
+ return errorResponse("Invalid route parameters.", 400);
262
+ }
263
+ const body = conversationReadPositionSchema.safeParse(await c.req.json());
264
+ if (!body.success) {
265
+ return errorResponse("Invalid request body.", 400);
266
+ }
267
+ const result = markMockConversationRead(
268
+ params.data.conversationId,
269
+ body.data.lastReadSeq,
270
+ );
271
+ return result
272
+ ? jsonResponse(conversationReadPositionSchema, result)
248
273
  : errorResponse("Conversation not found.", 404);
249
274
  });
250
275
  app.patch("/conversations/:conversationId/archive", async (c) => {
package/src/tailwind.css CHANGED
@@ -4,7 +4,7 @@
4
4
  @import "shadcn/tailwind.css";
5
5
 
6
6
  /*
7
- * Dashboard design tokens. components/AGENTS.md has the token rules.
7
+ * Dashboard design tokens. `pnpm dashboard-style:check` enforces their use.
8
8
  * Keep structural UI colors here so a second theme can redefine them.
9
9
  * Prefer roles over opacity ladders. Status accents stay as Tailwind palette utilities.
10
10
  */
@@ -1,65 +0,0 @@
1
- # Dashboard UI kit
2
-
3
- Shared dashboard components live here. Feature pages compose them instead of
4
- copying Tailwind blocks. Follow `policies/frontend-components.md`.
5
- `pnpm dashboard-style:check` enforces the token rules.
6
-
7
- ## shadcn/ui
8
-
9
- - Add components with
10
- `pnpm --filter @sentry/junior-dashboard exec shadcn add <component>`. They go
11
- to `ui/`.
12
- - `ui/` files are shadcn source on dashboard tokens. Remove unused exports.
13
- Change a `ui/` file when every caller needs the same style.
14
- - Use `ui/` components directly. Add an app component only when it adds
15
- structure or behavior, not to rename a `ui/` component or its props.
16
- - Do not apply the CLI's global border or body resets. Keep the Tailwind
17
- radius scale.
18
-
19
- ## Component rules
20
-
21
- - Use `ui/alert-dialog` for every confirmation. Never use `window.confirm`.
22
- - Use `ui/dialog` for a short task form. Never put a form in a `Drawer` or
23
- `DetailsPanel`. Use `useSheet` for each dialog and mount it only while open.
24
- - Use `ui/trace` for transcript activity rows. Put only inline elements in
25
- the row button.
26
- - Give an icon-only control a label with `TooltipContent size="compact"`.
27
- Never use the native `title` attribute.
28
- - Set `focusableWhenDisabled` on a `ui/button` tooltip trigger that can be
29
- disabled.
30
- - Build form fields from `ui/field`. Use the ids `<control id>-error` and
31
- `<control id>-help`.
32
- - Put a checkbox or radio inside its `<label>`. Give each `RadioGroup` an
33
- `aria-label`.
34
- - `ui/alert` sets `role="alert"`. Pass `role="status"` for a message that is
35
- not an error.
36
- - A form page has one `Button variant="primary"`.
37
- - `ObjectIcon` owns native object icons. Do not replace it with a provider
38
- logo.
39
-
40
- ## Tokens
41
-
42
- - Keep every color in a token in `src/tailwind.css`, so each theme can
43
- redefine it. Use `light:` only for values that no token covers.
44
- - Use `var(--color-*)` for chart series. Never use hex values.
45
- - Do not use `white/*` or `black/*` fills, borders, or rings, or hex surface
46
- colors. Shadows may use black.
47
- - Use `outline-dashboard-focus` for keyboard focus only.
48
- - Use the named type scale. Keep `text-2xs` for compact badges and chart
49
- annotations.
50
- - Labels use sentence case. Do not use uppercase or letter spacing.
51
-
52
- ## Navigation
53
-
54
- - App navigation uses pills, page links use `SegmentedNav`, and panel
55
- switches use `ui/tabs`. Keep the three levels distinct.
56
-
57
- ## Ownership
58
-
59
- - Conversation mobile geometry has hard owners: `layout/VisualViewportShell`,
60
- `conversations/ChatLayout`, and `conversations/ComposerDock`. Pages pass
61
- children only. Do not add viewport, offset, or safe-area math in pages.
62
- - Home and create must not use `ComposerDock`.
63
- - Feature surfaces stay under `conversations/` or `pages/` until two real
64
- callers share them. Add a named component on the second real use.
65
- - Keep Tailwind classes on the owning component.
@@ -1,15 +0,0 @@
1
- # System cache reporting
2
-
3
- - `cache-input.ts` owns the cache input calculation.
4
- - `ConversationMetricDay` comes from stored Run metrics, not Sentry spans.
5
- Its input, cached input, and cache-creation counters are disjoint. Add
6
- them once for total input. Do not include output. Never use this transform
7
- for Sentry span totals.
8
- - Period shares use summed token counts, not the mean of bucket shares.
9
- - A missing counter is not a zero. When an active bucket has no counter, omit
10
- its share and the whole-period share.
11
- - Do not call these values a cache hit rate or a cache failure rate. Field
12
- presence is not call-level coverage.
13
- - The page time control selects the buckets for the chart and `CacheWrites`.
14
- Do not add a local time selector.
15
- - Add a reporting contract before you add per-call filters or drilldowns.
@@ -1,38 +0,0 @@
1
- # Transcript components
2
-
3
- Keep transcript layout in these feature components. Use the UI kit in
4
- `../components/` for shared controls and overlays.
5
-
6
- ## Ownership
7
-
8
- - `TranscriptRows` and `TranscriptRow` own row gaps, padding, and indent.
9
- Keep horizontal padding inside each row, so focus outlines are not clipped.
10
- - `TranscriptMessageShell` owns the avatar column and message gutter.
11
- - `TranscriptSummaryTrigger` owns disclosure padding and focus. Do not repeat
12
- negative margins at call sites.
13
- - `ChatLayout` keeps the Conversation header outside the transcript scroll.
14
- - `eventLogRows` and `filterEventLogRows` own row text and search match. The
15
- page step controls use them too.
16
-
17
- ## Rules
18
-
19
- - The Automation trigger card shows the credentials from the dispatch record
20
- of the run, not the current setting. Keep the run input collapsed, because
21
- it is not from a person.
22
- - Each `Transcript` sets the panel for its own rows. A nested transcript never
23
- uses the panel of the page behind it.
24
- - The page has one right panel. Conversation details, event details, and turn
25
- context never show at the same time. Closing a panel returns focus to the
26
- control that opened it.
27
- - The URL owns the selected event in the main Conversation. Standalone
28
- transcripts keep selection in `ConversationEventLog`.
29
- - A pending exit must not close a new panel or another Conversation.
30
- - A pending fork blocks dismissal of its dialog.
31
- - Raw JSON keeps the original ISO values. Persist the UTC/local mode, not the
32
- timezone.
33
-
34
- ## Checks
35
-
36
- - Review with `JUNIOR_DASHBOARD_MOCK_CONVERSATIONS=true pnpm dev`.
37
- - Keep visual checks out of automated assertions. Browser tests cover
38
- opening, closing, and keyboard interaction.
@@ -1,207 +0,0 @@
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/ui/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
- variant="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
- }