@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.
Files changed (52) hide show
  1. package/dist/app.js +28 -9
  2. package/dist/client.js +115 -92
  3. package/dist/index.js +28 -9
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/app.ts +8 -3
  7. package/src/client/App.tsx +11 -0
  8. package/src/client/components/Metric.tsx +0 -32
  9. package/src/client/components/ProfileMenu.tsx +56 -4
  10. package/src/client/components/layout/DashboardChrome.tsx +3 -3
  11. package/src/client/components/layout/DashboardHeader.tsx +41 -4
  12. package/src/client/components/layout/NavRail.tsx +43 -40
  13. package/src/client/conversations/ConversationComposer.tsx +42 -26
  14. package/src/client/conversations/ConversationDetails.tsx +149 -166
  15. package/src/client/conversations/ConversationHeader.tsx +126 -132
  16. package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
  17. package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
  18. package/src/client/conversations/ConversationMemories.tsx +118 -78
  19. package/src/client/conversations/ConversationMeta.tsx +94 -84
  20. package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
  21. package/src/client/conversations/ConversationPage.tsx +197 -422
  22. package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
  23. package/src/client/conversations/ConversationSidebar.tsx +5 -7
  24. package/src/client/conversations/ConversationTranscript.tsx +37 -8
  25. package/src/client/conversations/ConversationWatches.tsx +37 -15
  26. package/src/client/conversations/PendingMailboxStack.tsx +11 -1
  27. package/src/client/conversations/QuickQuestion.tsx +476 -0
  28. package/src/client/conversations/TelemetryMetrics.tsx +2 -15
  29. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  30. package/src/client/conversations/TranscriptEventView.tsx +25 -2
  31. package/src/client/conversations/TranscriptMemoryPopover.tsx +179 -0
  32. package/src/client/conversations/TranscriptMessageView.tsx +46 -16
  33. package/src/client/conversations/TranscriptRows.tsx +9 -2
  34. package/src/client/conversations/TranscriptView.tsx +15 -32
  35. package/src/client/conversations/UsageCard.tsx +4 -18
  36. package/src/client/conversations/conversationMemory.ts +138 -0
  37. package/src/client/conversations/conversationSections.ts +18 -14
  38. package/src/client/conversations/eventTranscript.ts +8 -0
  39. package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
  40. package/src/client/review-desk/ReviewItemActions.tsx +1 -1
  41. package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
  42. package/src/client/review-desk/ReviewItemContext.tsx +41 -23
  43. package/src/client/review-desk/reviewItemFormat.ts +16 -0
  44. package/src/client/sentry.ts +77 -0
  45. package/src/client/types.ts +6 -0
  46. package/src/client.tsx +3 -0
  47. package/src/mock-reporting/watches.ts +2 -0
  48. package/src/shell.ts +27 -2
  49. package/src/tailwind.css +12 -19
  50. package/src/client/components/ActiveIndicator.tsx +0 -14
  51. package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
  52. package/src/client/review-desk/ConversationReviewBar.tsx +0 -85
@@ -1,12 +1,13 @@
1
1
  import { LockKeyhole } from "lucide-react";
2
2
 
3
- import { ActiveIndicator } from "../components/ActiveIndicator";
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, then new completions take priority.
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 === "active") {
53
- return <ActiveIndicator className="size-1.5" />;
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={cn(
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 { useQuery } from "@tanstack/react-query";
2
- import { Brain } from "lucide-react";
3
- import { z } from "zod";
1
+ import { ChevronRight } from "lucide-react";
2
+ import type { ConversationDetailReport } from "@sentry/junior/api/schema";
4
3
 
5
- import { formatMessageTimestamp } from "../format";
6
- import { fetchDashboardJson } from "../http";
4
+ import { dashboardDisclosureChevronClass } from "../styles";
5
+ import type {
6
+ ConversationMemoryEntry,
7
+ ConversationMemoryList,
8
+ } from "./conversationMemory";
9
+ import { DetailsCard } from "./DetailsList";
7
10
 
8
- const conversationMemoryListSchema = z
9
- .object({
10
- memories: z.array(
11
- z
12
- .object({
13
- capturedAt: z.iso.datetime(),
14
- content: z.string().min(1),
15
- id: z.string().min(1),
16
- kind: z.enum(["preference", "procedure", "knowledge"]),
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 the memories captured from one Conversation. */
39
- export function ConversationMemories(props: { conversationId: string }) {
40
- const query = useConversationMemories(props.conversationId);
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
- if (query.isPending) {
43
- return (
44
- <p className="m-0 text-sm text-dashboard-text-muted" role="status">
45
- Loading memories…
46
- </p>
47
- );
48
- }
49
- if (query.error) {
50
- return (
51
- <p className="m-0 text-sm text-red-300/80">
52
- Could not load memories from this Conversation.
53
- </p>
54
- );
55
- }
56
- if (query.data.memories.length === 0) {
57
- return (
58
- <div className="grid justify-items-center gap-2 py-10 text-center text-dashboard-text-muted">
59
- <Brain aria-hidden="true" className="size-5 opacity-60" />
60
- <p className="m-0 text-sm">No memories were captured.</p>
61
- </div>
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-3 p-0">
67
- {query.data.memories.map((memory) => (
68
- <li
69
- className="grid gap-2 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel px-3 py-3"
70
- key={memory.id}
71
- >
72
- <p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
73
- {memory.content}
74
- </p>
75
- <div className="flex flex-wrap items-center gap-1.5 text-xs tabular-nums text-dashboard-text-muted">
76
- <span className="rounded-md border border-dashboard-border-subtle bg-dashboard-surface-raised px-1.5 py-0.5">
77
- {memory.kind}
78
- </span>
79
- <span className="rounded-md border border-dashboard-border-subtle bg-dashboard-surface-raised px-1.5 py-0.5">
80
- {memory.visibility}
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
- <time className="ml-auto" dateTime={memory.capturedAt}>
83
- {formatMessageTimestamp(Date.parse(memory.capturedAt), true)}
84
- </time>
85
- </div>
86
- </li>
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
- /** Show whether the selected conversation is public or private. */
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 (!props.visibility) return null;
81
- const isPublic = props.visibility === "public";
82
- const Icon = isPublic ? Globe2 : LockKeyhole;
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={`${fullLabel}. ${detail}`}
88
+ title={label}
97
89
  >
98
- <Icon aria-hidden="true" className="size-3 shrink-0" />
99
- <span aria-hidden="true" className="truncate">
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 for the requested presentation. */
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 = MetricListItem & { label: string };
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
- if ((props.variant ?? "full") === "full") {
535
- return (
536
- <dl className="m-0 grid min-w-0 gap-2 text-xs leading-snug">
537
- {stats.map((item) => (
538
- <div
539
- className="flex min-w-0 items-baseline justify-between gap-4"
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
- }
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
- <MetricList
554
- className="break-words text-xs leading-[1.45] text-dashboard-text-muted"
555
- items={stats}
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: <LocationLink label={location} locationUrl={locationUrl} />,
615
+ content: location,
591
616
  key: "location",
592
617
  label: "Location",
618
+ mobileOnly: true,
593
619
  }
594
620
  : undefined,
595
- variant === "full" && sourceTask
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
- variant === "full" && lastActivityAt
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
- costSummary ||
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
+ }