@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.
- package/dist/app.js +44 -3
- package/dist/client.js +77 -77
- package/dist/index.js +40 -3
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/app.ts +2 -0
- package/src/client/App.tsx +89 -61
- package/src/client/components/ProfileMenu.tsx +0 -24
- package/src/client/components/UserInputForm.tsx +38 -7
- package/src/client/components/layout/PageHeader.tsx +25 -10
- package/src/client/conversations/ConversationPage.tsx +55 -8
- package/src/client/conversations/ConversationTranscript.tsx +74 -5
- package/src/client/conversations/TranscriptRows.tsx +23 -0
- package/src/client/conversations/TranscriptView.tsx +22 -0
- package/src/client/conversations/conversationRoutes.ts +30 -3
- package/src/client/conversations/queries.ts +30 -5
- package/src/client/conversations/transcriptBottomPinning.ts +84 -13
- package/src/client/conversations/transcriptReadPosition.ts +267 -0
- package/src/client/conversations/transcriptRenderModel.ts +16 -2
- package/src/client/pages/automations/AutomationActions.tsx +2 -3
- package/src/client/pages/automations/AutomationEditor.tsx +18 -30
- package/src/client/pages/automations/AutomationOverview.tsx +2 -2
- package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
- 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} +115 -61
- package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
- package/src/client/primaryNav.ts +1 -1
- package/src/client/review-desk/ReviewDeskPage.tsx +185 -242
- package/src/client/review-desk/ReviewDeskSidebar.tsx +259 -0
- package/src/client/review-desk/ReviewItemActions.tsx +1 -10
- 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/queries.ts +3 -3
- package/src/client/review-desk/reviewItemFormat.ts +52 -0
- package/src/mock-reporting/read-positions.ts +39 -0
- package/src/mock-reporting/routes.ts +26 -1
- package/src/tailwind.css +1 -1
- package/src/client/components/AGENTS.md +0 -65
- package/src/client/components/charts/AGENTS.md +0 -15
- package/src/client/conversations/AGENTS.md +0 -38
- package/src/client/pages/ConnectionsPage.tsx +0 -207
- package/src/client/pages/PersonalTokensPage.tsx +0 -205
- package/src/client/pages/automations/AGENTS.md +0 -34
- package/src/client/pages/system/AGENTS.md +0 -12
- 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
|
-
*
|
|
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,
|
|
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(
|
|
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.
|
|
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
|
-
}
|