@sentry/junior-dashboard 0.274.0 → 0.276.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 +28 -9
- package/dist/client.js +115 -92
- package/dist/index.js +28 -9
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/app.ts +8 -3
- 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/ConversationComposer.tsx +42 -26
- package/src/client/conversations/ConversationDetails.tsx +149 -166
- package/src/client/conversations/ConversationHeader.tsx +126 -132
- package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
- package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
- package/src/client/conversations/ConversationMemories.tsx +118 -78
- package/src/client/conversations/ConversationMeta.tsx +94 -84
- package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
- package/src/client/conversations/ConversationPage.tsx +197 -422
- package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
- package/src/client/conversations/ConversationSidebar.tsx +5 -7
- package/src/client/conversations/ConversationTranscript.tsx +37 -8
- package/src/client/conversations/ConversationWatches.tsx +37 -15
- package/src/client/conversations/PendingMailboxStack.tsx +11 -1
- package/src/client/conversations/QuickQuestion.tsx +476 -0
- package/src/client/conversations/TelemetryMetrics.tsx +2 -15
- package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
- package/src/client/conversations/TranscriptEventView.tsx +25 -2
- package/src/client/conversations/TranscriptMemoryPopover.tsx +179 -0
- package/src/client/conversations/TranscriptMessageView.tsx +46 -16
- package/src/client/conversations/TranscriptRows.tsx +9 -2
- package/src/client/conversations/TranscriptView.tsx +15 -32
- package/src/client/conversations/UsageCard.tsx +4 -18
- package/src/client/conversations/conversationMemory.ts +138 -0
- package/src/client/conversations/conversationSections.ts +18 -14
- package/src/client/conversations/eventTranscript.ts +8 -0
- package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
- package/src/client/review-desk/ReviewItemActions.tsx +1 -1
- package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
- package/src/client/review-desk/ReviewItemContext.tsx +41 -23
- package/src/client/review-desk/reviewItemFormat.ts +16 -0
- package/src/client/sentry.ts +77 -0
- package/src/client/types.ts +6 -0
- package/src/client.tsx +3 -0
- package/src/mock-reporting/watches.ts +2 -0
- package/src/shell.ts +27 -2
- package/src/tailwind.css +12 -19
- package/src/client/components/ActiveIndicator.tsx +0 -14
- package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
- package/src/client/review-desk/ConversationReviewBar.tsx +0 -85
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { LockKeyhole } from "lucide-react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { Spinner } from "../components/ui/spinner";
|
|
4
4
|
import { cn } from "../styles";
|
|
5
5
|
import type { VisualStatus } from "../types";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Render a conversation status marker. "Waiting on you" replaces every other
|
|
9
|
-
* marker,
|
|
9
|
+
* marker. A running conversation shows a spinner, so it never looks like an
|
|
10
|
+
* ended one.
|
|
10
11
|
*/
|
|
11
12
|
export function ConversationListStatusIcon(props: {
|
|
12
13
|
finishedSinceSeen: boolean;
|
|
@@ -24,6 +25,10 @@ export function ConversationListStatusIcon(props: {
|
|
|
24
25
|
);
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
if (props.status === "active") {
|
|
29
|
+
return <Spinner aria-label="Running" className="size-3 text-emerald-300" />;
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
if (props.finishedSinceSeen) {
|
|
28
33
|
return (
|
|
29
34
|
<span
|
|
@@ -40,8 +45,6 @@ export function ConversationListStatusIcon(props: {
|
|
|
40
45
|
aria-label="Private conversation"
|
|
41
46
|
className={cn(
|
|
42
47
|
"size-3 shrink-0",
|
|
43
|
-
props.status === "active" &&
|
|
44
|
-
"animate-[junior-active-indicator_1.8s_ease-in-out_infinite] text-emerald-300 drop-shadow-[0_0_6px_rgba(110,231,183,0.55)] motion-reduce:animate-none",
|
|
45
48
|
props.status === "failed" && "text-rose-300",
|
|
46
49
|
props.status === "idle" && "text-dashboard-text-muted",
|
|
47
50
|
)}
|
|
@@ -49,18 +52,20 @@ export function ConversationListStatusIcon(props: {
|
|
|
49
52
|
);
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
if (props.status === "
|
|
53
|
-
return
|
|
55
|
+
if (props.status === "failed") {
|
|
56
|
+
return (
|
|
57
|
+
<span
|
|
58
|
+
aria-label="Failed"
|
|
59
|
+
className="size-1.5 shrink-0 rounded-full bg-rose-300"
|
|
60
|
+
role="img"
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
return (
|
|
57
66
|
<span
|
|
58
67
|
aria-hidden="true"
|
|
59
|
-
className=
|
|
60
|
-
"size-1.5 shrink-0 rounded-full",
|
|
61
|
-
props.status === "failed" && "bg-rose-300",
|
|
62
|
-
props.status === "idle" && "bg-dashboard-text-faint",
|
|
63
|
-
)}
|
|
68
|
+
className="size-1.5 shrink-0 rounded-full bg-dashboard-text-faint"
|
|
64
69
|
/>
|
|
65
70
|
);
|
|
66
71
|
}
|
|
@@ -1,90 +1,130 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { z } from "zod";
|
|
1
|
+
import { ChevronRight } from "lucide-react";
|
|
2
|
+
import type { ConversationDetailReport } from "@sentry/junior/api/schema";
|
|
4
3
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { dashboardDisclosureChevronClass } from "../styles";
|
|
5
|
+
import type {
|
|
6
|
+
ConversationMemoryEntry,
|
|
7
|
+
ConversationMemoryList,
|
|
8
|
+
} from "./conversationMemory";
|
|
9
|
+
import { DetailsCard } from "./DetailsList";
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
visibility: z.enum(["private", "public"]),
|
|
18
|
-
})
|
|
19
|
-
.strict(),
|
|
20
|
-
),
|
|
21
|
-
})
|
|
22
|
-
.strict();
|
|
23
|
-
|
|
24
|
-
/** Load the memories captured from one Conversation. */
|
|
25
|
-
export function useConversationMemories(conversationId: string) {
|
|
26
|
-
return useQuery({
|
|
27
|
-
queryFn: ({ signal }) =>
|
|
28
|
-
fetchDashboardJson(
|
|
29
|
-
conversationMemoryListSchema,
|
|
30
|
-
`/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`,
|
|
31
|
-
signal,
|
|
32
|
-
),
|
|
33
|
-
queryKey: ["conversation", conversationId, "memories"],
|
|
34
|
-
retry: false,
|
|
35
|
-
});
|
|
11
|
+
/** Count the distinct memories that a Conversation recalled and captured. */
|
|
12
|
+
function conversationMemoryCounts(list: ConversationMemoryList) {
|
|
13
|
+
const ids = (source: "captured" | "recalled") =>
|
|
14
|
+
new Set(
|
|
15
|
+
list.events
|
|
16
|
+
.filter((event) => event.source === source)
|
|
17
|
+
.flatMap((event) => event.memoryIds),
|
|
18
|
+
).size;
|
|
19
|
+
return { captured: ids("captured"), recalled: ids("recalled") };
|
|
36
20
|
}
|
|
37
21
|
|
|
38
|
-
/** Show
|
|
39
|
-
export function
|
|
40
|
-
|
|
22
|
+
/** Show memory counts in one row that opens the full memory list. */
|
|
23
|
+
export function ConversationMemoryCard(props: {
|
|
24
|
+
list: ConversationMemoryList;
|
|
25
|
+
onOpen(): void;
|
|
26
|
+
}) {
|
|
27
|
+
const counts = conversationMemoryCounts(props.list);
|
|
28
|
+
const summary = [
|
|
29
|
+
counts.recalled ? `${counts.recalled} recalled` : undefined,
|
|
30
|
+
counts.captured ? `${counts.captured} captured` : undefined,
|
|
31
|
+
]
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.join(" · ");
|
|
34
|
+
return (
|
|
35
|
+
<DetailsCard title="Memories">
|
|
36
|
+
<button
|
|
37
|
+
className="group -mx-1.5 -my-1 flex min-h-8 w-[calc(100%+0.75rem)] items-center justify-between gap-2.5 rounded-md px-1.5 py-1.5 text-left text-sm tabular-nums text-dashboard-text transition-colors hover:bg-dashboard-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
|
|
38
|
+
onClick={props.onOpen}
|
|
39
|
+
type="button"
|
|
40
|
+
>
|
|
41
|
+
{summary}
|
|
42
|
+
<ChevronRight
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
className={dashboardDisclosureChevronClass}
|
|
45
|
+
/>
|
|
46
|
+
</button>
|
|
47
|
+
</DetailsCard>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
41
50
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
51
|
+
/**
|
|
52
|
+
* Map each loaded Turn to the prompt that started it, so a memory can say
|
|
53
|
+
* where it came from. A Turn starts from its last user input message.
|
|
54
|
+
*/
|
|
55
|
+
export function turnPrompts(
|
|
56
|
+
events: ConversationDetailReport["events"] | undefined,
|
|
57
|
+
): Map<string, string> {
|
|
58
|
+
const texts = new Map<string, string>();
|
|
59
|
+
const prompts = new Map<string, string>();
|
|
60
|
+
for (const { data } of events ?? []) {
|
|
61
|
+
if (data.type === "message" && data.role === "user" && data.text) {
|
|
62
|
+
texts.set(data.messageId, data.text);
|
|
63
|
+
}
|
|
64
|
+
if (data.type === "turn_lifecycle" && data.state === "started") {
|
|
65
|
+
const prompt = data.inputMessageIds
|
|
66
|
+
?.map((id) => texts.get(id))
|
|
67
|
+
.filter((text) => text !== undefined)
|
|
68
|
+
.at(-1);
|
|
69
|
+
if (prompt) prompts.set(data.turnId, prompt);
|
|
70
|
+
}
|
|
63
71
|
}
|
|
72
|
+
return prompts;
|
|
73
|
+
}
|
|
64
74
|
|
|
75
|
+
/**
|
|
76
|
+
* List every memory that a Conversation recalled or captured, with its full
|
|
77
|
+
* text. Selecting one shows it in the transcript.
|
|
78
|
+
*/
|
|
79
|
+
export function ConversationMemoryEntries(props: {
|
|
80
|
+
entries: ConversationMemoryEntry[];
|
|
81
|
+
onSelect(entry: ConversationMemoryEntry & { turnId: string }): void;
|
|
82
|
+
prompts: Map<string, string>;
|
|
83
|
+
}) {
|
|
65
84
|
return (
|
|
66
|
-
<ol className="m-0 grid list-none gap-
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
<ol className="m-0 grid list-none gap-2 p-0">
|
|
86
|
+
{props.entries.map((entry) => {
|
|
87
|
+
const { turnId } = entry;
|
|
88
|
+
const prompt = turnId ? props.prompts.get(turnId) : undefined;
|
|
89
|
+
const source =
|
|
90
|
+
entry.source === "recalled"
|
|
91
|
+
? prompt
|
|
92
|
+
? `Recalled for “${prompt}”`
|
|
93
|
+
: "Recalled"
|
|
94
|
+
: prompt
|
|
95
|
+
? `Captured after “${prompt}”`
|
|
96
|
+
: "Captured";
|
|
97
|
+
const body = (
|
|
98
|
+
<>
|
|
99
|
+
<p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
|
|
100
|
+
{entry.memory.content}
|
|
101
|
+
</p>
|
|
102
|
+
<span className="flex min-w-0 items-center gap-2 text-xs text-dashboard-text-muted">
|
|
103
|
+
<span className="min-w-0 truncate">{source}</span>
|
|
104
|
+
{entry.memory.visibility === "private" ? (
|
|
105
|
+
<span className="shrink-0">· Private</span>
|
|
106
|
+
) : null}
|
|
81
107
|
</span>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
const className =
|
|
111
|
+
"grid w-full gap-1.5 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel px-3 py-2.5 text-left font-sans";
|
|
112
|
+
return (
|
|
113
|
+
<li key={`${entry.source}:${entry.memory.id}`}>
|
|
114
|
+
{turnId ? (
|
|
115
|
+
<button
|
|
116
|
+
className={`${className} cursor-pointer transition-colors hover:bg-dashboard-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus`}
|
|
117
|
+
onClick={() => props.onSelect({ ...entry, turnId })}
|
|
118
|
+
type="button"
|
|
119
|
+
>
|
|
120
|
+
{body}
|
|
121
|
+
</button>
|
|
122
|
+
) : (
|
|
123
|
+
<div className={className}>{body}</div>
|
|
124
|
+
)}
|
|
125
|
+
</li>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
88
128
|
</ol>
|
|
89
129
|
);
|
|
90
130
|
}
|
|
@@ -6,11 +6,10 @@ import {
|
|
|
6
6
|
CircleX,
|
|
7
7
|
GitMerge,
|
|
8
8
|
GitPullRequest,
|
|
9
|
-
Globe2,
|
|
10
9
|
LockKeyhole,
|
|
11
10
|
TriangleAlert,
|
|
12
11
|
} from "lucide-react";
|
|
13
|
-
import { useSyncExternalStore } from "react";
|
|
12
|
+
import { useSyncExternalStore, type ReactNode } from "react";
|
|
14
13
|
import { Link } from "react-router";
|
|
15
14
|
import type { ConversationDetailReport } from "@sentry/junior/api/schema";
|
|
16
15
|
|
|
@@ -30,7 +29,6 @@ import {
|
|
|
30
29
|
TooltipTitle,
|
|
31
30
|
TooltipTrigger,
|
|
32
31
|
} from "../components/ui/tooltip";
|
|
33
|
-
import { MetricList, type MetricListItem } from "../components/Metric";
|
|
34
32
|
import { cn, dashboardInlineLinkClass } from "../styles";
|
|
35
33
|
import {
|
|
36
34
|
CostMetric,
|
|
@@ -73,35 +71,24 @@ export function PendingAuthorization(props: {
|
|
|
73
71
|
);
|
|
74
72
|
}
|
|
75
73
|
|
|
76
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Mark a private conversation with a lock icon. Public is the default, so it
|
|
76
|
+
* gets no marker, which keeps the title bar short.
|
|
77
|
+
*/
|
|
77
78
|
export function ConversationPrivacyChip(props: {
|
|
78
79
|
visibility: Conversation["visibility"];
|
|
79
80
|
}) {
|
|
80
|
-
if (
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
const shortLabel = isPublic ? "Public" : "Private";
|
|
84
|
-
const fullLabel = isPublic ? "Public conversation" : "Private conversation";
|
|
85
|
-
const detail = isPublic
|
|
86
|
-
? "Anyone in this workspace can see this transcript."
|
|
87
|
-
: "Only members of this conversation can see this transcript.";
|
|
81
|
+
if (props.visibility !== "private") return null;
|
|
82
|
+
const label =
|
|
83
|
+
"Private conversation. Only members of this conversation can see this transcript.";
|
|
88
84
|
return (
|
|
89
85
|
<span
|
|
90
|
-
className=
|
|
91
|
-
isPublic
|
|
92
|
-
? "inline-flex max-w-full items-center gap-1 rounded-full border border-dashboard-border-emphasis bg-dashboard-fill-soft px-2 py-0.5 font-sans text-2xs font-medium text-dashboard-text-muted"
|
|
93
|
-
: "inline-flex max-w-full items-center gap-1 rounded-full border border-dashboard-border-strong bg-dashboard-fill-soft px-2 py-0.5 font-sans text-2xs font-medium text-dashboard-text-muted"
|
|
94
|
-
}
|
|
86
|
+
className="inline-flex items-center text-dashboard-text-muted"
|
|
95
87
|
role="note"
|
|
96
|
-
title={
|
|
88
|
+
title={label}
|
|
97
89
|
>
|
|
98
|
-
<
|
|
99
|
-
<span
|
|
100
|
-
{shortLabel}
|
|
101
|
-
</span>
|
|
102
|
-
<span className="sr-only">
|
|
103
|
-
{fullLabel}. {detail}
|
|
104
|
-
</span>
|
|
90
|
+
<LockKeyhole aria-hidden="true" className="size-3.5 shrink-0" />
|
|
91
|
+
<span className="sr-only">{label}</span>
|
|
105
92
|
</span>
|
|
106
93
|
);
|
|
107
94
|
}
|
|
@@ -507,52 +494,95 @@ export function liveModelId(
|
|
|
507
494
|
return openTurns.size > 0 ? modelId : undefined;
|
|
508
495
|
}
|
|
509
496
|
|
|
510
|
-
/** True when runtime stats have content
|
|
497
|
+
/** True when runtime stats have content to show in details. */
|
|
511
498
|
export function hasConversationStats(props: {
|
|
512
499
|
conversation: Conversation | undefined;
|
|
513
500
|
detail?: ConversationDetailReport;
|
|
514
|
-
variant?: "compact" | "full";
|
|
515
501
|
}): boolean {
|
|
516
502
|
return conversationStatItems(props).length > 0;
|
|
517
503
|
}
|
|
518
504
|
|
|
519
|
-
type ConversationStatItem =
|
|
505
|
+
type ConversationStatItem = {
|
|
506
|
+
content: ReactNode;
|
|
507
|
+
key: string;
|
|
508
|
+
label: string;
|
|
509
|
+
/** Shown below `md` only, where the title bar is hidden. */
|
|
510
|
+
mobileOnly?: boolean;
|
|
511
|
+
};
|
|
520
512
|
|
|
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
|
-
*/
|
|
513
|
+
/** Render runtime, source, token, and cost metadata as a labeled list. */
|
|
526
514
|
export function ConversationStats(props: {
|
|
527
515
|
conversation: Conversation | undefined;
|
|
528
516
|
detail?: ConversationDetailReport;
|
|
529
|
-
variant?: "compact" | "full";
|
|
530
517
|
}) {
|
|
531
518
|
const stats = conversationStatItems(props);
|
|
532
519
|
if (stats.length === 0) return null;
|
|
520
|
+
return (
|
|
521
|
+
<dl className="m-0 grid min-w-0 gap-2 text-xs leading-snug">
|
|
522
|
+
{stats.map((item) => (
|
|
523
|
+
<div
|
|
524
|
+
className={cn(
|
|
525
|
+
"flex min-w-0 items-baseline justify-between gap-4",
|
|
526
|
+
item.mobileOnly && "md:hidden",
|
|
527
|
+
)}
|
|
528
|
+
key={item.key}
|
|
529
|
+
>
|
|
530
|
+
<dt className="shrink-0 text-dashboard-text-muted">{item.label}</dt>
|
|
531
|
+
<dd className="m-0 min-w-0 break-words text-right tabular-nums text-dashboard-text">
|
|
532
|
+
{item.content}
|
|
533
|
+
</dd>
|
|
534
|
+
</div>
|
|
535
|
+
))}
|
|
536
|
+
</dl>
|
|
537
|
+
);
|
|
538
|
+
}
|
|
533
539
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
540
|
+
/**
|
|
541
|
+
* Name where a conversation runs, linked when the source has a URL. Returns
|
|
542
|
+
* null without a location, so callers can drop the slot around it.
|
|
543
|
+
*/
|
|
544
|
+
export function conversationLocation(
|
|
545
|
+
conversation: Conversation | undefined,
|
|
546
|
+
detail: ConversationDetailReport | undefined,
|
|
547
|
+
): ReactNode {
|
|
548
|
+
if (!conversation) return null;
|
|
549
|
+
const location = slackLocationLabel(conversation, { includeId: false });
|
|
550
|
+
if (!location) return null;
|
|
551
|
+
return (
|
|
552
|
+
<LocationLink
|
|
553
|
+
label={location}
|
|
554
|
+
locationUrl={detail?.locationUrl ?? conversation.locationUrl}
|
|
555
|
+
/>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
551
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Show the estimated cost of a conversation with its breakdown tooltip.
|
|
561
|
+
* Returns null until there is a cost or a live run to show.
|
|
562
|
+
*/
|
|
563
|
+
export function conversationCost(
|
|
564
|
+
conversation: Conversation | undefined,
|
|
565
|
+
detail: ConversationDetailReport | undefined,
|
|
566
|
+
tooltipPlacement?: "above" | "below",
|
|
567
|
+
): ReactNode {
|
|
568
|
+
if (!conversation) return null;
|
|
569
|
+
const costSummary = summarizeCost(
|
|
570
|
+
detail?.cumulativeUsage ?? conversation.cumulativeUsage,
|
|
571
|
+
);
|
|
572
|
+
const auxiliaryCosts = detail?.auxiliaryCosts ?? conversation.auxiliaryCosts;
|
|
573
|
+
const live = (detail?.status ?? conversation.status) === "active";
|
|
574
|
+
if (!costSummary && !auxiliaryCosts && !live) return null;
|
|
552
575
|
return (
|
|
553
|
-
<
|
|
554
|
-
|
|
555
|
-
|
|
576
|
+
<CostMetric
|
|
577
|
+
auxiliaryCosts={auxiliaryCosts}
|
|
578
|
+
childAgentTotals={
|
|
579
|
+
detail?.childAgentTotals ?? conversation.childAgentTotals
|
|
580
|
+
}
|
|
581
|
+
live={live}
|
|
582
|
+
liveModelId={liveModelId(detail)}
|
|
583
|
+
modelUsage={detail?.modelUsage}
|
|
584
|
+
summary={costSummary}
|
|
585
|
+
tooltipPlacement={tooltipPlacement}
|
|
556
586
|
/>
|
|
557
587
|
);
|
|
558
588
|
}
|
|
@@ -560,10 +590,8 @@ export function ConversationStats(props: {
|
|
|
560
590
|
function conversationStatItems(props: {
|
|
561
591
|
conversation: Conversation | undefined;
|
|
562
592
|
detail?: ConversationDetailReport;
|
|
563
|
-
variant?: "compact" | "full";
|
|
564
593
|
}): ConversationStatItem[] {
|
|
565
594
|
if (!props.conversation) return [];
|
|
566
|
-
const variant = props.variant ?? "full";
|
|
567
595
|
const completeDetail = props.detail?.previousCursor
|
|
568
596
|
? undefined
|
|
569
597
|
: props.detail;
|
|
@@ -571,12 +599,6 @@ function conversationStatItems(props: {
|
|
|
571
599
|
props.detail?.cumulativeUsage ?? props.conversation.cumulativeUsage;
|
|
572
600
|
const tokenSummary =
|
|
573
601
|
summarizeModelUsage(props.detail?.modelUsage) ?? summarizeUsage(usage);
|
|
574
|
-
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
602
|
const durationLabel = formatConversationDuration(props.conversation);
|
|
581
603
|
const live = (props.detail?.status ?? props.conversation.status) === "active";
|
|
582
604
|
const activeModelId = liveModelId(props.detail);
|
|
@@ -584,22 +606,26 @@ function conversationStatItems(props: {
|
|
|
584
606
|
const childAgentTotals =
|
|
585
607
|
props.detail?.childAgentTotals ?? props.conversation.childAgentTotals;
|
|
586
608
|
const lastActivityAt = props.conversation.lastSeenAt;
|
|
609
|
+
const location = conversationLocation(props.conversation, props.detail);
|
|
610
|
+
const cost = conversationCost(props.conversation, props.detail);
|
|
587
611
|
const rawStats: Array<ConversationStatItem | undefined> = [
|
|
612
|
+
// The desktop title bar shows the location and cost. Mobile hides that bar.
|
|
588
613
|
location
|
|
589
614
|
? {
|
|
590
|
-
content:
|
|
615
|
+
content: location,
|
|
591
616
|
key: "location",
|
|
592
617
|
label: "Location",
|
|
618
|
+
mobileOnly: true,
|
|
593
619
|
}
|
|
594
620
|
: undefined,
|
|
595
|
-
|
|
621
|
+
sourceTask
|
|
596
622
|
? {
|
|
597
623
|
content: <SourceTask sourceTask={sourceTask} />,
|
|
598
624
|
key: "source-task",
|
|
599
625
|
label: "Source",
|
|
600
626
|
}
|
|
601
627
|
: undefined,
|
|
602
|
-
|
|
628
|
+
lastActivityAt
|
|
603
629
|
? {
|
|
604
630
|
content: (
|
|
605
631
|
<time
|
|
@@ -635,7 +661,6 @@ function conversationStatItems(props: {
|
|
|
635
661
|
? {
|
|
636
662
|
content: (
|
|
637
663
|
<TokenMetric
|
|
638
|
-
detailsHint={variant === "compact"}
|
|
639
664
|
childAgentTotals={childAgentTotals}
|
|
640
665
|
compactionCount={
|
|
641
666
|
completeDetail
|
|
@@ -654,27 +679,12 @@ function conversationStatItems(props: {
|
|
|
654
679
|
label: "Tokens",
|
|
655
680
|
}
|
|
656
681
|
: undefined,
|
|
657
|
-
|
|
658
|
-
props.detail?.auxiliaryCosts ||
|
|
659
|
-
props.conversation.auxiliaryCosts ||
|
|
660
|
-
live
|
|
682
|
+
cost
|
|
661
683
|
? {
|
|
662
|
-
content:
|
|
663
|
-
<CostMetric
|
|
664
|
-
detailsHint={variant === "compact"}
|
|
665
|
-
auxiliaryCosts={
|
|
666
|
-
props.detail?.auxiliaryCosts ??
|
|
667
|
-
props.conversation.auxiliaryCosts
|
|
668
|
-
}
|
|
669
|
-
childAgentTotals={childAgentTotals}
|
|
670
|
-
live={live}
|
|
671
|
-
liveModelId={activeModelId}
|
|
672
|
-
modelUsage={props.detail?.modelUsage}
|
|
673
|
-
summary={costSummary}
|
|
674
|
-
/>
|
|
675
|
-
),
|
|
684
|
+
content: cost,
|
|
676
685
|
key: "cost",
|
|
677
686
|
label: "Cost",
|
|
687
|
+
mobileOnly: true,
|
|
678
688
|
}
|
|
679
689
|
: undefined,
|
|
680
690
|
childAgentTotals
|
|
@@ -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
|
+
}
|