@sentry/junior-dashboard 0.273.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.
Files changed (36) hide show
  1. package/dist/app.js +3 -3
  2. package/dist/client.js +76 -76
  3. package/dist/index.js +3 -3
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/client/App.tsx +11 -0
  7. package/src/client/components/Metric.tsx +0 -32
  8. package/src/client/components/ProfileMenu.tsx +56 -4
  9. package/src/client/components/layout/DashboardChrome.tsx +3 -3
  10. package/src/client/components/layout/DashboardHeader.tsx +41 -4
  11. package/src/client/components/layout/NavRail.tsx +43 -40
  12. package/src/client/conversations/ConversationComposer.tsx +7 -4
  13. package/src/client/conversations/ConversationDetails.tsx +117 -162
  14. package/src/client/conversations/ConversationHeader.tsx +119 -133
  15. package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
  16. package/src/client/conversations/ConversationMeta.tsx +49 -44
  17. package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
  18. package/src/client/conversations/ConversationPage.tsx +33 -368
  19. package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
  20. package/src/client/conversations/ConversationSidebar.tsx +119 -60
  21. package/src/client/conversations/ConversationWorkspace.tsx +8 -3
  22. package/src/client/conversations/TelemetryMetrics.tsx +0 -14
  23. package/src/client/conversations/TranscriptView.tsx +15 -32
  24. package/src/client/conversations/UsageCard.tsx +1 -15
  25. package/src/client/conversations/composerAutocorrect.ts +12 -0
  26. package/src/client/conversations/conversationAge.ts +24 -0
  27. package/src/client/conversations/conversationRoutes.ts +14 -5
  28. package/src/client/conversations/conversationSections.ts +66 -48
  29. package/src/client/conversations/useConversationFinishedIndicators.ts +40 -8
  30. package/src/client/pages/settings/SettingsPage.tsx +28 -0
  31. package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
  32. package/src/client/review-desk/queries.ts +30 -6
  33. package/src/mock-reporting/routes.ts +1 -1
  34. package/src/tailwind.css +4 -0
  35. package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
  36. package/src/client/review-desk/ConversationReviewBar.tsx +0 -67
@@ -1,17 +1,12 @@
1
+ import { ChevronRight } from "lucide-react";
1
2
  import { useState, type ReactNode } from "react";
3
+ import type { ConversationDetailReport } from "@sentry/junior/api/schema";
2
4
 
3
5
  import { DetailsPanel, useDetailsPanel } from "../components/DetailsPanel";
