@sentry/junior-dashboard 0.274.0 → 0.275.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +2 -2
- package/dist/client.js +72 -72
- package/dist/index.js +2 -2
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/App.tsx +11 -0
- package/src/client/components/Metric.tsx +0 -32
- package/src/client/components/ProfileMenu.tsx +56 -4
- package/src/client/components/layout/DashboardChrome.tsx +3 -3
- package/src/client/components/layout/DashboardHeader.tsx +41 -4
- package/src/client/components/layout/NavRail.tsx +43 -40
- package/src/client/conversations/ConversationDetails.tsx +117 -162
- package/src/client/conversations/ConversationHeader.tsx +119 -133
- package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
- package/src/client/conversations/ConversationMeta.tsx +49 -44
- package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
- package/src/client/conversations/ConversationPage.tsx +33 -368
- package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
- package/src/client/conversations/ConversationSidebar.tsx +5 -7
- package/src/client/conversations/TelemetryMetrics.tsx +0 -14
- package/src/client/conversations/TranscriptView.tsx +15 -32
- package/src/client/conversations/UsageCard.tsx +1 -15
- package/src/client/conversations/conversationSections.ts +18 -14
- package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
- package/src/tailwind.css +4 -0
- package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
- package/src/client/review-desk/ConversationReviewBar.tsx +0 -85
|
@@ -3,14 +3,13 @@ import {
|
|
|
3
3
|
Archive,
|
|
4
4
|
ArchiveRestore,
|
|
5
5
|
Info,
|
|
6
|
-
PanelRight,
|
|
7
6
|
MessagesSquare,
|
|
8
7
|
ScrollText,
|
|
9
8
|
Search,
|
|
10
9
|
X,
|
|
11
10
|
} from "lucide-react";
|
|
12
11
|
|
|
13
|
-
import { Button } from "../components/ui/button";
|
|
12
|
+
import { Button, buttonVariants } from "../components/ui/button";
|
|
14
13
|
import {
|
|
15
14
|
Tooltip,
|
|
16
15
|
TooltipContent,
|
|
@@ -29,59 +28,74 @@ export type ConversationArchiveAction = {
|
|
|
29
28
|
pending: boolean;
|
|
30
29
|
};
|
|
31
30
|
|
|
32
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Show conversation actions in the `⋯` menu. On desktop the title bar shows
|
|
33
|
+
* search, share, and details, so the menu leaves them out.
|
|
34
|
+
*/
|
|
33
35
|
export function ConversationHeaderActions(props: {
|
|
34
36
|
archive: ConversationArchiveAction;
|
|
35
37
|
conversationId: string;
|
|
36
38
|
copyAction?: ReactNode;
|
|
37
39
|
detailsOpen: boolean;
|
|
38
|
-
detailsButtonRef?: Ref<HTMLButtonElement>;
|
|
39
|
-
/** `bar` is the desktop icon row. `menu` stacks actions for mobile overflow. */
|
|
40
|
-
layout?: "bar" | "menu";
|
|
41
40
|
onDetailsClick(): void;
|
|
42
41
|
onSearchClick(): void;
|
|
43
42
|
onViewChange(value: TranscriptViewMode): void;
|
|
43
|
+
placement: "desktop" | "mobile";
|
|
44
44
|
searchOpen: boolean;
|
|
45
|
+
sentryConversationUrl?: string;
|
|
45
46
|
view: TranscriptViewMode;
|
|
46
47
|
}) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{props.view === "rich" ? (
|
|
52
|
-
<MenuActionButton
|
|
53
|
-
label={props.searchOpen ? "Hide search" : "Search transcript"}
|
|
54
|
-
onClick={props.onSearchClick}
|
|
55
|
-
pressed={props.searchOpen}
|
|
56
|
-
>
|
|
57
|
-
{props.searchOpen ? (
|
|
58
|
-
<X aria-hidden="true" size={16} strokeWidth={2} />
|
|
59
|
-
) : (
|
|
60
|
-
<Search aria-hidden="true" size={16} strokeWidth={2} />
|
|
61
|
-
)}
|
|
62
|
-
</MenuActionButton>
|
|
63
|
-
) : null}
|
|
64
|
-
<MenuActionButton
|
|
65
|
-
label="Conversation"
|
|
66
|
-
onClick={() => props.onViewChange("rich")}
|
|
67
|
-
pressed={props.view === "rich"}
|
|
68
|
-
>
|
|
69
|
-
<MessagesSquare aria-hidden="true" size={16} strokeWidth={2} />
|
|
70
|
-
</MenuActionButton>
|
|
48
|
+
const mobile = props.placement === "mobile";
|
|
49
|
+
return (
|
|
50
|
+
<div className="grid gap-0.5">
|
|
51
|
+
{mobile && props.view === "rich" ? (
|
|
71
52
|
<MenuActionButton
|
|
72
|
-
label="
|
|
73
|
-
onClick={
|
|
74
|
-
pressed={props.
|
|
53
|
+
label={props.searchOpen ? "Hide search" : "Search transcript"}
|
|
54
|
+
onClick={props.onSearchClick}
|
|
55
|
+
pressed={props.searchOpen}
|
|
75
56
|
>
|
|
76
|
-
|
|
57
|
+
{props.searchOpen ? (
|
|
58
|
+
<X aria-hidden="true" size={16} strokeWidth={2} />
|
|
59
|
+
) : (
|
|
60
|
+
<Search aria-hidden="true" size={16} strokeWidth={2} />
|
|
61
|
+
)}
|
|
77
62
|
</MenuActionButton>
|
|
63
|
+
) : null}
|
|
64
|
+
<MenuActionButton
|
|
65
|
+
label="Conversation"
|
|
66
|
+
onClick={() => props.onViewChange("rich")}
|
|
67
|
+
pressed={props.view === "rich"}
|
|
68
|
+
>
|
|
69
|
+
<MessagesSquare aria-hidden="true" size={16} strokeWidth={2} />
|
|
70
|
+
</MenuActionButton>
|
|
71
|
+
<MenuActionButton
|
|
72
|
+
label="Event log"
|
|
73
|
+
onClick={() => props.onViewChange("raw")}
|
|
74
|
+
pressed={props.view === "raw"}
|
|
75
|
+
>
|
|
76
|
+
<ScrollText aria-hidden="true" size={16} strokeWidth={2} />
|
|
77
|
+
</MenuActionButton>
|
|
78
|
+
{mobile ? (
|
|
78
79
|
<ShareConversationButton
|
|
79
80
|
key={props.conversationId}
|
|
80
81
|
conversationId={props.conversationId}
|
|
81
82
|
layout="menu"
|
|
82
83
|
/>
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
) : null}
|
|
85
|
+
{props.copyAction}
|
|
86
|
+
<ArchiveConversationButton {...props.archive} />
|
|
87
|
+
{props.sentryConversationUrl ? (
|
|
88
|
+
<a
|
|
89
|
+
className={buttonVariants({ size: "menu" })}
|
|
90
|
+
href={props.sentryConversationUrl}
|
|
91
|
+
rel="noopener noreferrer"
|
|
92
|
+
target="_blank"
|
|
93
|
+
>
|
|
94
|
+
<SentryGlyph />
|
|
95
|
+
Open in Sentry
|
|
96
|
+
</a>
|
|
97
|
+
) : null}
|
|
98
|
+
{mobile ? (
|
|
85
99
|
<MenuActionButton
|
|
86
100
|
label="Conversation details"
|
|
87
101
|
onClick={props.onDetailsClick}
|
|
@@ -89,57 +103,29 @@ export function ConversationHeaderActions(props: {
|
|
|
89
103
|
>
|
|
90
104
|
<Info aria-hidden="true" size={16} strokeWidth={2} />
|
|
91
105
|
</MenuActionButton>
|
|
92
|
-
</div>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return (
|
|
97
|
-
<div className="flex shrink-0 items-center gap-0.5">
|
|
98
|
-
{props.view === "rich" ? (
|
|
99
|
-
<TranscriptSearchToggle
|
|
100
|
-
onClick={props.onSearchClick}
|
|
101
|
-
open={props.searchOpen}
|
|
102
|
-
/>
|
|
103
106
|
) : null}
|
|
104
|
-
{props.copyAction}
|
|
105
|
-
<ArchiveConversationButton {...props.archive} layout="bar" />
|
|
106
|
-
<Button
|
|
107
|
-
ref={props.detailsButtonRef}
|
|
108
|
-
aria-label="Conversation details"
|
|
109
|
-
aria-pressed={props.detailsOpen}
|
|
110
|
-
className="ml-2"
|
|
111
|
-
onClick={props.onDetailsClick}
|
|
112
|
-
>
|
|
113
|
-
<PanelRight aria-hidden="true" size={15} strokeWidth={2} />
|
|
114
|
-
Details
|
|
115
|
-
</Button>
|
|
116
107
|
</div>
|
|
117
108
|
);
|
|
118
109
|
}
|
|
119
110
|
|
|
120
111
|
/** Switch between the conversation and event log. */
|
|
121
112
|
export function TranscriptViewToggle(props: {
|
|
122
|
-
labelled?: boolean;
|
|
123
113
|
onChange(value: TranscriptViewMode): void;
|
|
124
114
|
value: TranscriptViewMode;
|
|
125
115
|
}) {
|
|
126
116
|
return (
|
|
127
117
|
<ToggleGroup
|
|
128
118
|
aria-label="Transcript view"
|
|
129
|
-
className=
|
|
130
|
-
props.labelled
|
|
131
|
-
? "inline-flex gap-6"
|
|
132
|
-
: "inline-flex gap-0.5 rounded-md border border-dashboard-border bg-dashboard-overlay-soft p-0.5"
|
|
133
|
-
}
|
|
119
|
+
className="inline-flex gap-0.5 rounded-md border border-dashboard-border bg-dashboard-overlay-soft p-0.5"
|
|
134
120
|
onValueChange={([next]) => {
|
|
135
121
|
if (next) props.onChange(next);
|
|
136
122
|
}}
|
|
137
123
|
value={[props.value]}
|
|
138
124
|
>
|
|
139
|
-
<ViewModeItem label="Conversation"
|
|
125
|
+
<ViewModeItem label="Conversation" value="rich">
|
|
140
126
|
<MessagesSquare aria-hidden="true" size={14} strokeWidth={2} />
|
|
141
127
|
</ViewModeItem>
|
|
142
|
-
<ViewModeItem label="Event log"
|
|
128
|
+
<ViewModeItem label="Event log" value="raw">
|
|
143
129
|
<ScrollText aria-hidden="true" size={14} strokeWidth={2} />
|
|
144
130
|
</ViewModeItem>
|
|
145
131
|
</ToggleGroup>
|
|
@@ -149,28 +135,21 @@ export function TranscriptViewToggle(props: {
|
|
|
149
135
|
function ViewModeItem(props: {
|
|
150
136
|
children: ReactNode;
|
|
151
137
|
label: string;
|
|
152
|
-
labelled?: boolean;
|
|
153
138
|
value: TranscriptViewMode;
|
|
154
139
|
}) {
|
|
155
|
-
|
|
156
|
-
<ToggleGroupItem
|
|
157
|
-
aria-label={props.label}
|
|
158
|
-
className={
|
|
159
|
-
props.labelled
|
|
160
|
-
? "relative h-11 min-w-0 gap-2 rounded-none border-0 px-0 text-sm hover:bg-transparent after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 data-pressed:border-0 data-pressed:bg-transparent data-pressed:after:bg-dashboard-text-solid"
|
|
161
|
-
: "size-7 min-w-0 border-0 px-0 data-pressed:border-0 data-pressed:bg-dashboard-fill-hover"
|
|
162
|
-
}
|
|
163
|
-
value={props.value}
|
|
164
|
-
>
|
|
165
|
-
{props.children}
|
|
166
|
-
{props.labelled ? props.label : null}
|
|
167
|
-
</ToggleGroupItem>
|
|
168
|
-
);
|
|
169
|
-
return props.labelled ? (
|
|
170
|
-
item
|
|
171
|
-
) : (
|
|
140
|
+
return (
|
|
172
141
|
<Tooltip>
|
|
173
|
-
<TooltipTrigger
|
|
142
|
+
<TooltipTrigger
|
|
143
|
+
render={
|
|
144
|
+
<ToggleGroupItem
|
|
145
|
+
aria-label={props.label}
|
|
146
|
+
className="size-7 min-w-0 border-0 px-0 data-pressed:border-0 data-pressed:bg-dashboard-fill-hover"
|
|
147
|
+
value={props.value}
|
|
148
|
+
>
|
|
149
|
+
{props.children}
|
|
150
|
+
</ToggleGroupItem>
|
|
151
|
+
}
|
|
152
|
+
/>
|
|
174
153
|
<TooltipContent size="compact">{props.label}</TooltipContent>
|
|
175
154
|
</Tooltip>
|
|
176
155
|
);
|
|
@@ -183,9 +162,11 @@ export function HeaderIconButton(props: {
|
|
|
183
162
|
label: string;
|
|
184
163
|
onClick(): void;
|
|
185
164
|
pressed?: boolean;
|
|
165
|
+
/** Hide the tooltip, such as while the menu this button opens covers it. */
|
|
166
|
+
tooltipDisabled?: boolean;
|
|
186
167
|
}) {
|
|
187
168
|
return (
|
|
188
|
-
<Tooltip>
|
|
169
|
+
<Tooltip disabled={props.tooltipDisabled}>
|
|
189
170
|
<TooltipTrigger
|
|
190
171
|
render={
|
|
191
172
|
<Button
|
|
@@ -248,45 +229,36 @@ function MenuActionButton(props: {
|
|
|
248
229
|
);
|
|
249
230
|
}
|
|
250
231
|
|
|
251
|
-
function ArchiveConversationButton(
|
|
252
|
-
props: ConversationArchiveAction & { layout: "bar" | "menu" },
|
|
253
|
-
) {
|
|
232
|
+
function ArchiveConversationButton(props: ConversationArchiveAction) {
|
|
254
233
|
const label = props.pending
|
|
255
234
|
? "Saving archive state"
|
|
256
235
|
: props.archived
|
|
257
236
|
? "Unarchive"
|
|
258
237
|
: "Archive";
|
|
259
238
|
const Icon = props.archived ? ArchiveRestore : Archive;
|
|
260
|
-
if (props.layout === "menu") {
|
|
261
|
-
return (
|
|
262
|
-
<Button
|
|
263
|
-
aria-label={label}
|
|
264
|
-
disabled={props.disabled}
|
|
265
|
-
onClick={props.onClick}
|
|
266
|
-
size="menu"
|
|
267
|
-
>
|
|
268
|
-
<Icon aria-hidden="true" size={16} strokeWidth={2} />
|
|
269
|
-
<span>{label}</span>
|
|
270
|
-
</Button>
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
239
|
return (
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
240
|
+
<Button
|
|
241
|
+
aria-label={label}
|
|
242
|
+
disabled={props.disabled}
|
|
243
|
+
onClick={props.onClick}
|
|
244
|
+
size="menu"
|
|
245
|
+
>
|
|
246
|
+
<Icon aria-hidden="true" size={16} strokeWidth={2} />
|
|
247
|
+
<span>{label}</span>
|
|
248
|
+
</Button>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Draw the Sentry glyph in the current text color, sized like a menu icon. */
|
|
253
|
+
function SentryGlyph() {
|
|
254
|
+
return (
|
|
255
|
+
<svg
|
|
256
|
+
aria-hidden="true"
|
|
257
|
+
className="size-4 shrink-0"
|
|
258
|
+
fill="currentColor"
|
|
259
|
+
viewBox="0 0 24 24"
|
|
260
|
+
>
|
|
261
|
+
<path d="M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z" />
|
|
262
|
+
</svg>
|
|
291
263
|
);
|
|
292
264
|
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
LockKeyhole,
|
|
11
11
|
TriangleAlert,
|
|
12
12
|
} from "lucide-react";
|
|
13
|
-
import { useSyncExternalStore } from "react";
|
|
13
|
+
import { useSyncExternalStore, type ReactNode } from "react";
|
|
14
14
|
import { Link } from "react-router";
|
|
15
15
|
import type { ConversationDetailReport } from "@sentry/junior/api/schema";
|
|
16
16
|
|
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
TooltipTitle,
|
|
31
31
|
TooltipTrigger,
|
|
32
32
|
} from "../components/ui/tooltip";
|
|
33
|
-
import { MetricList, type MetricListItem } from "../components/Metric";
|
|
34
33
|
import { cn, dashboardInlineLinkClass } from "../styles";
|
|
35
34
|
import {
|
|
36
35
|
CostMetric,
|
|
@@ -507,52 +506,64 @@ export function liveModelId(
|
|
|
507
506
|
return openTurns.size > 0 ? modelId : undefined;
|
|
508
507
|
}
|
|
509
508
|
|
|
510
|
-
/** True when runtime stats have content
|
|
509
|
+
/** True when runtime stats have content to show in details. */
|
|
511
510
|
export function hasConversationStats(props: {
|
|
512
511
|
conversation: Conversation | undefined;
|
|
513
512
|
detail?: ConversationDetailReport;
|
|
514
|
-
variant?: "compact" | "full";
|
|
515
513
|
}): boolean {
|
|
516
514
|
return conversationStatItems(props).length > 0;
|
|
517
515
|
}
|
|
518
516
|
|
|
519
|
-
type ConversationStatItem =
|
|
517
|
+
type ConversationStatItem = {
|
|
518
|
+
content: ReactNode;
|
|
519
|
+
key: string;
|
|
520
|
+
label: string;
|
|
521
|
+
/** Shown below `md` only, where the title bar is hidden. */
|
|
522
|
+
mobileOnly?: boolean;
|
|
523
|
+
};
|
|
520
524
|
|
|
521
|
-
/**
|
|
522
|
-
* Render runtime, source, token, and cost metadata. The compact variant is one
|
|
523
|
-
* line under the conversation title. The full variant is a labeled list for
|
|
524
|
-
* conversation details.
|
|
525
|
-
*/
|
|
525
|
+
/** Render runtime, source, token, and cost metadata as a labeled list. */
|
|
526
526
|
export function ConversationStats(props: {
|
|
527
527
|
conversation: Conversation | undefined;
|
|
528
528
|
detail?: ConversationDetailReport;
|
|
529
|
-
variant?: "compact" | "full";
|
|
530
529
|
}) {
|
|
531
530
|
const stats = conversationStatItems(props);
|
|
532
531
|
if (stats.length === 0) return null;
|
|
532
|
+
return (
|
|
533
|
+
<dl className="m-0 grid min-w-0 gap-2 text-xs leading-snug">
|
|
534
|
+
{stats.map((item) => (
|
|
535
|
+
<div
|
|
536
|
+
className={cn(
|
|
537
|
+
"flex min-w-0 items-baseline justify-between gap-4",
|
|
538
|
+
item.mobileOnly && "md:hidden",
|
|
539
|
+
)}
|
|
540
|
+
key={item.key}
|
|
541
|
+
>
|
|
542
|
+
<dt className="shrink-0 text-dashboard-text-muted">{item.label}</dt>
|
|
543
|
+
<dd className="m-0 min-w-0 break-words text-right tabular-nums text-dashboard-text">
|
|
544
|
+
{item.content}
|
|
545
|
+
</dd>
|
|
546
|
+
</div>
|
|
547
|
+
))}
|
|
548
|
+
</dl>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
533
551
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
</dd>
|
|
546
|
-
</div>
|
|
547
|
-
))}
|
|
548
|
-
</dl>
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
+
/**
|
|
553
|
+
* Name where a conversation runs, linked when the source has a URL. Returns
|
|
554
|
+
* null without a location, so callers can drop the slot around it.
|
|
555
|
+
*/
|
|
556
|
+
export function conversationLocation(
|
|
557
|
+
conversation: Conversation | undefined,
|
|
558
|
+
detail: ConversationDetailReport | undefined,
|
|
559
|
+
): ReactNode {
|
|
560
|
+
if (!conversation) return null;
|
|
561
|
+
const location = slackLocationLabel(conversation, { includeId: false });
|
|
562
|
+
if (!location) return null;
|
|
552
563
|
return (
|
|
553
|
-
<
|
|
554
|
-
|
|
555
|
-
|
|
564
|
+
<LocationLink
|
|
565
|
+
label={location}
|
|
566
|
+
locationUrl={detail?.locationUrl ?? conversation.locationUrl}
|
|
556
567
|
/>
|
|
557
568
|
);
|
|
558
569
|
}
|
|
@@ -560,10 +571,8 @@ export function ConversationStats(props: {
|
|
|
560
571
|
function conversationStatItems(props: {
|
|
561
572
|
conversation: Conversation | undefined;
|
|
562
573
|
detail?: ConversationDetailReport;
|
|
563
|
-
variant?: "compact" | "full";
|
|
564
574
|
}): ConversationStatItem[] {
|
|
565
575
|
if (!props.conversation) return [];
|
|
566
|
-
const variant = props.variant ?? "full";
|
|
567
576
|
const completeDetail = props.detail?.previousCursor
|
|
568
577
|
? undefined
|
|
569
578
|
: props.detail;
|
|
@@ -572,11 +581,6 @@ function conversationStatItems(props: {
|
|
|
572
581
|
const tokenSummary =
|
|
573
582
|
summarizeModelUsage(props.detail?.modelUsage) ?? summarizeUsage(usage);
|
|
574
583
|
const costSummary = summarizeCost(usage);
|
|
575
|
-
const location = slackLocationLabel(props.conversation, {
|
|
576
|
-
includeId: false,
|
|
577
|
-
});
|
|
578
|
-
const locationUrl =
|
|
579
|
-
props.detail?.locationUrl ?? props.conversation.locationUrl;
|
|
580
584
|
const durationLabel = formatConversationDuration(props.conversation);
|
|
581
585
|
const live = (props.detail?.status ?? props.conversation.status) === "active";
|
|
582
586
|
const activeModelId = liveModelId(props.detail);
|
|
@@ -584,22 +588,25 @@ function conversationStatItems(props: {
|
|
|
584
588
|
const childAgentTotals =
|
|
585
589
|
props.detail?.childAgentTotals ?? props.conversation.childAgentTotals;
|
|
586
590
|
const lastActivityAt = props.conversation.lastSeenAt;
|
|
591
|
+
const location = conversationLocation(props.conversation, props.detail);
|
|
587
592
|
const rawStats: Array<ConversationStatItem | undefined> = [
|
|
593
|
+
// The desktop title bar shows the location. Mobile hides that bar.
|
|
588
594
|
location
|
|
589
595
|
? {
|
|
590
|
-
content:
|
|
596
|
+
content: location,
|
|
591
597
|
key: "location",
|
|
592
598
|
label: "Location",
|
|
599
|
+
mobileOnly: true,
|
|
593
600
|
}
|
|
594
601
|
: undefined,
|
|
595
|
-
|
|
602
|
+
sourceTask
|
|
596
603
|
? {
|
|
597
604
|
content: <SourceTask sourceTask={sourceTask} />,
|
|
598
605
|
key: "source-task",
|
|
599
606
|
label: "Source",
|
|
600
607
|
}
|
|
601
608
|
: undefined,
|
|
602
|
-
|
|
609
|
+
lastActivityAt
|
|
603
610
|
? {
|
|
604
611
|
content: (
|
|
605
612
|
<time
|
|
@@ -635,7 +642,6 @@ function conversationStatItems(props: {
|
|
|
635
642
|
? {
|
|
636
643
|
content: (
|
|
637
644
|
<TokenMetric
|
|
638
|
-
detailsHint={variant === "compact"}
|
|
639
645
|
childAgentTotals={childAgentTotals}
|
|
640
646
|
compactionCount={
|
|
641
647
|
completeDetail
|
|
@@ -661,7 +667,6 @@ function conversationStatItems(props: {
|
|
|
661
667
|
? {
|
|
662
668
|
content: (
|
|
663
669
|
<CostMetric
|
|
664
|
-
detailsHint={variant === "compact"}
|
|
665
670
|
auxiliaryCosts={
|
|
666
671
|
props.detail?.auxiliaryCosts ??
|
|
667
672
|
props.conversation.auxiliaryCosts
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Ellipsis } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef, useState, type ReactNode, type Ref } from "react";
|
|
3
|
+
|
|
4
|
+
import { HeaderIconButton } from "./ConversationHeaderActions";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Show the conversation `⋯` button and its menu. The menu closes on an
|
|
8
|
+
* outside click or Escape. `children` gets `close` so an item can close the
|
|
9
|
+
* menu before it acts.
|
|
10
|
+
*/
|
|
11
|
+
export function ConversationOverflowMenu(props: {
|
|
12
|
+
buttonRef?: Ref<HTMLButtonElement>;
|
|
13
|
+
children(close: () => void): ReactNode;
|
|
14
|
+
}) {
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!open) return;
|
|
20
|
+
|
|
21
|
+
function closeOnOutsideClick(event: PointerEvent) {
|
|
22
|
+
if (!menuRef.current?.contains(event.target as Node)) setOpen(false);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Capture runs before the details panel's document listener, and
|
|
26
|
+
// preventDefault tells it that this Escape is taken.
|
|
27
|
+
function closeOnEscape(event: KeyboardEvent) {
|
|
28
|
+
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
setOpen(false);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
document.addEventListener("pointerdown", closeOnOutsideClick);
|
|
34
|
+
document.addEventListener("keydown", closeOnEscape, true);
|
|
35
|
+
return () => {
|
|
36
|
+
document.removeEventListener("pointerdown", closeOnOutsideClick);
|
|
37
|
+
document.removeEventListener("keydown", closeOnEscape, true);
|
|
38
|
+
};
|
|
39
|
+
}, [open]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="relative" ref={menuRef}>
|
|
43
|
+
<HeaderIconButton
|
|
44
|
+
ref={props.buttonRef}
|
|
45
|
+
label={open ? "Close conversation menu" : "Conversation menu"}
|
|
46
|
+
onClick={() => setOpen((value) => !value)}
|
|
47
|
+
pressed={open}
|
|
48
|
+
tooltipDisabled={open}
|
|
49
|
+
>
|
|
50
|
+
<Ellipsis aria-hidden="true" size={18} strokeWidth={2} />
|
|
51
|
+
</HeaderIconButton>
|
|
52
|
+
{open ? (
|
|
53
|
+
<div className="absolute right-0 top-[calc(100%+0.35rem)] z-40 w-56 rounded-xl border border-dashboard-border-strong bg-dashboard-surface-raised/95 p-1.5 shadow-2xl shadow-black/75 backdrop-blur-xl">
|
|
54
|
+
{props.children(() => setOpen(false))}
|
|
55
|
+
</div>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|