@sentry/junior-dashboard 0.261.0 → 0.262.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 (29) hide show
  1. package/dist/app.js +2 -2
  2. package/dist/client.js +96 -74
  3. package/dist/index.js +2 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -3
  6. package/src/client/components/BottomSheet.tsx +32 -0
  7. package/src/client/components/Button.tsx +2 -1
  8. package/src/client/components/Drawer.tsx +23 -109
  9. package/src/client/components/README.md +12 -3
  10. package/src/client/components/SegmentedTabs.tsx +23 -4
  11. package/src/client/components/Sheet.LICENSE +21 -0
  12. package/src/client/components/Sheet.tsx +141 -0
  13. package/src/client/conversations/{ConversationDetailsDrawer.tsx → ConversationDetails.tsx} +19 -38
  14. package/src/client/conversations/ConversationDetailsFrame.tsx +91 -0
  15. package/src/client/conversations/ConversationEventLog.tsx +0 -1
  16. package/src/client/conversations/ConversationHeader.tsx +20 -28
  17. package/src/client/conversations/ConversationHeaderActions.tsx +5 -1
  18. package/src/client/conversations/ConversationPage.tsx +49 -36
  19. package/src/client/conversations/ForkConversationButton.tsx +0 -1
  20. package/src/client/conversations/README.md +18 -2
  21. package/src/client/conversations/SubagentTranscriptDrawer.tsx +0 -1
  22. package/src/client/conversations/TelemetryMetrics.tsx +2 -2
  23. package/src/client/conversations/TranscriptSubagentView.tsx +1 -1
  24. package/src/client/conversations/TranscriptTurnContextView.tsx +0 -1
  25. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +0 -1
  26. package/src/client/pages/dev/ComponentsPage.tsx +16 -2
  27. package/src/client/pages/dev/SegmentedFixtures.tsx +8 -7
  28. package/src/client/pages/memory/MemoryDetailsDrawer.tsx +0 -1
  29. package/LICENSE +0 -201
@@ -1,118 +1,29 @@
1
1
  import { X } from "lucide-react";
2
- import { useEffect, useRef, type ReactNode } from "react";
2
+ import { useRef, type ReactNode } from "react";
3
3
 
4
- import {
5
- acquireBodyScrollLock,
6
- releaseBodyScrollLock,
7
- } from "../bodyScrollLock";
8
4
  import { cn } from "../styles";
9
5
  import { Button } from "./Button";
6
+ import { Sheet, SheetBody, SheetClose, SheetContent } from "./Sheet";
10
7
 
