@sentry/junior-dashboard 0.232.0 → 0.234.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 +163 -46
- package/dist/client.js +55 -52
- package/dist/index.js +163 -46
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/client/App.tsx +359 -345
- package/src/client/api.ts +17 -1
- package/src/client/components/ActorAvatar.tsx +37 -0
- package/src/client/components/Button.tsx +12 -10
- package/src/client/components/Field.tsx +14 -1
- package/src/client/components/FormNotice.tsx +40 -0
- package/src/client/components/ParticipantAvatarStack.tsx +6 -14
- package/src/client/components/README.md +11 -2
- package/src/client/components/Select.tsx +18 -0
- package/src/client/components/StatusChip.tsx +15 -12
- package/src/client/components/TextInput.tsx +25 -5
- package/src/client/conversations/ConversationEventLog.tsx +49 -9
- package/src/client/conversations/EventDetails.tsx +94 -11
- package/src/client/conversations/EventUsage.tsx +118 -0
- package/src/client/conversations/eventLog.ts +59 -6
- package/src/client/conversations/eventTranscript.ts +2 -0
- package/src/client/conversations/transcriptBottomPinning.ts +2 -2
- package/src/client/http.ts +31 -3
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
- package/src/client/pages/automations/AutomationEditPage.tsx +172 -0
- package/src/client/pages/automations/AutomationEditor.tsx +397 -0
- package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
- package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
- package/src/client/pages/automations/AutomationFilters.tsx +171 -0
- package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
- package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
- package/src/client/pages/automations/AutomationRow.tsx +358 -0
- package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
- package/src/client/pages/automations/AutomationsPage.tsx +157 -408
- package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
- package/src/client/pages/automations/README.md +19 -0
- package/src/client/pages/automations/automationDraft.ts +110 -0
- package/src/client/pages/automations/automationOutcomes.ts +23 -0
- package/src/client/pages/dev/ComponentsPage.tsx +50 -4
- package/src/client/searchParams.ts +4 -2
- package/src/client.tsx +7 -4
- package/src/mock-reporting/automation-collection.ts +101 -0
- package/src/mock-reporting/fixtures.ts +82 -32
- package/src/mock-reporting/routes.ts +14 -9
package/src/client/api.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
personalSpendReportSchema,
|
|
17
17
|
automationExecutionListSchema,
|
|
18
18
|
automationListSchema,
|
|
19
|
+
automationSummarySchema,
|
|
19
20
|
automationRunListSchema,
|
|
20
21
|
} from "@sentry/junior/api/schema";
|
|
21
22
|
import {
|
|
@@ -122,7 +123,7 @@ export function useAutomationsData(enabled: boolean, search: string) {
|
|
|
122
123
|
queryFn: ({ signal }) =>
|
|
123
124
|
fetchDashboardJson(
|
|
124
125
|
automationListSchema,
|
|
125
|
-
`/api/automations${search ?
|
|
126
|
+
`/api/automations${search ? `?${search}` : ""}`,
|
|
126
127
|
signal,
|
|
127
128
|
),
|
|
128
129
|
placeholderData: keepPreviousData,
|
|
@@ -130,6 +131,21 @@ export function useAutomationsData(enabled: boolean, search: string) {
|
|
|
130
131
|
});
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
/** Load details by ID, independent of list filters and pagination. */
|
|
135
|
+
export function useAutomationData(enabled: boolean, id: string | undefined) {
|
|
136
|
+
return useQuery({
|
|
137
|
+
enabled: enabled && Boolean(id),
|
|
138
|
+
queryKey: ["dashboard", "automations", "detail", id],
|
|
139
|
+
queryFn: ({ signal }) =>
|
|
140
|
+
fetchDashboardJson(
|
|
141
|
+
automationSummarySchema,
|
|
142
|
+
`/api/automations/${encodeURIComponent(id!)}`,
|
|
143
|
+
signal,
|
|
144
|
+
),
|
|
145
|
+
retry: false,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
133
149
|
/** Fetch newest runs across all viewer-visible automations. */
|
|
134
150
|
export function useAutomationRunsData(enabled: boolean) {
|
|
135
151
|
return useQuery({
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { cn } from "../styles";
|
|
3
|
+
|
|
4
|
+
/** Show an actor's image, with initials when the image is missing or fails. */
|
|
5
|
+
export function ActorAvatar(props: {
|
|
6
|
+
name: string;
|
|
7
|
+
imageUrl?: string;
|
|
8
|
+
size: "detail" | "list";
|
|
9
|
+
}) {
|
|
10
|
+
const [failedUrl, setFailedUrl] = useState<string>();
|
|
11
|
+
const words = props.name.trim().split(/\s+/).filter(Boolean);
|
|
12
|
+
const initials =
|
|
13
|
+
words.length > 1
|
|
14
|
+
? `${words[0]![0]}${words.at(-1)![0]}`
|
|
15
|
+
: props.name.slice(0, 2);
|
|
16
|
+
return (
|
|
17
|
+
<span
|
|
18
|
+
aria-hidden="true"
|
|
19
|
+
className={cn(
|
|
20
|
+
"relative inline-grid shrink-0 place-items-center overflow-hidden rounded-full border-2 border-dashboard-bg bg-dashboard-focus font-sans font-bold leading-none text-dashboard-text-inverse shadow-sm",
|
|
21
|
+
props.size === "list" ? "size-6 text-2xs" : "size-7 text-xs",
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
24
|
+
{initials.toUpperCase()}
|
|
25
|
+
{props.imageUrl && props.imageUrl !== failedUrl ? (
|
|
26
|
+
<img
|
|
27
|
+
alt=""
|
|
28
|
+
className="absolute inset-0 size-full object-cover"
|
|
29
|
+
loading="lazy"
|
|
30
|
+
onError={() => setFailedUrl(props.imageUrl)}
|
|
31
|
+
referrerPolicy="no-referrer"
|
|
32
|
+
src={props.imageUrl}
|
|
33
|
+
/>
|
|
34
|
+
) : null}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -4,7 +4,7 @@ import { Link, type LinkProps } from "react-router";
|
|
|
4
4
|
import { cn } from "../styles";
|
|
5
5
|
|
|
6
6
|
type ButtonSize = "default" | "icon";
|
|
7
|
-
type ButtonTone = "default" | "danger";
|
|
7
|
+
type ButtonTone = "default" | "danger" | "primary";
|
|
8
8
|
type ToggleButtonVariant = "pill" | "segment" | "text";
|
|
9
9
|
|
|
10
10
|
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
@@ -44,15 +44,17 @@ export function Button({
|
|
|
44
44
|
? "text-dashboard-text-muted hover:!bg-rose-300/10 hover:!text-rose-300 focus-visible:outline-rose-300/55 disabled:hover:!text-dashboard-text-muted"
|
|
45
45
|
: "text-dashboard-text-muted hover:!bg-dashboard-fill-strong hover:!text-dashboard-text focus-visible:outline-cyan-300/55 disabled:hover:!text-dashboard-text-muted",
|
|
46
46
|
)
|
|
47
|
-
:
|
|
48
|
-
?
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
: tone === "primary"
|
|
48
|
+
? "inline-flex h-9 items-center justify-center gap-2 rounded border border-dashboard-text-solid bg-dashboard-text-solid px-3 font-sans text-sm font-semibold text-dashboard-text-inverse hover:bg-dashboard-text-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus"
|
|
49
|
+
: isDanger
|
|
50
|
+
? cn(
|
|
51
|
+
dangerButtonClassName,
|
|
52
|
+
"disabled:hover:border-dashboard-border-strong disabled:hover:text-dashboard-text-muted",
|
|
53
|
+
)
|
|
54
|
+
: cn(
|
|
55
|
+
buttonClassName,
|
|
56
|
+
"disabled:hover:border-dashboard-border-emphasis disabled:hover:bg-dashboard-surface-raised disabled:hover:text-dashboard-text",
|
|
57
|
+
),
|
|
56
58
|
props.disabled ? "" : "cursor-pointer",
|
|
57
59
|
className,
|
|
58
60
|
)}
|
|
@@ -20,6 +20,7 @@ export function Field(props: {
|
|
|
20
20
|
children: ReactNode;
|
|
21
21
|
className?: string;
|
|
22
22
|
help?: ReactNode;
|
|
23
|
+
error?: string;
|
|
23
24
|
htmlFor?: string;
|
|
24
25
|
label: ReactNode;
|
|
25
26
|
size?: FieldSize;
|
|
@@ -31,8 +32,20 @@ export function Field(props: {
|
|
|
31
32
|
{props.label}
|
|
32
33
|
</label>
|
|
33
34
|
{props.children}
|
|
35
|
+
{props.error ? (
|
|
36
|
+
<p
|
|
37
|
+
id={props.htmlFor ? `${props.htmlFor}-error` : undefined}
|
|
38
|
+
role="alert"
|
|
39
|
+
className="m-0 text-xs leading-relaxed text-rose-300"
|
|
40
|
+
>
|
|
41
|
+
{props.error}
|
|
42
|
+
</p>
|
|
43
|
+
) : null}
|
|
34
44
|
{props.help ? (
|
|
35
|
-
<p
|
|
45
|
+
<p
|
|
46
|
+
id={props.htmlFor ? `${props.htmlFor}-help` : undefined}
|
|
47
|
+
className="m-0 text-xs leading-relaxed text-dashboard-text-muted sm:text-sm"
|
|
48
|
+
>
|
|
36
49
|
{props.help}
|
|
37
50
|
</p>
|
|
38
51
|
) : null}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { TriangleAlert } from "lucide-react";
|
|
3
|
+
import { cn } from "../styles";
|
|
4
|
+
|
|
5
|
+
/** Keep form recovery text and actions visible without truncation. */
|
|
6
|
+
export function FormNotice(props: {
|
|
7
|
+
title: string;
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
tone?: "error" | "warning";
|
|
10
|
+
}) {
|
|
11
|
+
const error = props.tone === "error";
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
role={error ? "alert" : "status"}
|
|
15
|
+
className={cn(
|
|
16
|
+
"flex min-w-0 gap-3 rounded-md border p-4",
|
|
17
|
+
error
|
|
18
|
+
? "border-rose-300/25 bg-rose-300/5"
|
|
19
|
+
: "border-amber-300/25 bg-amber-300/5",
|
|
20
|
+
)}
|
|
21
|
+
>
|
|
22
|
+
<TriangleAlert
|
|
23
|
+
aria-hidden
|
|
24
|
+
size={18}
|
|
25
|
+
className={cn(
|
|
26
|
+
"mt-0.5 shrink-0",
|
|
27
|
+
error ? "text-rose-300" : "text-amber-300",
|
|
28
|
+
)}
|
|
29
|
+
/>
|
|
30
|
+
<div className="min-w-0">
|
|
31
|
+
<p className="m-0 text-sm font-medium">{props.title}</p>
|
|
32
|
+
{props.children ? (
|
|
33
|
+
<div className="mt-1 text-sm leading-relaxed text-dashboard-text-muted">
|
|
34
|
+
{props.children}
|
|
35
|
+
</div>
|
|
36
|
+
) : null}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { ActorIdentity } from "@sentry/junior/api/schema";
|
|
|
3
3
|
import { actorLabel } from "../format";
|
|
4
4
|
import { cn } from "../styles";
|
|
5
5
|
import type { Conversation } from "../types";
|
|
6
|
+
import { ActorAvatar } from "./ActorAvatar";
|
|
6
7
|
import { Tooltip } from "./Tooltip";
|
|
7
8
|
|
|
8
9
|
const MAX_VISIBLE_PARTICIPANTS = 3;
|
|
@@ -28,15 +29,6 @@ function participantDescription(participant: ActorIdentity): string {
|
|
|
28
29
|
return email && email !== name ? `${name}, ${email}` : name;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function participantInitials(participant: ActorIdentity): string {
|
|
32
|
-
const name = participantName(participant);
|
|
33
|
-
const words = name.split(/\s+/).filter(Boolean);
|
|
34
|
-
if (words.length > 1) {
|
|
35
|
-
return `${words[0]![0] ?? ""}${words.at(-1)?.[0] ?? ""}`.toUpperCase();
|
|
36
|
-
}
|
|
37
|
-
return name.slice(0, 2).toUpperCase();
|
|
38
|
-
}
|
|
39
|
-
|
|
40
32
|
function participantKey(participant: ActorIdentity, index: number): string {
|
|
41
33
|
return (
|
|
42
34
|
participant.email?.trim().toLowerCase() ||
|
|
@@ -72,14 +64,14 @@ function Avatar(props: {
|
|
|
72
64
|
>
|
|
73
65
|
<span
|
|
74
66
|
aria-label={label}
|
|
75
|
-
className=
|
|
76
|
-
"relative inline-grid shrink-0 place-items-center rounded-full border-2 border-dashboard-bg bg-dashboard-focus font-sans font-bold leading-none text-dashboard-text-inverse shadow-sm focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus",
|
|
77
|
-
props.size === "list" ? "size-6 text-2xs" : "size-7 text-xs",
|
|
78
|
-
)}
|
|
67
|
+
className="relative inline-flex rounded-full focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus"
|
|
79
68
|
onKeyDown={(event) => event.stopPropagation()}
|
|
80
69
|
tabIndex={0}
|
|
81
70
|
>
|
|
82
|
-
|
|
71
|
+
<ActorAvatar
|
|
72
|
+
name={participantName(props.participant)}
|
|
73
|
+
size={props.size}
|
|
74
|
+
/>
|
|
83
75
|
</span>
|
|
84
76
|
</Tooltip>
|
|
85
77
|
);
|
|
@@ -6,7 +6,7 @@ instead of copying Tailwind blocks.
|
|
|
6
6
|
## Layers
|
|
7
7
|
|
|
8
8
|
1. **Primitives** — `Button` / `ToggleButton`, `Field`, `TextInput`,
|
|
9
|
-
`StatusChip`, `StatusDot`, `Notice`, `Drawer`, `Tooltip`, `Skeleton`,
|
|
9
|
+
`StatusChip`, `StatusDot`, `Notice`, `FormNotice`, `Select`, `Drawer`, `Tooltip`, `Skeleton`,
|
|
10
10
|
`PageContentSkeleton`, `SegmentedTabs`, and other small reusable controls.
|
|
11
11
|
Use `SegmentedTabs` for equal-width panel navigation. Pass the selected value,
|
|
12
12
|
items, change handler, and selected panel content. It owns tab/panel labels
|
|
@@ -14,7 +14,9 @@ instead of copying Tailwind blocks.
|
|
|
14
14
|
`ToggleButton` variants (`pill`, `segment`, `text`) over one-off pressed
|
|
15
15
|
styles. Prefer `Field` sizes (`default`, `compact`) over handwritten labels.
|
|
16
16
|
Prefer `StatusDot` for dense table or list status markers and `StatusChip`
|
|
17
|
-
when the label itself must stay visible.
|
|
17
|
+
when the label itself must stay visible. `StatusChip` uses a small dot and
|
|
18
|
+
sentence-case text, without a border, fill, or all-caps styling. It is not
|
|
19
|
+
an interactive control. Prefer `PageContentSkeleton`
|
|
18
20
|
variants over ad-hoc pulse blocks in route modules. Use `PageRouteLoading`
|
|
19
21
|
when a standard page must keep its real header above a body skeleton. Use a
|
|
20
22
|
feature-owned loading component for distinct geometry, such as conversations.
|
|
@@ -54,3 +56,10 @@ instead of copying Tailwind blocks.
|
|
|
54
56
|
- One-off page glue may stay inline when extraction only adds indirection.
|
|
55
57
|
|
|
56
58
|
See `policies/frontend-components.md`.
|
|
59
|
+
|
|
60
|
+
Use `TextInput size="comfortable"` for larger mobile form controls.
|
|
61
|
+
Form pages use `Button tone="primary"` for their one main save action. Use
|
|
62
|
+
`TextArea prose` for instructions rather than code. `Field error` renders an
|
|
63
|
+
error at `<htmlFor>-error`; the owning control must set `aria-invalid` and
|
|
64
|
+
`aria-describedby`. `Select` matches native text controls. Use `FormNotice` for
|
|
65
|
+
wrapping form recovery text; use `Notice` for compact transient messages.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SelectHTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../styles";
|
|
3
|
+
|
|
4
|
+
/** Match native form choices to the dashboard text controls. */
|
|
5
|
+
export function Select({
|
|
6
|
+
className,
|
|
7
|
+
...props
|
|
8
|
+
}: SelectHTMLAttributes<HTMLSelectElement>) {
|
|
9
|
+
return (
|
|
10
|
+
<select
|
|
11
|
+
{...props}
|
|
12
|
+
className={cn(
|
|
13
|
+
"block min-h-11 w-full min-w-0 rounded border border-dashboard-border-emphasis bg-dashboard-ink px-3 py-2 text-base text-dashboard-text focus:border-dashboard-focus focus:outline-none aria-invalid:border-rose-300 disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-10 sm:text-sm",
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -13,21 +13,20 @@ export type StatusChipTone =
|
|
|
13
13
|
export type StatusChipSize = "default" | "compact";
|
|
14
14
|
|
|
15
15
|
const toneClass: Record<StatusChipTone, string> = {
|
|
16
|
-
accent: "
|
|
17
|
-
danger: "
|
|
18
|
-
info: "
|
|
19
|
-
neutral: "
|
|
20
|
-
success: "
|
|
21
|
-
warning: "
|
|
16
|
+
accent: "text-violet-300",
|
|
17
|
+
danger: "text-rose-300",
|
|
18
|
+
info: "text-cyan-300",
|
|
19
|
+
neutral: "text-dashboard-text-muted",
|
|
20
|
+
success: "text-emerald-300",
|
|
21
|
+
warning: "text-amber-300",
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
const sizeClass: Record<StatusChipSize, string> = {
|
|
25
|
-
compact:
|
|
26
|
-
|
|
27
|
-
default: "px-2 py-1 font-mono text-xs uppercase tracking-[0.1em]",
|
|
25
|
+
compact: "text-xs",
|
|
26
|
+
default: "text-sm",
|
|
28
27
|
};
|
|
29
28
|
|
|
30
|
-
/**
|
|
29
|
+
/** Show status as quiet text, not a button-shaped badge. */
|
|
31
30
|
export function StatusChip(props: {
|
|
32
31
|
children: ReactNode;
|
|
33
32
|
className?: string;
|
|
@@ -37,13 +36,17 @@ export function StatusChip(props: {
|
|
|
37
36
|
return (
|
|
38
37
|
<span
|
|
39
38
|
className={cn(
|
|
40
|
-
"inline-flex w-fit items-center
|
|
39
|
+
"inline-flex w-fit items-center gap-1.5 font-sans font-medium normal-case leading-snug tracking-normal",
|
|
41
40
|
sizeClass[props.size ?? "default"],
|
|
42
41
|
toneClass[props.tone ?? "neutral"],
|
|
43
42
|
props.className,
|
|
44
43
|
)}
|
|
45
44
|
>
|
|
46
|
-
|
|
45
|
+
<span
|
|
46
|
+
aria-hidden="true"
|
|
47
|
+
className="size-1.5 shrink-0 rounded-full bg-current"
|
|
48
|
+
/>
|
|
49
|
+
<span className="block first-letter:uppercase">{props.children}</span>
|
|
47
50
|
</span>
|
|
48
51
|
);
|
|
49
52
|
}
|
|
@@ -3,36 +3,56 @@ import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
|
3
3
|
import { cn } from "../styles";
|
|
4
4
|
|
|
5
5
|
const textControlClassName =
|
|
6
|
-
"block w-full rounded border border-dashboard-border-emphasis bg-dashboard-ink px-3 py-2 text-sm text-dashboard-text focus:border-dashboard-focus focus:outline-none disabled:cursor-not-allowed disabled:opacity-50";
|
|
6
|
+
"block w-full rounded border border-dashboard-border-emphasis bg-dashboard-ink px-3 py-2 text-sm text-dashboard-text focus:border-dashboard-focus focus:outline-none aria-invalid:border-rose-300 disabled:cursor-not-allowed disabled:opacity-50";
|
|
7
7
|
|
|
8
8
|
export type TextInputProps = Omit<
|
|
9
9
|
InputHTMLAttributes<HTMLInputElement>,
|
|
10
10
|
"size"
|
|
11
11
|
> & {
|
|
12
12
|
className?: string;
|
|
13
|
+
size?: "default" | "comfortable";
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
/** Render the dashboard's standard single-line text control. */
|
|
16
|
-
export function TextInput({
|
|
17
|
+
export function TextInput({
|
|
18
|
+
className,
|
|
19
|
+
type = "text",
|
|
20
|
+
size = "default",
|
|
21
|
+
...props
|
|
22
|
+
}: TextInputProps) {
|
|
17
23
|
return (
|
|
18
24
|
<input
|
|
19
25
|
{...props}
|
|
20
|
-
className={cn(
|
|
26
|
+
className={cn(
|
|
27
|
+
textControlClassName,
|
|
28
|
+
size === "comfortable" && "min-h-11 text-base sm:min-h-10 sm:text-sm",
|
|
29
|
+
className,
|
|
30
|
+
)}
|
|
21
31
|
type={type}
|
|
22
32
|
/>
|
|
23
33
|
);
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
|
37
|
+
prose?: boolean;
|
|
27
38
|
className?: string;
|
|
28
39
|
};
|
|
29
40
|
|
|
30
41
|
/** Render the dashboard's standard multi-line text control. */
|
|
31
|
-
export function TextArea({
|
|
42
|
+
export function TextArea({
|
|
43
|
+
className,
|
|
44
|
+
prose = false,
|
|
45
|
+
...props
|
|
46
|
+
}: TextAreaProps) {
|
|
32
47
|
return (
|
|
33
48
|
<textarea
|
|
34
49
|
{...props}
|
|
35
|
-
className={cn(
|
|
50
|
+
className={cn(
|
|
51
|
+
textControlClassName,
|
|
52
|
+
"min-h-36",
|
|
53
|
+
prose ? "font-sans leading-relaxed" : "font-mono",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
36
56
|
/>
|
|
37
57
|
);
|
|
38
58
|
}
|
|
@@ -5,7 +5,12 @@ import { createPortal } from "react-dom";
|
|
|
5
5
|
import { Drawer } from "../components/Drawer";
|
|
6
6
|
import { cn } from "../styles";
|
|
7
7
|
import type { ConversationTranscript } from "../types";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
eventLogModel,
|
|
10
|
+
eventLogSummary,
|
|
11
|
+
eventLogTone,
|
|
12
|
+
eventLogUsage,
|
|
13
|
+
} from "./eventLog";
|
|
9
14
|
import { EventDetails } from "./EventDetails";
|
|
10
15
|
import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
|
|
11
16
|
|
|
@@ -25,6 +30,8 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
|
|
|
25
30
|
() =>
|
|
26
31
|
events.map((event) => ({
|
|
27
32
|
event,
|
|
33
|
+
model: eventLogModel(event),
|
|
34
|
+
usage: eventLogUsage(event),
|
|
28
35
|
summary: eventLogSummary(event.data).replace(/\s+/g, " ").slice(0, 300),
|
|
29
36
|
})),
|
|
30
37
|
[events],
|
|
@@ -33,8 +40,8 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
|
|
|
33
40
|
const visibleRows = useMemo(
|
|
34
41
|
() =>
|
|
35
42
|
search.active
|
|
36
|
-
? rows.filter(({ event, summary }) =>
|
|
37
|
-
`${summary} ${JSON.stringify(event)}`
|
|
43
|
+
? rows.filter(({ event, summary, usage }) =>
|
|
44
|
+
`${summary} ${usage} ${JSON.stringify(event)}`
|
|
38
45
|
.toLowerCase()
|
|
39
46
|
.includes(search.normalizedQuery),
|
|
40
47
|
)
|
|
@@ -79,7 +86,7 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
|
|
|
79
86
|
aria-label="Events"
|
|
80
87
|
className="m-0 list-none divide-y divide-dashboard-border-subtle p-0"
|
|
81
88
|
>
|
|
82
|
-
{visibleRows.map(({ event, summary }) => (
|
|
89
|
+
{visibleRows.map(({ event, summary, model, usage }) => (
|
|
83
90
|
<li key={event.seq}>
|
|
84
91
|
<button
|
|
85
92
|
aria-haspopup="dialog"
|
|
@@ -105,11 +112,44 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
|
|
|
105
112
|
>
|
|
106
113
|
<HighlightText text={event.data.type} />
|
|
107
114
|
</span>
|
|
108
|
-
<span
|
|
109
|
-
className="
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
<span className="col-span-2 row-start-2 grid min-w-0 gap-1 text-dashboard-text @min-[48rem]:col-span-1 @min-[48rem]:row-auto">
|
|
116
|
+
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
|
|
117
|
+
<span
|
|
118
|
+
className={cn(
|
|
119
|
+
"break-all",
|
|
120
|
+
model
|
|
121
|
+
? "font-semibold text-violet-300"
|
|
122
|
+
: "text-dashboard-text-muted",
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
<HighlightText
|
|
126
|
+
text={model?.modelId ?? "Model not recorded"}
|
|
127
|
+
/>
|
|
128
|
+
</span>
|
|
129
|
+
{model?.modelProfile ? (
|
|
130
|
+
<span className="text-dashboard-text-muted">
|
|
131
|
+
<HighlightText text={model.modelProfile} />
|
|
132
|
+
</span>
|
|
133
|
+
) : null}
|
|
134
|
+
{model?.reasoningLevel ? (
|
|
135
|
+
<span className="text-dashboard-text-muted">
|
|
136
|
+
<HighlightText
|
|
137
|
+
text={`${model.reasoningLevel} reasoning`}
|
|
138
|
+
/>
|
|
139
|
+
</span>
|
|
140
|
+
) : null}
|
|
141
|
+
</span>
|
|
142
|
+
<span className="truncate" title={summary}>
|
|
143
|
+
<HighlightText text={summary} />
|
|
144
|
+
</span>
|
|
145
|
+
{usage ? (
|
|
146
|
+
<span
|
|
147
|
+
className="text-dashboard-text-muted"
|
|
148
|
+
title="Usage for this event only; costs are estimated USD"
|
|
149
|
+
>
|
|
150
|
+
<HighlightText text={usage} />
|
|
151
|
+
</span>
|
|
152
|
+
) : null}
|
|
113
153
|
</span>
|
|
114
154
|
<ChevronRight
|
|
115
155
|
aria-hidden="true"
|
|
@@ -3,13 +3,64 @@ import type { ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { RedactedMarker } from "./TranscriptRedacted";
|
|
5
5
|
import { TranscriptText } from "./TranscriptText";
|
|
6
|
+
import { eventLogModel } from "./eventLog";
|
|
7
|
+
import { formatCostBreakdown } from "../format";
|
|
8
|
+
import { EventUsage } from "./EventUsage";
|
|
6
9
|
|
|
7
10
|
/** Present event content as readable sections, with the exact report available on demand. */
|
|
8
11
|
export function EventDetails({ event }: { event: ConversationReportEvent }) {
|
|
9
12
|
const data = event.data;
|
|
13
|
+
const model = eventLogModel(event);
|
|
14
|
+
const { usage, ...callAttributes } = event.modelCall ?? {};
|
|
10
15
|
let content: ReactNode;
|
|
11
16
|
|
|
12
17
|
switch (data.type) {
|
|
18
|
+
case "turn_routed": {
|
|
19
|
+
const {
|
|
20
|
+
type: _,
|
|
21
|
+
modelId: _modelId,
|
|
22
|
+
modelProfile: _profile,
|
|
23
|
+
reasoningLevel: _reasoning,
|
|
24
|
+
costUsd,
|
|
25
|
+
...metadata
|
|
26
|
+
} = data;
|
|
27
|
+
content = (
|
|
28
|
+
<DetailSection title="Route selection">
|
|
29
|
+
<DetailValue
|
|
30
|
+
value={{
|
|
31
|
+
...metadata,
|
|
32
|
+
...(costUsd !== undefined
|
|
33
|
+
? { routerCostUsd: formatCostBreakdown({ total: costUsd }) }
|
|
34
|
+
: undefined),
|
|
35
|
+
}}
|
|
36
|
+
/>
|
|
37
|
+
</DetailSection>
|
|
38
|
+
);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case "handoff": {
|
|
42
|
+
const {
|
|
43
|
+
type: _,
|
|
44
|
+
modelId: _modelId,
|
|
45
|
+
modelProfile: _profile,
|
|
46
|
+
reasoningLevel: _reasoning,
|
|
47
|
+
summary,
|
|
48
|
+
...metadata
|
|
49
|
+
} = data;
|
|
50
|
+
content = (
|
|
51
|
+
<>
|
|
52
|
+
{Object.keys(metadata).length > 0 ? (
|
|
53
|
+
<DetailValue value={metadata} />
|
|
54
|
+
) : null}
|
|
55
|
+
{summary ? (
|
|
56
|
+
<DetailSection title="Continuation summary">
|
|
57
|
+
<TranscriptText text={summary} />
|
|
58
|
+
</DetailSection>
|
|
59
|
+
) : null}
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
13
64
|
case "message": {
|
|
14
65
|
const { type: _, text, redacted, ...metadata } = data;
|
|
15
66
|
content = (
|
|
@@ -98,7 +149,30 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
|
|
|
98
149
|
}
|
|
99
150
|
|
|
100
151
|
return (
|
|
101
|
-
<div className="grid min-w-0 gap-6">
|
|
152
|
+
<div className="@container grid min-w-0 gap-6">
|
|
153
|
+
<DetailSection
|
|
154
|
+
title={
|
|
155
|
+
data.type === "handoff" ? "Target configuration" : "Configuration"
|
|
156
|
+
}
|
|
157
|
+
>
|
|
158
|
+
<DetailValue
|
|
159
|
+
value={{
|
|
160
|
+
model: model?.modelId ?? "Not recorded",
|
|
161
|
+
...(model
|
|
162
|
+
? {
|
|
163
|
+
modelProfile: model.modelProfile ?? "Not recorded",
|
|
164
|
+
reasoningLevel: model.reasoningLevel ?? "Not recorded",
|
|
165
|
+
}
|
|
166
|
+
: undefined),
|
|
167
|
+
...callAttributes,
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
</DetailSection>
|
|
171
|
+
{event.modelCall ? (
|
|
172
|
+
<DetailSection title="Call usage">
|
|
173
|
+
<EventUsage usage={usage} />
|
|
174
|
+
</DetailSection>
|
|
175
|
+
) : null}
|
|
102
176
|
{content}
|
|
103
177
|
<details className="border-t border-dashboard-border pt-4">
|
|
104
178
|
<summary className="cursor-pointer text-xs text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-dashboard-focus">
|
|
@@ -114,8 +188,8 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
|
|
|
114
188
|
|
|
115
189
|
function DetailSection(props: { title: string; children: ReactNode }) {
|
|
116
190
|
return (
|
|
117
|
-
<section aria-label={props.title} className="grid min-w-0 gap-
|
|
118
|
-
<h3 className="m-0
|
|
191
|
+
<section aria-label={props.title} className="grid min-w-0 gap-2">
|
|
192
|
+
<h3 className="m-0 border-b border-dashboard-border pb-2 text-sm font-semibold text-dashboard-text">
|
|
119
193
|
{props.title}
|
|
120
194
|
</h3>
|
|
121
195
|
{props.children}
|
|
@@ -131,7 +205,8 @@ function fieldLabel(key: string): string {
|
|
|
131
205
|
return (words.charAt(0).toUpperCase() + words.slice(1))
|
|
132
206
|
.replace(/\bid\b/gi, "ID")
|
|
133
207
|
.replace(/\bids\b/gi, "IDs")
|
|
134
|
-
.replace(/\busd\b/gi, "USD")
|
|
208
|
+
.replace(/\busd\b/gi, "USD")
|
|
209
|
+
.replace(/\bapi\b/gi, "API");
|
|
135
210
|
}
|
|
136
211
|
|
|
137
212
|
// Keep arbitrary tool and context payloads readable without guessing their schema.
|
|
@@ -150,9 +225,11 @@ function DetailValue({ value }: { value: unknown }) {
|
|
|
150
225
|
);
|
|
151
226
|
}
|
|
152
227
|
if (typeof value === "object" && value !== null) {
|
|
153
|
-
const fields = Object.entries(value)
|
|
228
|
+
const fields = Object.entries(value).filter(
|
|
229
|
+
([, entry]) => entry !== undefined,
|
|
230
|
+
);
|
|
154
231
|
return fields.length ? (
|
|
155
|
-
<dl className="m-0 grid min-w-0
|
|
232
|
+
<dl className="m-0 grid min-w-0 divide-y divide-dashboard-border-subtle">
|
|
156
233
|
{fields.map(([key, entry]) => {
|
|
157
234
|
const nested = typeof entry === "object" && entry !== null;
|
|
158
235
|
return (
|
|
@@ -160,15 +237,21 @@ function DetailValue({ value }: { value: unknown }) {
|
|
|
160
237
|
key={key}
|
|
161
238
|
className={
|
|
162
239
|
nested
|
|
163
|
-
? "grid min-w-0 gap-2"
|
|
164
|
-
: "grid min-w-0 grid-cols-[minmax(0,
|
|
240
|
+
? "grid min-w-0 gap-2 py-2"
|
|
241
|
+
: "grid min-w-0 grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-baseline gap-x-4 py-2 @min-[30rem]:grid-cols-[minmax(0,10rem)_minmax(0,1fr)]"
|
|
165
242
|
}
|
|
166
243
|
>
|
|
167
|
-
<dt className="break-words text-
|
|
244
|
+
<dt className="break-words text-xs leading-relaxed text-dashboard-text-muted">
|
|
168
245
|
{fieldLabel(key)}
|
|
169
246
|
</dt>
|
|
170
247
|
<dd className="m-0 min-w-0">
|
|
171
|
-
|
|
248
|
+
{nested ? (
|
|
249
|
+
<div className="border-l border-dashboard-border pl-3">
|
|
250
|
+
<DetailValue value={entry} />
|
|
251
|
+
</div>
|
|
252
|
+
) : (
|
|
253
|
+
<DetailValue value={entry} />
|
|
254
|
+
)}
|
|
172
255
|
</dd>
|
|
173
256
|
</div>
|
|
174
257
|
);
|
|
@@ -179,7 +262,7 @@ function DetailValue({ value }: { value: unknown }) {
|
|
|
179
262
|
);
|
|
180
263
|
}
|
|
181
264
|
return (
|
|
182
|
-
<span className="block whitespace-pre-wrap break-words font-mono text-
|
|
265
|
+
<span className="block whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-dashboard-text [overflow-wrap:anywhere]">
|
|
183
266
|
{value === null ? "null" : value === "" ? "Empty text" : String(value)}
|
|
184
267
|
</span>
|
|
185
268
|
);
|