4
- import {
5
- Tooltip,
6
- TooltipContent,
7
- TooltipTrigger,
8
- } from "../components/ui/tooltip";
9
- import {
10
- Tabs,
11
- TabsContent,
12
- TabsList,
13
- TabsTrigger,
14
- } from "../components/ui/tabs";
6
+ import { formatRelativeTime, formatTime } from "../format";
7
+ import { ConversationReviewItems } from "../review-desk/ConversationReviewItems";
8
+ import { dashboardDisclosureChevronClass } from "../styles";
9
+ import { ConversationBrief } from "./ConversationBrief";
15
10
  import {
16
11
  ConversationChildAgents,
17
12
  useConversationChildAgents,
@@ -29,62 +24,33 @@ import {
29
24
  useConversationWatches,
30
25
  } from "./ConversationWatches";
31
26
  import { DetailsCard } from "./DetailsList";
32
- import { ShareConversationButton } from "./ShareConversationButton";
33
27
  import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
34
28
 
35
- type DetailsTab = "agents" | "summary" | "memories";
36
-
37
- /** Keep the tabs fixed while each panel scrolls to the frame edge. */
38
- const detailsPanelClass =
39
- "min-h-0 overflow-y-auto overscroll-contain rounded-none px-4 pb-[max(1rem,env(safe-area-inset-bottom))] focus-visible:-outline-offset-2";
29
+ type Brief = NonNullable<ConversationDetailReport["brief"]>;
40
30
 
41
31
  /**
42
- * Show conversation details: the brief, participants, and run facts first,
43
- * then watches, linked work, and the usage breakdown. Share and Sentry links
44
- * stay in the header so every tab can reach them.
32
+ * Show conversation details in the order a reader asks about them: what the
33
+ * work is (the Brief), what needs them, what it changed or waits on, who did
34
+ * it, and what it cost. Each fact shows here only, not in the title bar.
45
35
  */
46
36
  export function ConversationDetails(props: {
47
- brief: ReactNode;
37
+ brief?: Brief;
48
38
  conversationId: string;
39
+ /** True when a sidebar review chip opened details. */
40
+ highlightReview: boolean;
49
41
  identity: ReactNode;
50
42
  links: ConversationLink[];
51
43
  onClose(): void;
52
44
  /** Open one child agent transcript. A details sheet closes first. */
53
45
  onOpenSubagent(target: SubagentTranscriptTarget): void;
54
46
  overview: ReactNode;
55
- sentryConversationUrl?: string;
56
47
  usage: ReactNode;
57
48
  }) {
58
- const [tab, setTab] = useState<DetailsTab>("summary");
59
49
  const panel = useDetailsPanel();
60
- // Load both lists with details so each tab can show its count.
61
- const memories = useConversationMemories(props.conversationId);
62
- const agents = useConversationChildAgents(props.conversationId);
63
- const watches = useConversationWatches(props.conversationId).data?.watches;
64
-
65
- // People first, then run facts. Watches come before linked work: they say
66
- // what can wake this agent later, so readers see them without another tab.
67
- const sections = [
68
- { content: props.identity, key: "participants", title: "Participants" },
69
- { content: props.overview, key: "overview", title: undefined },
70
- {
71
- content: watches?.length ? (
72
- <ConversationWatches watches={watches} />
73
- ) : null,
74
- count: watches?.length,
75
- key: "watching",
76
- title: "Watching",
77
- },
78
- {
79
- content: props.links.length ? (
80
- <ConversationLinkedWork links={props.links} />
81
- ) : null,
82
- count: props.links.length,
83
- key: "linked-work",
84
- title: "Linked work",
85
- },
86
- { content: props.usage, key: "usage", title: "Usage" },
87
- ].filter((section) => section.content != null);
50
+ const memories = useConversationMemories(props.conversationId).data?.memories;
51
+ const agents = useConversationChildAgents(props.conversationId).data?.agents;
52
+ const watches =
53
+ useConversationWatches(props.conversationId).data?.watches ?? [];
88
54
 
89
55
  return (
90
56
  <DetailsPanel
@@ -92,126 +58,115 @@ export function ConversationDetails(props: {
92
58
  title={
93
59
  <h2
94
60
  aria-label="Conversation details"
95
- className="m-0 text-base font-semibold text-dashboard-text"
61
+ className="m-0 flex min-w-0 items-center gap-1.5 font-sans text-sm"
96
62
  >
97
- Details
63
+ {props.brief ? (
64
+ <>
65
+ <span className="font-semibold text-dashboard-text">Brief</span>
66
+ <span aria-hidden="true" className="text-dashboard-text-faint">
67
+ ·
68
+ </span>
69
+ <time
70
+ className="truncate font-normal text-dashboard-text-muted"
71
+ dateTime={props.brief.updatedAt}
72
+ title={formatTime(props.brief.updatedAt, {
73
+ dateStyle: "medium",
74
+ timeStyle: "short",
75
+ })}
76
+ >
77
+ Updated {formatRelativeTime(props.brief.updatedAt)}
78
+ </time>
79
+ </>
80
+ ) : (
81
+ <span className="font-semibold text-dashboard-text">Details</span>
82
+ )}
98
83
  </h2>
99
84
  }
100
- actions={
101
- <>
102
- <ShareConversationButton
103
- key={props.conversationId}
104
- conversationId={props.conversationId}
105
- layout="icon"
106
- />
107
- {props.sentryConversationUrl ? (
108
- <Tooltip>
109
- <TooltipTrigger
110
- render={
111
- <a
112
- aria-label="Open in Sentry"
113
- className="grid size-9 place-items-center rounded-md text-dashboard-text-muted transition-colors hover:bg-dashboard-fill-strong hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus"
114
- href={props.sentryConversationUrl}
115
- rel="noopener noreferrer"
116
- target="_blank"
117
- >
118
- <SentryGlyph />
119
- </a>
120
- }
121
- />
122
- <TooltipContent size="compact" side="bottom">
123
- Open in Sentry
124
- </TooltipContent>
125
- </Tooltip>
126
- ) : null}
127
- </>
128
- }
129
85
  onClose={props.onClose}
130
86
  >
131
- <Tabs
132
- className="h-full min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-0"
133
- onValueChange={(value: DetailsTab) => setTab(value)}
134
- value={tab}
135
- >
136
- <TabsList
137
- activateOnFocus
138
- aria-label="Conversation details"
139
- className="m-4"
140
- >
141
- <TabsTrigger value="summary">
142
- <span className="truncate">Summary</span>
143
- </TabsTrigger>
144
- <TabsTrigger value="memories">
145
- <span className="truncate">Memories</span>
146
- <TabCount count={memories.data?.memories.length} />
147
- </TabsTrigger>
148
- <TabsTrigger value="agents">
149
- <span className="truncate">Subagents</span>
150
- <TabCount count={agents.data?.agents.length} />
151
- </TabsTrigger>
152
- </TabsList>
153
- <TabsContent className={detailsPanelClass} value="summary">
154
- {props.brief != null || sections.length > 0 ? (
155
- <div className="grid min-w-0 gap-3">
156
- {props.brief != null ? (
157
- <div className="min-w-0 break-words px-1 pb-1 font-sans text-sm leading-relaxed text-dashboard-text-muted">
158
- {props.brief}
159
- </div>
160
- ) : null}
161
- {sections.map((section) => (
162
- <DetailsCard
163
- count={section.count}
164
- key={section.key}
165
- title={section.title}
166
- >
167
- {section.content}
168
- </DetailsCard>
169
- ))}
170
- </div>
171
- ) : (
172
- <p className="m-0 font-sans text-sm leading-relaxed text-dashboard-text-muted">
173
- No additional conversation details.
174
- </p>
175
- )}
176
- </TabsContent>
177
- <TabsContent className={detailsPanelClass} value="memories">
178
- <ConversationMemories conversationId={props.conversationId} />
179
- </TabsContent>
180
- <TabsContent className={detailsPanelClass} value="agents">
181
- <ConversationChildAgents
182
- conversationId={props.conversationId}
183
- onOpenAgent={(target) => {
184
- // The panel stays beside the drawer. A modal sheet cannot.
185
- if (!panel) props.onClose();
186
- props.onOpenSubagent(target);
187
- }}
188
- />
189
- </TabsContent>
190
- </Tabs>
87
+ <div className="grid min-w-0 content-start gap-3 px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-3">
88
+ {props.brief ? (
89
+ <div className="min-w-0 break-words px-1 pb-1 font-sans text-sm leading-relaxed text-dashboard-text-muted">
90
+ <ConversationBrief brief={props.brief} variant="summary" />
91
+ </div>
92
+ ) : null}
93
+ <ConversationReviewItems
94
+ conversationId={props.conversationId}
95
+ highlight={props.highlightReview}
96
+ />
97
+ {props.links.length > 0 || watches.length > 0 ? (
98
+ // A Watch waits on something. It shows with linked work, so the
99
+ // work and what Junior waits on read as one list.
100
+ <DetailsCard
101
+ count={props.links.length || watches.length}
102
+ title={props.links.length > 0 ? "Linked work" : "Waiting on"}
103
+ >
104
+ {props.links.length > 0 ? (
105
+ <ConversationLinkedWork links={props.links} />
106
+ ) : null}
107
+ {props.links.length > 0 && watches.length > 0 ? (
108
+ <h4 className="m-0 mb-1.5 mt-3 font-sans text-xs font-medium text-dashboard-text-faint">
109
+ Also waiting on
110
+ </h4>
111
+ ) : null}
112
+ {watches.length > 0 ? (
113
+ <ConversationWatches watches={watches} />
114
+ ) : null}
115
+ </DetailsCard>
116
+ ) : null}
117
+ {agents?.length ? (
118
+ <DetailsCard count={agents.length} title="Subagents">
119
+ <ConversationChildAgents
120
+ conversationId={props.conversationId}
121
+ onOpenAgent={(target) => {
122
+ // The panel stays beside the drawer. A modal sheet cannot.
123
+ if (!panel) props.onClose();
124
+ props.onOpenSubagent(target);
125
+ }}
126
+ />
127
+ </DetailsCard>
128
+ ) : null}
129
+ {props.overview != null ? (
130
+ <DetailsCard>{props.overview}</DetailsCard>
131
+ ) : null}
132
+ {props.usage != null ? (
133
+ <DetailsCard title="Usage">{props.usage}</DetailsCard>
134
+ ) : null}
135
+ {props.identity != null ? (
136
+ <DetailsCard title="Participants">{props.identity}</DetailsCard>
137
+ ) : null}
138
+ {memories?.length ? (
139
+ <DetailsCard count={memories.length} title="Memories">
140
+ <Disclosure label="memories">
141
+ <ConversationMemories conversationId={props.conversationId} />
142
+ </Disclosure>
143
+ </DetailsCard>
144
+ ) : null}
145
+ </div>
191
146
  </DetailsPanel>
192
147
  );
193
148
  }
194
149
 
195
- /** Show a panel's entry count. Zero and unknown counts are hidden. */
196
- function TabCount(props: { count: number | undefined }) {
197
- if (!props.count) return null;
198
- return (
199
- <span className="min-w-5 shrink-0 rounded-full bg-dashboard-fill-strong px-1.5 text-center text-xs font-semibold leading-5 tabular-nums">
200
- {props.count.toLocaleString("en-US")}
201
- </span>
202
- );
203
- }
204
-
205
- /** Draw the Sentry glyph in the current text color, sized like a header icon. */
206
- function SentryGlyph() {
150
+ /** Hide long, rarely read content behind one show/hide row. */
151
+ function Disclosure(props: { children: ReactNode; label: string }) {
152
+ const [open, setOpen] = useState(false);
207
153
  return (
208
- <svg
209
- aria-hidden="true"
210
- className="size-4"
211
- fill="currentColor"
212
- viewBox="0 0 24 24"
213
- >
214
- <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" />
215
- </svg>
154
+ <div className="grid min-w-0 gap-2">
155
+ <button
156
+ aria-expanded={open}
157
+ className="group -mx-1.5 -my-1 flex min-h-8 items-center gap-2.5 rounded-md px-1.5 py-1.5 text-left text-xs font-medium leading-4 text-dashboard-text-muted transition-colors hover:bg-dashboard-surface-hover hover:text-dashboard-text focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
158
+ onClick={() => setOpen((value) => !value)}
159
+ type="button"
160
+ >
161
+ <span className="grid size-5 shrink-0 place-items-center">
162
+ <ChevronRight
163
+ aria-hidden="true"
164
+ className={dashboardDisclosureChevronClass}
165
+ />
166
+ </span>
167
+ {open ? `Hide ${props.label}` : `Show ${props.label}`}
168
+ </button>
169
+ {open ? props.children : null}
170
+ </div>
216
171
  );
217
172
  }
@@ -1,13 +1,5 @@
1
- import {
2
- cloneElement,
3
- isValidElement,
4
- useEffect,
5
- useRef,
6
- useState,
7
- type ReactElement,
8
- type ReactNode,
9
- } from "react";
10
- import { Ellipsis, Menu } from "lucide-react";
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { Menu, PanelRight, ScrollText, X } from "lucide-react";
11
3
 
12
4
  import { SearchInput } from "../components/SearchInput";
13
5
  import { Button } from "../components/ui/button";
@@ -20,15 +12,21 @@ import {
20
12
  import {
21
13
  ConversationHeaderActions,
22
14
  HeaderIconButton,
23
- TranscriptViewToggle,
15
+ TranscriptSearchToggle,
24
16
  type ConversationArchiveAction,
25
17
  } from "./ConversationHeaderActions";
18
+ import { ConversationOverflowMenu } from "./ConversationOverflowMenu";
19
+ import { ShareConversationButton } from "./ShareConversationButton";
26
20
  import type { TranscriptViewMode } from "./transcriptRenderModel";
27
21
 
28
- /** Show the conversation title, search, and details. */
22
+ /**
23
+ * Show the conversation title bar: where it runs, the title, and actions.
24
+ * Run facts, linked work, and the Brief live in Conversation details.
25
+ */
29
26
  export function ConversationHeader(props: {
30
27
  /** Parent chain of a child Conversation. Root Conversations omit it. */
31
28
  agentTree?: ReactNode;
29
+ /** A `menu` layout copy action for the `⋯` menu. */
32
30
  copyAction?: ReactNode;
33
31
  archive: ConversationArchiveAction;
34
32
  conversationId: string;
@@ -36,13 +34,16 @@ export function ConversationHeader(props: {
36
34
  onDetailsClick(): void;
37
35
  /** Links to the fork source and to forks of this conversation. */
38
36
  forks?: ReactNode;
37
+ /** Mobile only. Desktop shows linked work in Conversation details. */
39
38
  linkedWork?: ReactNode;
40
39
  live: boolean;
41
- meta?: ReactNode;
40
+ /** Where the conversation runs, such as a Slack channel. */
41
+ location?: ReactNode;
42
42
  onSearchChange(value: string): void;
43
43
  onViewChange(value: TranscriptViewMode): void;
44
44
  privacy: ReactNode;
45
45
  search: string;
46
+ sentryConversationUrl?: string;
46
47
  title: string;
47
48
  view: TranscriptViewMode;
48
49
  }) {
@@ -51,9 +52,7 @@ export function ConversationHeader(props: {
51
52
  const menuButtonRef = useRef<HTMLButtonElement>(null);
52
53
  const wasDetailsOpen = useRef(detailsOpen);
53
54
  const [searchOpen, setSearchOpen] = useState(false);
54
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
55
55
  const searchInputRef = useRef<HTMLInputElement>(null);
56
- const mobileMenuRef = useRef<HTMLDivElement>(null);
57
56
  const searchOpenVisible =
58
57
  props.view === "rich" && (searchOpen || props.search.length > 0);
59
58
  const openMobileNavigation = useOpenMobileNavigation();
@@ -77,27 +76,6 @@ export function ConversationHeader(props: {
77
76
  searchInputRef.current?.focus();
78
77
  }, [searchOpenVisible]);
79
78
 
80
- useEffect(() => {
81
- if (!mobileMenuOpen) return;
82
-
83
- function closeOnOutsideClick(event: PointerEvent) {
84
- if (!mobileMenuRef.current?.contains(event.target as Node)) {
85
- setMobileMenuOpen(false);
86
- }
87
- }
88
-
89
- function closeOnEscape(event: KeyboardEvent) {
90
- if (event.key === "Escape") setMobileMenuOpen(false);
91
- }
92
-
93
- document.addEventListener("pointerdown", closeOnOutsideClick);
94
- document.addEventListener("keydown", closeOnEscape);
95
- return () => {
96
- document.removeEventListener("pointerdown", closeOnOutsideClick);
97
- document.removeEventListener("keydown", closeOnEscape);
98
- };
99
- }, [mobileMenuOpen]);
100
-
101
79
  const toggleSearch = () => {
102
80
  if (searchOpenVisible) {
103
81
  setSearchOpen(false);
@@ -107,64 +85,29 @@ export function ConversationHeader(props: {
107
85
  setSearchOpen(true);
108
86
  };
109
87
 
110
- const menuCopyAction =
111
- isValidElement(props.copyAction) &&
112
- typeof props.copyAction.type !== "string"
113
- ? cloneElement(
114
- props.copyAction as ReactElement<{ layout?: "icon" | "menu" }>,
115
- { layout: "menu" },
116
- )
117
- : props.copyAction;
118
-
119
- const mobileOverflow = (
120
- <div className="relative" ref={mobileMenuRef}>
121
- <HeaderIconButton
122
- ref={menuButtonRef}
123
- label={mobileMenuOpen ? "Close conversation menu" : "Conversation menu"}
124
- onClick={() => setMobileMenuOpen((open) => !open)}
125
- pressed={mobileMenuOpen}
126
- >
127
- <Ellipsis aria-hidden="true" size={18} strokeWidth={2} />
128
- </HeaderIconButton>
129
- {mobileMenuOpen ? (
130
- <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">
131
- <ConversationHeaderActions
132
- archive={props.archive}
133
- conversationId={props.conversationId}
134
- copyAction={menuCopyAction}
135
- detailsOpen={detailsOpen}
136
- layout="menu"
137
- onDetailsClick={() => {
138
- setMobileMenuOpen(false);
139
- props.onDetailsClick();
140
- }}
141
- onSearchClick={() => {
142
- setMobileMenuOpen(false);
143
- toggleSearch();
144
- }}
145
- onViewChange={(value) => {
146
- setMobileMenuOpen(false);
147
- props.onViewChange(value);
148
- }}
149
- searchOpen={searchOpenVisible}
150
- view={props.view}
151
- />
152
- {openMobileNavigation ? (
153
- <Button
154
- className="mt-0.5"
155
- onClick={() => {
156
- setMobileMenuOpen(false);
157
- openMobileNavigation();
158
- }}
159
- size="menu"
160
- >
161
- <Menu aria-hidden="true" size={16} strokeWidth={2} />
162
- App menu
163
- </Button>
164
- ) : null}
165
- </div>
166
- ) : null}
167
- </div>
88
+ const menuActions = (placement: "desktop" | "mobile", close: () => void) => (
89
+ <ConversationHeaderActions
90
+ archive={props.archive}
91
+ conversationId={props.conversationId}
92
+ copyAction={props.copyAction}
93
+ detailsOpen={detailsOpen}
94
+ onDetailsClick={() => {
95
+ close();
96
+ props.onDetailsClick();
97
+ }}
98
+ onSearchClick={() => {
99
+ close();
100
+ toggleSearch();
101
+ }}
102
+ onViewChange={(value) => {
103
+ close();
104
+ props.onViewChange(value);
105
+ }}
106
+ placement={placement}
107
+ searchOpen={searchOpenVisible}
108
+ sentryConversationUrl={props.sentryConversationUrl}
109
+ view={props.view}
110
+ />
168
111
  );
169
112
 
170
113
  const showMobileHeader =
@@ -184,48 +127,98 @@ export function ConversationHeader(props: {
184
127
 
185
128
  return (
186
129
  <>
187
- <MobileHeaderActionsPortal>{mobileOverflow}</MobileHeaderActionsPortal>
130
+ <MobileHeaderActionsPortal>
131
+ <ConversationOverflowMenu buttonRef={menuButtonRef}>
132
+ {(close) => (
133
+ <>
134
+ {menuActions("mobile", close)}
135
+ {openMobileNavigation ? (
136
+ <Button
137
+ className="mt-0.5"
138
+ onClick={() => {
139
+ close();
140
+ openMobileNavigation();
141
+ }}
142
+ size="menu"
143
+ >
144
+ <Menu aria-hidden="true" size={16} strokeWidth={2} />
145
+ App menu
146
+ </Button>
147
+ ) : null}
148
+ </>
149
+ )}
150
+ </ConversationOverflowMenu>
151
+ </MobileHeaderActionsPortal>
188
152
  <MobileHeaderLivePortal>{liveIndicator}</MobileHeaderLivePortal>
153
+ {/* Desktop: above the event log's sticky toolbar, so the `⋯` menu
154
+ covers it. Mobile: below the app header, which holds the menu. */}
189
155
  <header
190
156
  className={cn(
191
- "relative z-10 border-b border-dashboard-border bg-dashboard-bg",
157
+ "relative z-10 border-b md:z-30 border-dashboard-border bg-dashboard-bg",
192
158
  !showMobileHeader && "hidden md:block",
193
159
  )}
194
160
  >
195
- <div className="hidden min-w-0 items-start justify-between gap-4 px-7 py-5 md:flex">
196
- <div className="min-w-0">
161
+ <div className="hidden h-12 min-w-0 items-center justify-between gap-4 pl-7 pr-4 md:flex">
162
+ <div className="flex min-w-0 items-center gap-2">
197
163
  {props.agentTree ? (
198
- <div className="mb-1.5 min-w-0">{props.agentTree}</div>
164
+ <div className="min-w-0 shrink-[2]">{props.agentTree}</div>
165
+ ) : props.location ? (
166
+ <span className="shrink-0 text-sm text-dashboard-text-muted">
167
+ {props.location}
168
+ </span>
199
169
  ) : null}
200
- <div className="flex min-w-0 items-center gap-2">
201
- <h2 className="m-0 line-clamp-1 min-w-0 font-display text-xl font-medium leading-tight tracking-tight">
202
- {props.title}
203
- </h2>
204
- {liveIndicator}
205
- <span className="shrink-0">{props.privacy}</span>
206
- </div>
207
- {props.meta ? (
208
- <div className="mt-2 min-w-0 text-xs leading-snug text-dashboard-text-muted">
209
- {props.meta}
210
- </div>
211
- ) : null}
212
- {props.forks ? (
213
- <div className="mt-1.5 min-w-0">{props.forks}</div>
170
+ {props.agentTree || props.location ? (
171
+ <span aria-hidden="true" className="text-dashboard-text-faint">
172
+ ›
173
+ </span>
214
174
  ) : null}
175
+ <h2 className="m-0 line-clamp-1 min-w-0 font-display text-base font-medium leading-tight tracking-tight">
176
+ {props.title}
177
+ </h2>
178
+ {liveIndicator}
179
+ <span className="shrink-0">{props.privacy}</span>
180
+ </div>
181
+ <div className="flex shrink-0 items-center gap-0.5">
182
+ {props.view === "raw" ? (
183
+ // Event log is a debug view reached from the menu. Say which
184
+ // view this is, and give one click back.
185
+ <Button
186
+ aria-label="Close event log"
187
+ className="mr-1 h-8 px-2.5"
188
+ onClick={() => props.onViewChange("rich")}
189
+ variant="quiet"
190
+ >
191
+ <ScrollText aria-hidden="true" size={14} strokeWidth={2} />
192
+ Event log
193
+ <X aria-hidden="true" size={14} strokeWidth={2} />
194
+ </Button>
195
+ ) : (
196
+ <TranscriptSearchToggle
197
+ onClick={toggleSearch}
198
+ open={searchOpenVisible}
199
+ />
200
+ )}
201
+ <ShareConversationButton
202
+ key={props.conversationId}
203
+ conversationId={props.conversationId}
204
+ layout="icon"
205
+ />
206
+ <ConversationOverflowMenu>
207
+ {(close) => menuActions("desktop", close)}
208
+ </ConversationOverflowMenu>
209
+ <HeaderIconButton
210
+ ref={detailsButtonRef}
211
+ label="Conversation details"
212
+ onClick={props.onDetailsClick}
213
+ pressed={detailsOpen}
214
+ >
215
+ <PanelRight aria-hidden="true" size={15} strokeWidth={2} />
216
+ </HeaderIconButton>
215
217
  </div>
216
- <ConversationHeaderActions
217
- archive={props.archive}
218
- conversationId={props.conversationId}
219
- copyAction={props.copyAction}
220
- detailsOpen={detailsOpen}
221
- detailsButtonRef={detailsButtonRef}
222
- onDetailsClick={props.onDetailsClick}
223
- onSearchClick={toggleSearch}
224
- onViewChange={props.onViewChange}
225
- searchOpen={searchOpenVisible}
226
- view={props.view}
227
- />
228
218
  </div>
219
+ {props.forks ? (
220
+ <div className="hidden px-7 pb-2.5 md:block">{props.forks}</div>
221
+ ) : null}
229
222
  {props.agentTree ? (
230
223
  <div className="px-4 py-2 md:hidden">{props.agentTree}</div>
231
224
  ) : null}
@@ -235,18 +228,11 @@ export function ConversationHeader(props: {
235
228
  {props.linkedWork ? (
236
229
  <div
237
230
  aria-label="Linked work"
238
- className="border-t border-dashboard-border-subtle px-4 py-2.5 md:px-7"
231
+ className="border-t border-dashboard-border-subtle px-4 py-2.5 md:hidden"
239
232
  >
240
233
  {props.linkedWork}
241
234
  </div>
242
235
  ) : null}
243
- <div className="hidden border-t border-dashboard-border-subtle px-7 md:flex">
244
- <TranscriptViewToggle
245
- labelled
246
- onChange={props.onViewChange}
247
- value={props.view}
248
- />
249
- </div>
250
236
  {searchOpenVisible ? (
251
237
  <div className="px-4 py-3 md:px-7">
252
238
  <SearchInput