11
- const focusableSelector = [
12
- "a[href]",
13
- "button:not([disabled])",
14
- "input:not([disabled]):not([type='hidden'])",
15
- "select:not([disabled])",
16
- "textarea:not([disabled])",
17
- "summary",
18
- "[tabindex]:not([tabindex='-1'])",
19
- ].join(",");
20
-
21
- /** Render an accessible modal drawer and own its focus and scroll lifecycle. */
8
+ /** Render a side drawer with shared modal focus and dismissal behavior. */
22
9
  export function Drawer(props: {
23
10
  actions?: ReactNode;
24
11
  children: ReactNode;
25
12
  closeLabel: string;
26
- dismissLabel: string;
27
13
  header: ReactNode;
28
14
  onClose(): void;
29
15
  openKey: string;
30
16
  titleId: string;
31
17
  width?: "default" | "wide" | "narrow";
32
18
  }) {
33
- const dialogRef = useRef<HTMLDivElement>(null);
34
- const onCloseRef = useRef(props.onClose);
35
- const previousFocusRef = useRef<HTMLElement | undefined>(undefined);
36
- onCloseRef.current = props.onClose;
37
-
38
- useEffect(() => {
39
- previousFocusRef.current =
40
- document.activeElement instanceof HTMLElement
41
- ? document.activeElement
42
- : undefined;
43
- acquireBodyScrollLock();
44
- const focusFrame = requestAnimationFrame(() => {
45
- dialogRef.current
46
- ?.querySelector<HTMLElement>("[data-drawer-close]")
47
- ?.focus();
48
- });
49
- const onKeyDown = (event: KeyboardEvent) => {
50
- // An event detail drawer can open above a child conversation drawer.
51
- // Only the last mounted drawer owns Escape and the focus trap.
52
- const drawers = document.querySelectorAll("[data-dashboard-drawer]");
53
- if (drawers[drawers.length - 1] !== dialogRef.current) return;
54
- if (event.key === "Escape") {
55
- event.preventDefault();
56
- onCloseRef.current();
57
- return;
58
- }
59
- if (event.key !== "Tab") return;
60
-
61
- const dialog = dialogRef.current;
62
- const focusable = Array.from(
63
- dialog?.querySelectorAll<HTMLElement>(focusableSelector) ?? [],
64
- ).filter(
65
- (element) =>
66
- element.tabIndex >= 0 && element.getClientRects().length > 0,
67
- );
68
- const first = focusable.at(0);
69
- const last = focusable.at(-1);
70
- if (!dialog || !first || !last) {
71
- event.preventDefault();
72
- return;
73
- }
74
- const active = document.activeElement;
75
- if (event.shiftKey && (active === first || !dialog.contains(active))) {
76
- event.preventDefault();
77
- last.focus();
78
- } else if (
79
- !event.shiftKey &&
80
- (active === last || !dialog.contains(active))
81
- ) {
82
- event.preventDefault();
83
- first.focus();
84
- }
85
- };
86
- window.addEventListener("keydown", onKeyDown);
87
- return () => {
88
- cancelAnimationFrame(focusFrame);
89
- window.removeEventListener("keydown", onKeyDown);
90
- releaseBodyScrollLock();
91
- const previousFocus = previousFocusRef.current;
92
- previousFocusRef.current = undefined;
93
- if (previousFocus?.isConnected) previousFocus.focus();
94
- };
95
- }, [props.openKey]);
96
-
19
+ const closeRef = useRef<HTMLButtonElement>(null);
97
20
  return (
98
- <div
99
- aria-labelledby={props.titleId}
100
- aria-modal="true"
101
- data-dashboard-drawer=""
102
- className="fixed inset-0 z-50"
103
- ref={dialogRef}
104
- role="dialog"
105
- >
106
- <button
107
- aria-label={props.dismissLabel}
108
- className="absolute inset-0 cursor-default bg-dashboard-overlay"
109
- onClick={props.onClose}
110
- tabIndex={-1}
111
- type="button"
112
- />
113
- <aside
21
+ <Sheet key={props.openKey} onClose={props.onClose} side="right">
22
+ <SheetContent
23
+ initialFocusRef={closeRef}
24
+ titleId={props.titleId}
25
+ side="right"
114
26
  className={cn(
115
- "absolute top-0 right-0 grid h-full w-full grid-rows-[auto_minmax(0,1fr)] bg-dashboard-bg-elevated shadow-[-20px_0_60px_rgba(0,0,0,0.45)] md:border-l md:border-dashboard-border-strong",
116
27
  props.width === "wide"
117
28
  ? "md:w-[min(760px,94vw)]"
118
29
  : props.width === "narrow"
@@ -124,21 +35,24 @@ export function Drawer(props: {
124
35
  <div className="min-w-0">{props.header}</div>
125
36
  <div className="flex items-start gap-1.5">
126
37
  {props.actions}
127
- <Button
128
- aria-label={props.closeLabel}
129
- data-drawer-close
130
- onClick={props.onClose}
131
- size="icon"
132
- title="Close"
38
+ <SheetClose
39
+ render={
40
+ <Button
41
+ ref={closeRef}
42
+ aria-label={props.closeLabel}
43
+ size="icon"
44
+ title="Close"
45
+ />
46
+ }
133
47
  >
134
48
  <X aria-hidden="true" size={15} strokeWidth={2.25} />
135
- </Button>
49
+ </SheetClose>
136
50
  </div>
137
51
  </header>
138
- <div className="min-h-0 overflow-auto px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-5">
52
+ <SheetBody className="min-h-0 overflow-auto px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-5">
139
53
  {props.children}
140
- </div>
141
- </aside>
142
- </div>
54
+ </SheetBody>
55
+ </SheetContent>
56
+ </Sheet>
143
57
  );
144
58
  }
@@ -80,8 +80,14 @@ token so each theme can redefine it.
80
80
  sentence-case text, with no border or fill. It is not a control.
81
81
  - **Messages.** Use `Notice` for compact transient messages and `FormNotice`
82
82
  for wrapping form recovery text.
83
- - **Overlays.** `Drawer` owns dismissal, focus trapping, focus return, scroll
84
- locking, and panel layout. Context panels use it, not a separate modal.
83
+ - **Overlays.** `Sheet` is a local adaptation of shadcn/ui's Base UI Sheet.
84
+ It uses Base UI Drawer for focus, dismissal, and native swipe support. The
85
+ app's shared scroll lock keeps it in sync with the mobile conversation shell.
86
+ `SheetContent` owns side placement, backdrop fade, and slide transitions.
87
+ `Drawer` adds a visible header and close control. `BottomSheet` adds a swipe
88
+ handle and keeps content scrolling separate from gestures. Both skip motion
89
+ when reduced motion is set. Docked conversation details use a non-modal aside
90
+ so the chat stays usable. The copied component's license is in `Sheet.LICENSE`.
85
91
  `Tooltip` and `IconButtonTooltip` own hover text.
86
92
  - **Identity.** `ActorAvatar`, `ParticipantAvatarStack`, and `ObjectIcon`.
87
93
  `ObjectIcon` owns native Ticket, Code change, Automation, Deployment, and
@@ -103,7 +109,10 @@ levels stay distinct.
103
109
  the row width while their labels fit. Rows that do not fit scroll sideways.
104
110
  - **Panel switches** use `SegmentedTabs`, which draws a pill track. Pass the
105
111
  selected value, items, change handler, and panel content. It owns tab and
106
- panel labels and keyboard focus.
112
+ panel labels and keyboard focus. Pass `scroll` to keep the tabs fixed while
113
+ the panel content scrolls. That mode fills its frame and owns the content
114
+ padding, so its scrollbar stays at the frame edge. `BottomSheet` content owns
115
+ its own padding.
107
116
 
108
117
  Keep collection filters in `FilterTabList`.
109
118
 
@@ -12,7 +12,7 @@ const segmentedTabsInsetClass = "gap-1 p-1";
12
12
  /** Style one tab or link in the segmented pill track. */
13
13
  export function segmentedTabClass(selected: boolean): string {
14
14
  return cn(
15
- "min-h-9 cursor-pointer rounded-md border-0 px-3 py-2 font-sans text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus",
15
+ "min-h-9 cursor-pointer rounded-md border-0 px-1.5 py-2 font-sans text-xs font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus",
16
16
  selected
17
17
  ? "bg-dashboard-fill-strong text-dashboard-text"
18
18
  : "bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-faint hover:text-dashboard-text",
@@ -23,6 +23,8 @@ export function segmentedTabClass(selected: boolean): string {
23
23
  export function SegmentedTabs<const Value extends string>(props: {
24
24
  children: ReactNode;
25
25
  label: string;
26
+ /** Fill the frame with fixed tabs and padded, scrolling content. */
27
+ scroll?: boolean;
26
28
  /** `count` is the number of entries in the panel. Zero and unknown counts are hidden. */
27
29
  items: readonly { count?: number; label: string; value: Value }[];
28
30
  onChange(value: NoInfer<Value>): void;
@@ -31,10 +33,22 @@ export function SegmentedTabs<const Value extends string>(props: {
31
33
  const id = useId();
32
34
 
33
35
  return (
34
- <div className="grid min-w-0 gap-4">
36
+ <div
37
+ className={cn(
38
+ "grid min-w-0",
39
+ props.scroll
40
+ ? "h-full min-h-0 grid-rows-[auto_minmax(0,1fr)]"
41
+ : "gap-4",
42
+ )}
43
+ >
35
44
  <div
36
45
  aria-label={props.label}
37
- className={cn("flex", segmentedTabsFrameClass, segmentedTabsInsetClass)}
46
+ className={cn(
47
+ "flex min-w-0",
48
+ segmentedTabsFrameClass,
49
+ segmentedTabsInsetClass,
50
+ props.scroll && "m-4",
51
+ )}
38
52
  role="tablist"
39
53
  >
40
54
  {props.items.map((item, index) => (
@@ -88,7 +102,12 @@ export function SegmentedTabs<const Value extends string>(props: {
88
102
  </div>
89
103
  <div
90
104
  aria-labelledby={`${id}-${props.value}`}
91
- className="min-w-0 rounded-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
105
+ className={cn(
106
+ "min-w-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus",
107
+ props.scroll
108
+ ? "min-h-0 overflow-y-auto overscroll-contain px-4 pb-[max(1rem,env(safe-area-inset-bottom))] focus-visible:-outline-offset-2"
109
+ : "rounded-md",
110
+ )}
92
111
  id={`${id}-panel`}
93
112
  role="tabpanel"
94
113
  tabIndex={0}
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 shadcn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,141 @@
1
+ // Adapted from shadcn/ui's Base UI Sheet. Drawer adds native swipe support.
2
+ // https://ui.shadcn.com/docs/components/base/sheet
3
+ import { Drawer as SheetPrimitive } from "@base-ui/react/drawer";
4
+ import {
5
+ createContext,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ useSyncExternalStore,
11
+ type ReactNode,
12
+ type RefObject,
13
+ } from "react";
14
+
15
+ import {
16
+ acquireBodyScrollLock,
17
+ releaseBodyScrollLock,
18
+ } from "../bodyScrollLock";
19
+ import { cn } from "../styles";
20
+
21
+ const SheetContext = createContext<{
22
+ closingRef: RefObject<boolean>;
23
+ popupRef: RefObject<HTMLDivElement | null>;
24
+ topmost: boolean;
25
+ } | null>(null);
26
+
27
+ // Separate roots can overlap when a resize mounts a sheet below an open drawer.
28
+ // Only the last modal in document order may trap focus or handle dismissal.
29
+ function subscribeToSheets(onChange: () => void) {
30
+ const observer = new MutationObserver(onChange);
31
+ observer.observe(document.body, { childList: true, subtree: true });
32
+ return () => observer.disconnect();
33
+ }
34
+
35
+ /** Keep modal state until Base UI finishes its close transition. */
36
+ export function Sheet(props: {
37
+ children: ReactNode;
38
+ onClose(): void;
39
+ side: "right" | "bottom";
40
+ }) {
41
+ const [open, setOpen] = useState(false);
42
+ const closingRef = useRef(false);
43
+ const popupRef = useRef<HTMLDivElement>(null);
44
+ const topmost = useSyncExternalStore(
45
+ subscribeToSheets,
46
+ () => {
47
+ const modals = document.querySelectorAll("[data-dashboard-modal]");
48
+ const last = modals[modals.length - 1];
49
+ return popupRef.current ? last === popupRef.current : !last;
50
+ },
51
+ () => true,
52
+ );
53
+ useEffect(() => {
54
+ // The mobile conversation shell uses this same document scroll lock.
55
+ acquireBodyScrollLock();
56
+ setOpen(true);
57
+ return releaseBodyScrollLock;
58
+ }, []);
59
+
60
+ return (
61
+ <SheetContext value={{ closingRef, popupRef, topmost }}>
62
+ <SheetPrimitive.Root
63
+ open={open}
64
+ modal={topmost ? "trap-focus" : false}
65
+ disablePointerDismissal={!topmost}
66
+ swipeDirection={props.side === "bottom" ? "down" : "right"}
67
+ onOpenChange={(open, details) => {
68
+ if (!open && !topmost) {
69
+ details.cancel();
70
+ return;
71
+ }
72
+ closingRef.current = !open;
73
+ setOpen(open);
74
+ }}
75
+ onOpenChangeComplete={(open) => {
76
+ if (!open && closingRef.current) props.onClose();
77
+ }}
78
+ >
79
+ {props.children}
80
+ </SheetPrimitive.Root>
81
+ </SheetContext>
82
+ );
83
+ }
84
+
85
+ /** Close the enclosing sheet through its transition and focus lifecycle. */
86
+ export const SheetClose = SheetPrimitive.Close;
87
+
88
+ /** Render sheet chrome, a fading backdrop, and Base UI's focus boundary. */
89
+ export function SheetContent(
90
+ props: {
91
+ children: ReactNode;
92
+ className?: string;
93
+ initialFocusRef?: RefObject<HTMLElement | null>;
94
+ side: "right" | "bottom";
95
+ } & ({ label: string; titleId?: never } | { label?: never; titleId: string }),
96
+ ) {
97
+ const sheet = useContext(SheetContext);
98
+ const popupRef = sheet?.popupRef;
99
+ const portalRef = useRef<HTMLDivElement>(null);
100
+ return (
101
+ <>
102
+ <div ref={portalRef} className="contents" />
103
+ <SheetPrimitive.Portal container={portalRef}>
104
+ <SheetPrimitive.Backdrop className="fixed inset-0 z-50 bg-dashboard-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-180 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 data-[swiping]:transition-none motion-reduce:transition-none" />
105
+ <SheetPrimitive.Viewport className="pointer-events-none fixed inset-0 z-50">
106
+ <SheetPrimitive.Popup
107
+ ref={popupRef}
108
+ aria-modal={sheet?.topmost ? true : undefined}
109
+ aria-label={props.label}
110
+ aria-labelledby={props.titleId}
111
+ data-dashboard-modal=""
112
+ data-base-ui-swipe-ignore={props.side === "right" ? "" : undefined}
113
+ finalFocus={() =>
114
+ Boolean(sheet?.closingRef.current && sheet.topmost)
115
+ }
116
+ initialFocus={() => {
117
+ // A resize can mount a sheet beneath an existing event dialog.
118
+ const modals = document.querySelectorAll(
119
+ "[data-dashboard-modal]",
120
+ );
121
+ if (modals[modals.length - 1] !== popupRef?.current) return false;
122
+ return props.initialFocusRef?.current ?? true;
123
+ }}
124
+ className={cn(
125
+ "pointer-events-auto grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] bg-dashboard-bg-elevated outline-none transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.2,0.8,0.2,1)] data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 data-[swiping]:transition-none motion-reduce:transition-none",
126
+ props.side === "bottom"
127
+ ? "absolute inset-x-0 bottom-0 mx-auto h-[85dvh] max-h-full w-full max-w-2xl [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-t-2xl border border-b-0 border-dashboard-border-strong shadow-[0_-12px_40px_rgba(0,0,0,0.25)] data-[ending-style]:[transform:translateY(100%)] data-[starting-style]:[transform:translateY(2.5rem)]"
128
+ : "absolute right-0 top-0 h-full w-full shadow-[-12px_0_40px_rgba(0,0,0,0.25)] data-[ending-style]:[transform:translateX(2.5rem)] data-[starting-style]:[transform:translateX(2.5rem)] md:border-l md:border-dashboard-border-strong",
129
+ props.className,
130
+ )}
131
+ >
132
+ {props.children}
133
+ </SheetPrimitive.Popup>
134
+ </SheetPrimitive.Viewport>
135
+ </SheetPrimitive.Portal>
136
+ </>
137
+ );
138
+ }
139
+
140
+ /** Keep text selection and scrolling separate from the swipe handle. */
141
+ export const SheetBody = SheetPrimitive.Content;
@@ -3,7 +3,7 @@ import { useState, type ReactNode } from "react";
3
3
 
4
4
  import { formatRelativeTime, formatTime } from "../format";
5
5
  import { ButtonLink } from "../components/Button";
6
- import { Drawer } from "../components/Drawer";
6
+ import { ConversationDetailsFrame } from "./ConversationDetailsFrame";
7
7
  import { SegmentedTabs } from "../components/SegmentedTabs";
8
8
  import {
9
9
  ConversationChildAgents,
@@ -16,29 +16,27 @@ import {
16
16
  import { ShareConversationButton } from "./ShareConversationButton";
17
17
  import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
18
18
 
19
- type DetailsTab = "agents" | "details" | "memories";
19
+ type DetailsTab = "agents" | "summary" | "memories";
20
20
 
21
21
  /** Put conversation context before usage and diagnostic links. */
22
- export function ConversationDetailsDrawer(props: {
22
+ export function ConversationDetails(props: {
23
23
  annotations: ReactNode;
24
24
  brief: ReactNode;
25
25
  conversationId: string;
26
26
  identity: ReactNode;
27
27
  lastActivityAt?: string;
28
28
  onClose(): void;
29
- /** Open one child agent transcript. The details drawer closes first. */
29
+ /** Open one child agent transcript. Details closes first. */
30
30
  onOpenSubagent(target: SubagentTranscriptTarget): void;
31
31
  sentryConversationUrl?: string;
32
- privacy: ReactNode;
33
32
  stats: ReactNode;
34
- title: string;
35
33
  }) {
36
- const [tab, setTab] = useState<DetailsTab>("details");
37
- // Load both lists with the drawer so each tab can show its count.
34
+ const [tab, setTab] = useState<DetailsTab>("summary");
35
+ // Load both lists with details so each tab can show its count.
38
36
  const memories = useConversationMemories(props.conversationId);
39
37
  const agents = useConversationChildAgents(props.conversationId);
40
38
  const tabs = [
41
- { label: "Details", value: "details" },
39
+ { label: "Summary", value: "summary" },
42
40
  {
43
41
  count: memories.data?.memories.length,
44
42
  label: "Memories",
@@ -46,37 +44,18 @@ export function ConversationDetailsDrawer(props: {
46
44
  },
47
45
  { count: agents.data?.agents.length, label: "Subagents", value: "agents" },
48
46
  ] as const;
49
- const titleId = "conversation-details-drawer-title";
47
+
50
48
  const sections = [
51
- { content: props.brief, title: "Summary" },
49
+ { content: props.brief, title: undefined },
52
50
  { content: props.annotations, title: "Linked work" },
53
51
  { content: props.identity, title: "Participants" },
54
52
  { content: props.stats, title: "Usage" },
55
53
  ].filter((section) => section.content != null);
56
54
 
57
55
  return (
58
- <Drawer
59
- closeLabel="Close conversation details"
60
- dismissLabel="Dismiss conversation details"
61
- header={
62
- <>
63
- <h2
64
- className="m-0 min-w-0 break-words text-lg font-bold leading-tight text-dashboard-text"
65
- id={titleId}
66
- >
67
- {props.title}
68
- </h2>
69
- <div className="mt-2 flex flex-wrap items-center gap-2">
70
- {props.privacy}
71
- </div>
72
- </>
73
- }
74
- onClose={props.onClose}
75
- openKey={props.conversationId}
76
- titleId={titleId}
77
- width="narrow"
78
- >
56
+ <ConversationDetailsFrame onClose={props.onClose}>
79
57
  <SegmentedTabs
58
+ scroll
80
59
  items={tabs}
81
60
  label="Conversation details"
82
61
  onChange={setTab}
@@ -97,11 +76,13 @@ export function ConversationDetailsDrawer(props: {
97
76
  {sections.map((section) => (
98
77
  <section
99
78
  className="grid min-w-0 gap-3 border-b border-dashboard-border pb-5 last:border-0 last:pb-0"
100
- key={section.title}
79
+ key={section.title ?? "summary"}
101
80
  >
102
- <h3 className="m-0 text-sm font-semibold text-dashboard-text">
103
- {section.title}
104
- </h3>
81
+ {section.title ? (
82
+ <h3 className="m-0 text-sm font-semibold text-dashboard-text">
83
+ {section.title}
84
+ </h3>
85
+ ) : null}
105
86
  <div className="min-w-0 break-words font-sans text-sm leading-relaxed text-dashboard-text-muted">
106
87
  {section.content}
107
88
  </div>
@@ -113,7 +94,7 @@ export function ConversationDetailsDrawer(props: {
113
94
  No additional conversation details.
114
95
  </p>
115
96
  )}
116
- {tab === "details" ? (
97
+ {tab === "summary" ? (
117
98
  <footer className="mt-5 grid gap-6 border-t border-dashboard-border pt-4 text-xs text-dashboard-text-muted">
118
99
  {props.lastActivityAt ? (
119
100
  <div className="flex flex-wrap items-baseline justify-between gap-2">
@@ -152,6 +133,6 @@ export function ConversationDetailsDrawer(props: {
152
133
  </footer>
153
134
  ) : null}
154
135
  </SegmentedTabs>
155
- </Drawer>
136
+ </ConversationDetailsFrame>
156
137
  );
157
138
  }
@@ -0,0 +1,91 @@
1
+ import { X } from "lucide-react";
2
+ import {
3
+ useEffect,
4
+ useId,
5
+ useRef,
6
+ useSyncExternalStore,
7
+ type ReactNode,
8
+ } from "react";
9
+
10
+ import { Button } from "../components/Button";
11
+ import { BottomSheet } from "../components/BottomSheet";
12
+
13
+ const panelMediaQuery = "(min-width: 1280px)";
14
+
15
+ function subscribeToPanelLayout(onChange: () => void) {
16
+ const media = window.matchMedia(panelMediaQuery);
17
+ media.addEventListener("change", onChange);
18
+ return () => media.removeEventListener("change", onChange);
19
+ }
20
+
21
+ function hasPanelSpace() {
22
+ return window.matchMedia(panelMediaQuery).matches;
23
+ }
24
+
25
+ /** Keep details beside the chat when space permits, or in a modal bottom sheet. */
26
+ export function ConversationDetailsFrame(props: {
27
+ children: ReactNode;
28
+ onClose(): void;
29
+ }) {
30
+ const titleId = useId();
31
+ const panel = useSyncExternalStore(
32
+ subscribeToPanelLayout,
33
+ hasPanelSpace,
34
+ () => false,
35
+ );
36
+ const closeRef = useRef<HTMLButtonElement>(null);
37
+ const wasPanel = useRef(panel);
38
+ useEffect(() => {
39
+ if (
40
+ panel &&
41
+ !wasPanel.current &&
42
+ !document.querySelector("[data-dashboard-modal]")
43
+ ) {
44
+ closeRef.current?.focus({ preventScroll: true });
45
+ }
46
+ wasPanel.current = panel;
47
+ }, [panel]);
48
+ if (!panel) {
49
+ return (
50
+ <BottomSheet label="Conversation details" onClose={props.onClose}>
51
+ {props.children}
52
+ </BottomSheet>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <aside
58
+ aria-labelledby={titleId}
59
+ className="grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] border-l border-dashboard-border bg-dashboard-bg-elevated"
60
+ onKeyDown={(event) => {
61
+ if (
62
+ event.key === "Escape" &&
63
+ !event.defaultPrevented &&
64
+ !document.querySelector("[data-dashboard-modal]")
65
+ ) {
66
+ event.preventDefault();
67
+ props.onClose();
68
+ }
69
+ }}
70
+ >
71
+ <header className="flex items-center justify-between gap-3 border-b border-dashboard-border px-5 py-3">
72
+ <h2
73
+ aria-label="Conversation details"
74
+ className="m-0 text-base font-semibold text-dashboard-text"
75
+ id={titleId}
76
+ >
77
+ Details
78
+ </h2>
79
+ <Button
80
+ ref={closeRef}
81
+ aria-label="Close conversation details"
82
+ onClick={props.onClose}
83
+ size="icon"
84
+ >
85
+ <X aria-hidden="true" size={15} />
86
+ </Button>
87
+ </header>
88
+ <div className="min-h-0">{props.children}</div>
89
+ </aside>
90
+ );
91
+ }
@@ -200,7 +200,6 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
200
200
  ? createPortal(
201
201
  <Drawer
202
202
  closeLabel="Close event details"
203
- dismissLabel="Dismiss event details"
204
203
  header={
205
204
  <>
206
205
  <h2