@sentry/junior-dashboard 0.265.0 → 0.266.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 (47) hide show
  1. package/dist/app.js +2 -2
  2. package/dist/client.js +48 -48
  3. package/dist/index.js +2 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/components/ActorAvatar.tsx +10 -22
  7. package/src/client/components/ConnectionBanner.tsx +4 -3
  8. package/src/client/components/CopyButton.tsx +3 -4
  9. package/src/client/components/EmptyTelemetry.tsx +5 -3
  10. package/src/client/components/ImageAttachment.tsx +49 -63
  11. package/src/client/components/InlineError.tsx +8 -4
  12. package/src/client/components/Notice.tsx +19 -26
  13. package/src/client/components/Pagination.tsx +26 -26
  14. package/src/client/components/ParticipantAvatarStack.tsx +10 -10
  15. package/src/client/components/README.md +17 -1
  16. package/src/client/components/SearchInput.tsx +51 -41
  17. package/src/client/components/VersionDriftBanner.tsx +9 -7
  18. package/src/client/components/charts/CacheWrites.tsx +52 -43
  19. package/src/client/components/ui/alert.tsx +64 -0
  20. package/src/client/components/ui/avatar.tsx +53 -0
  21. package/src/client/components/ui/button.tsx +2 -1
  22. package/src/client/components/ui/dialog.tsx +66 -0
  23. package/src/client/components/ui/empty.tsx +32 -0
  24. package/src/client/components/ui/input-group.tsx +96 -0
  25. package/src/client/components/ui/pagination.tsx +60 -0
  26. package/src/client/components/ui/spinner.tsx +21 -0
  27. package/src/client/components/ui/table.tsx +22 -1
  28. package/src/client/components/ui/tooltip.tsx +1 -1
  29. package/src/client/conversations/ConversationChildAgents.tsx +5 -5
  30. package/src/client/conversations/ConversationHeader.tsx +5 -4
  31. package/src/client/conversations/ConversationHeaderActions.tsx +7 -11
  32. package/src/client/conversations/ConversationSidebar.tsx +5 -4
  33. package/src/client/conversations/ConversationUsageBreakdown.tsx +108 -65
  34. package/src/client/conversations/ConversationWatches.tsx +67 -59
  35. package/src/client/conversations/DetailsList.tsx +44 -29
  36. package/src/client/conversations/EventUsage.tsx +99 -74
  37. package/src/client/conversations/PendingMailboxStack.tsx +5 -10
  38. package/src/client/conversations/README.md +3 -0
  39. package/src/client/conversations/ShareConversationButton.tsx +3 -4
  40. package/src/client/conversations/TranscriptEventView.tsx +35 -16
  41. package/src/client/conversations/TranscriptMessageContextView.tsx +31 -20
  42. package/src/client/conversations/TranscriptStructuredEventView.tsx +25 -15
  43. package/src/client/conversations/TranscriptSummary.tsx +34 -2
  44. package/src/client/conversations/TranscriptTurnContextView.tsx +45 -38
  45. package/src/client/conversations/eventLog.ts +1 -0
  46. package/src/client/conversations/transcriptMentions.tsx +18 -5
  47. package/src/mock-reporting/watches.ts +1 -1
@@ -0,0 +1,21 @@
1
+ import type { ComponentProps } from "react";
2
+ import { Loader2Icon } from "lucide-react";
3
+ import { cn } from "@dashboard/lib/utils";
4
+
5
+ /** Render a loading spinner. It stops spinning when the user prefers reduced motion. */
6
+ function Spinner({ className, ...props }: ComponentProps<"svg">) {
7
+ return (
8
+ <Loader2Icon
9
+ data-slot="spinner"
10
+ role="status"
11
+ aria-label="Loading"
12
+ className={cn(
13
+ "size-4 animate-spin motion-reduce:animate-none",
14
+ className,
15
+ )}
16
+ {...props}
17
+ />
18
+ );
19
+ }
20
+
21
+ export { Spinner };
@@ -37,6 +37,19 @@ function TableBody({ className, ...props }: ComponentProps<"tbody">) {
37
37
  );
38
38
  }
39
39
 
40
+ function TableFooter({ className, ...props }: ComponentProps<"tfoot">) {
41
+ return (
42
+ <tfoot
43
+ data-slot="table-footer"
44
+ className={cn(
45
+ "border-t border-dashboard-border font-medium [&>tr]:last:border-b-0",
46
+ className,
47
+ )}
48
+ {...props}
49
+ />
50
+ );
51
+ }
52
+
40
53
  function TableRow({ className, ...props }: ComponentProps<"tr">) {
41
54
  return (
42
55
  <tr
@@ -77,4 +90,12 @@ function TableCell({ className, ...props }: ComponentProps<"td">) {
77
90
  );
78
91
  }
79
92
 
80
- export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell };
93
+ export {
94
+ Table,
95
+ TableHeader,
96
+ TableBody,
97
+ TableFooter,
98
+ TableHead,
99
+ TableRow,
100
+ TableCell,
101
+ };
@@ -132,7 +132,7 @@ function TooltipContent({
132
132
  collisionPadding={8}
133
133
  side={side}
134
134
  sideOffset={sideOffset}
135
- className="isolate z-50 data-anchor-hidden:hidden"
135
+ className="isolate z-50 data-anchor-hidden:invisible"
136
136
  >
137
137
  <TooltipPrimitive.Popup
138
138
  data-slot="tooltip-content"
@@ -7,7 +7,6 @@ import {
7
7
  CircleDot,
8
8
  CirclePause,
9
9
  CircleX,
10
- LoaderCircle,
11
10
  LockKeyhole,
12
11
  type LucideIcon,
13
12
  } from "lucide-react";
@@ -17,6 +16,7 @@ import {
17
16
  } from "@sentry/junior/api/schema";
18
17
 
19
18
  import type { StatusChipTone } from "../components/StatusChip";
19
+ import { Spinner } from "../components/ui/spinner";
20
20
  import {
21
21
  formatConversationCostTotal,
22
22
  formatRuntime,
@@ -115,7 +115,6 @@ const statusIcons: Partial<Record<string, LucideIcon>> = {
115
115
  error: CircleX,
116
116
  failed: CircleX,
117
117
  paused: CirclePause,
118
- running: LoaderCircle,
119
118
  };
120
119
 
121
120
  const statusIconToneClass: Record<StatusChipTone, string> = {
@@ -129,7 +128,10 @@ const statusIconToneClass: Record<StatusChipTone, string> = {
129
128
 
130
129
  /** Mark a child agent status with one icon and an audible label. */
131
130
  function ChildAgentStatusIcon(props: { status: string }) {
132
- const Icon = statusIcons[props.status] ?? Circle;
131
+ const Icon =
132
+ props.status === "running"
133
+ ? Spinner
134
+ : (statusIcons[props.status] ?? Circle);
133
135
  return (
134
136
  <span className="mt-px grid size-4 shrink-0 place-items-center">
135
137
  <Icon
@@ -137,8 +139,6 @@ function ChildAgentStatusIcon(props: { status: string }) {
137
139
  className={cn(
138
140
  "size-3.5",
139
141
  statusIconToneClass[subagentStatusTone(props.status)],
140
- props.status === "running" &&
141
- "animate-spin motion-reduce:animate-none",
142
142
  )}
143
143
  />
144
144
  <span className="sr-only">{props.status}</span>
@@ -10,6 +10,7 @@ import {
10
10
  import { Ellipsis, Menu } from "lucide-react";
11
11
 
12
12
  import { SearchInput } from "../components/SearchInput";
13
+ import { Button } from "../components/ui/button";
13
14
  import { cn } from "../styles";
14
15
  import {
15
16
  MobileHeaderActionsPortal,
@@ -149,17 +150,17 @@ export function ConversationHeader(props: {
149
150
  view={props.view}
150
151
  />
151
152
  {openMobileNavigation ? (
152
- <button
153
- className="mt-0.5 flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-sm font-semibold text-dashboard-text transition-colors hover:bg-dashboard-fill-hover focus-visible:bg-dashboard-fill-hover focus-visible:outline-none"
153
+ <Button
154
+ className="mt-0.5"
154
155
  onClick={() => {
155
156
  setMobileMenuOpen(false);
156
157
  openMobileNavigation();
157
158
  }}
158
- type="button"
159
+ size="menu"
159
160
  >
160
161
  <Menu aria-hidden="true" size={16} strokeWidth={2} />
161
162
  App menu
162
- </button>
163
+ </Button>
163
164
  ) : null}
164
165
  </div>
165
166
  ) : null}
@@ -235,19 +235,16 @@ function MenuActionButton(props: {
235
235
  pressed?: boolean;
236
236
  }) {
237
237
  return (
238
- <button
238
+ <Button
239
239
  aria-label={props.label}
240
240
  aria-pressed={props.pressed}
241
- className={cn(
242
- "flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-sm font-semibold text-dashboard-text transition-colors hover:bg-dashboard-fill-strong focus-visible:bg-dashboard-fill-strong focus-visible:outline-none",
243
- props.pressed && "bg-dashboard-fill-strong",
244
- )}
241
+ className={props.pressed ? "bg-dashboard-fill-strong" : undefined}
245
242
  onClick={props.onClick}
246
- type="button"
243
+ size="menu"
247
244
  >
248
245
  {props.children}
249
246
  <span>{props.label}</span>
250
- </button>
247
+ </Button>
251
248
  );
252
249
  }
253
250
 
@@ -262,16 +259,15 @@ function ArchiveConversationButton(
262
259
  const Icon = props.archived ? ArchiveRestore : Archive;
263
260
  if (props.layout === "menu") {
264
261
  return (
265
- <button
262
+ <Button
266
263
  aria-label={label}
267
- className="flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-sm font-semibold text-dashboard-text transition-colors hover:bg-dashboard-fill-strong focus-visible:bg-dashboard-fill-strong focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
268
264
  disabled={props.disabled}
269
265
  onClick={props.onClick}
270
- type="button"
266
+ size="menu"
271
267
  >
272
268
  <Icon aria-hidden="true" size={16} strokeWidth={2} />
273
269
  <span>{label}</span>
274
- </button>
270
+ </Button>
275
271
  );
276
272
  }
277
273
  return (
@@ -19,6 +19,7 @@ import {
19
19
  } from "./conversationSections";
20
20
  import { EmptyTelemetry } from "../components/EmptyTelemetry";
21
21
  import { SearchInput } from "../components/SearchInput";
22
+ import { Button } from "../components/ui/button";
22
23
  import { Skeleton } from "../components/ui/skeleton";
23
24
  import {
24
25
  Tooltip,
@@ -112,14 +113,14 @@ export function ConversationSidebar(props: {
112
113
  <Tooltip>
113
114
  <TooltipTrigger
114
115
  render={
115
- <button
116
+ <Button
116
117
  aria-label="New conversation"
117
- className="grid size-7 cursor-pointer place-items-center rounded-md text-dashboard-text-muted transition hover:bg-dashboard-fill-hover hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus"
118
+ className="size-7"
118
119
  onClick={props.onNewConversation}
119
- type="button"
120
+ size="icon"
120
121
  >
121
122
  <SquarePen aria-hidden="true" size={15} />
122
- </button>
123
+ </Button>
123
124
  }
124
125
  />
125
126
  <TooltipContent size="compact">New conversation</TooltipContent>
@@ -13,6 +13,16 @@ import {
13
13
  totalConversationCost,
14
14
  } from "../format";
15
15
  import { ShimmerText } from "../components/ShimmerText";
16
+ import {
17
+ Table,
18
+ TableBody,
19
+ TableCell,
20
+ TableFooter,
21
+ TableHead,
22
+ TableHeader,
23
+ TableRow,
24
+ } from "../components/ui/table";
25
+ import { cn } from "../styles";
16
26
  import type { Conversation } from "../types";
17
27
  import { liveModelId } from "./ConversationMeta";
18
28
  import {
@@ -70,7 +80,7 @@ function tokenTypeParts(
70
80
  return parts.length ? parts : undefined;
71
81
  }
72
82
 
73
- const CELL = "text-right align-top font-sans tabular-nums";
83
+ const CELL = "px-0 text-right align-top font-sans tabular-nums";
74
84
 
75
85
  /**
76
86
  * One usage table. Every table in the breakdown shares column widths, so the
@@ -85,85 +95,118 @@ function BreakdownTable(props: {
85
95
  total?: { cost: string; count: string; label: string };
86
96
  }) {
87
97
  return (
88
- <div className="min-w-0 overflow-x-auto">
89
- <table
90
- aria-label={props.label}
91
- className="w-full min-w-72 table-fixed border-collapse text-xs leading-relaxed"
92
- >
93
- <colgroup>
94
- <col />
95
- <col className="w-20" />
96
- <col className="w-20" />
97
- </colgroup>
98
- <thead>
99
- <tr className="border-b border-dashboard-border text-dashboard-text-muted">
100
- <th scope="col" className="py-2 text-left font-normal">
101
- {props.label}
102
- </th>
103
- <th scope="col" className="py-2 text-right font-normal">
104
- {props.countLabel}
105
- </th>
106
- <th scope="col" className="py-2 text-right font-normal">
107
- Cost
108
- </th>
109
- </tr>
110
- </thead>
111
- {props.rows.map((row) => (
112
- <tbody
98
+ <Table
99
+ aria-label={props.label}
100
+ className="min-w-72 table-fixed text-xs leading-relaxed"
101
+ >
102
+ <colgroup>
103
+ <col />
104
+ <col className="w-20" />
105
+ <col className="w-20" />
106
+ </colgroup>
107
+ <TableHeader>
108
+ <TableRow className="border-dashboard-border">
109
+ <TableHead
110
+ scope="col"
111
+ className="px-0 py-2 leading-relaxed font-normal"
112
+ >
113
+ {props.label}
114
+ </TableHead>
115
+ <TableHead
116
+ scope="col"
117
+ className="px-0 py-2 text-right leading-relaxed font-normal"
118
+ >
119
+ {props.countLabel}
120
+ </TableHead>
121
+ <TableHead
122
+ scope="col"
123
+ className="px-0 py-2 text-right leading-relaxed font-normal"
124
+ >
125
+ Cost
126
+ </TableHead>
127
+ </TableRow>
128
+ </TableHeader>
129
+ {props.rows.map((row) => {
130
+ const padding = row.parts ? "pt-2 pb-0.5" : "py-2";
131
+ return (
132
+ <TableBody
113
133
  className="border-b border-dashboard-border-subtle last-of-type:border-b-0"
114
134
  key={row.key}
115
135
  >
116
- <tr className="text-dashboard-text">
117
- <th
136
+ {/* The tbody draws the group border, so its rows have none. */}
137
+ <TableRow className="border-0">
138
+ <TableHead
118
139
  scope="row"
119
- className={`pr-3 text-left font-normal ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}
140
+ className={cn(
141
+ "pl-0 pr-3 align-top leading-relaxed font-normal text-dashboard-text",
142
+ padding,
143
+ )}
120
144
  >
121
145
  <span className="block truncate">{row.label}</span>
122
- </th>
123
- <td className={`${CELL} ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}>
124
- {row.count}
125
- </td>
126
- <td className={`${CELL} ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}>
127
- {row.cost}
128
- </td>
129
- </tr>
146
+ </TableHead>
147
+ <TableCell className={cn(CELL, padding)}>{row.count}</TableCell>
148
+ <TableCell className={cn(CELL, padding)}>{row.cost}</TableCell>
149
+ </TableRow>
130
150
  {row.parts?.map((part, index, parts) => {
131
- const padding = index === parts.length - 1 ? "pb-2" : "";
151
+ const partPadding =
152
+ index === parts.length - 1 ? "pt-0 pb-2" : "py-0";
132
153
  return (
133
- <tr
134
- className="text-2xs leading-5 text-dashboard-text-faint"
154
+ <TableRow
155
+ className="border-0 text-2xs leading-5"
135
156
  key={part.label}
136
157
  >
137
- <th
158
+ <TableHead
138
159
  scope="row"
139
- className={`truncate pl-3 pr-3 text-left font-normal ${padding}`}
160
+ className={cn(
161
+ "truncate pl-3 pr-3 align-top text-2xs leading-5 font-normal text-dashboard-text-faint",
162
+ partPadding,
163
+ )}
140
164
  >
141
165
  {part.label}
142
- </th>
143
- <td className={`${CELL} ${padding}`}>{part.count}</td>
144
- <td className={`${CELL} ${padding}`}>{part.cost}</td>
145
- </tr>
166
+ </TableHead>
167
+ <TableCell
168
+ className={cn(
169
+ CELL,
170
+ "text-dashboard-text-faint",
171
+ partPadding,
172
+ )}
173
+ >
174
+ {part.count}
175
+ </TableCell>
176
+ <TableCell
177
+ className={cn(
178
+ CELL,
179
+ "text-dashboard-text-faint",
180
+ partPadding,
181
+ )}
182
+ >
183
+ {part.cost}
184
+ </TableCell>
185
+ </TableRow>
146
186
  );
147
187
  })}
148
- </tbody>
149
- ))}
150
- {props.total ? (
151
- <tfoot>
152
- <tr className="border-t border-dashboard-border font-semibold text-dashboard-text">
153
- <th scope="row" className="py-2 text-left font-semibold">
154
- {props.total.label}
155
- </th>
156
- <td className="py-2 text-right font-sans tabular-nums">
157
- {props.total.count}
158
- </td>
159
- <td className="py-2 text-right font-sans tabular-nums">
160
- {props.total.cost}
161
- </td>
162
- </tr>
163
- </tfoot>
164
- ) : null}
165
- </table>
166
- </div>
188
+ </TableBody>
189
+ );
190
+ })}
191
+ {props.total ? (
192
+ <TableFooter className="font-semibold">
193
+ <TableRow>
194
+ <TableHead
195
+ scope="row"
196
+ className="px-0 py-2 leading-relaxed font-semibold text-dashboard-text"
197
+ >
198
+ {props.total.label}
199
+ </TableHead>
200
+ <TableCell className="px-0 py-2 text-right font-sans tabular-nums">
201
+ {props.total.count}
202
+ </TableCell>
203
+ <TableCell className="px-0 py-2 text-right font-sans tabular-nums">
204
+ {props.total.cost}
205
+ </TableCell>
206
+ </TableRow>
207
+ </TableFooter>
208
+ ) : null}
209
+ </Table>
167
210
  );
168
211
  }
169
212
 
@@ -1,6 +1,7 @@
1
1
  import { useQuery } from "@tanstack/react-query";
2
2
  import {
3
3
  Bot,
4
+ ChevronRight,
4
5
  CircleDot,
5
6
  Eye,
6
7
  FolderGit2,
@@ -14,7 +15,7 @@ import {
14
15
  Timer,
15
16
  Users,
16
17
  } from "lucide-react";
17
- import type { ReactNode } from "react";
18
+ import { useState, type ReactNode } from "react";
18
19
  import {
19
20
  conversationWatchesReportSchema,
20
21
  type ConversationWatch,
@@ -22,7 +23,8 @@ import {
22
23
 
23
24
  import { formatTime } from "../format";
24
25
  import { fetchDashboardJson } from "../http";
25
- import { DetailsList, DetailsRow, DetailsRowLine } from "./DetailsList";
26
+ import { dashboardDisclosureChevronClass } from "../styles";
27
+ import { DetailsList, DetailsRow } from "./DetailsList";
26
28
 
27
29
  /** Load the active Watches that one Conversation owns. */
28
30
  export function useConversationWatches(conversationId: string) {
@@ -39,8 +41,8 @@ export function useConversationWatches(conversationId: string) {
39
41
  }
40
42
 
41
43
  /**
42
- * Show what can wake this Conversation: each active Watch with the resource,
43
- * the reason, and the event types that deliver to it.
44
+ * Show what can wake this Conversation: one line per active Watch. A line
45
+ * expands to show the reason and the event types that deliver to it.
44
46
  */
45
47
  export function ConversationWatches(props: { watches: ConversationWatch[] }) {
46
48
  return (
@@ -66,65 +68,81 @@ const resourceIcons: Partial<Record<string, LucideIcon>> = {
66
68
  };
67
69
 
68
70
  /**
69
- * Show one Watch in up to three short lines: the resource and time left, the
70
- * reason it exists, then the event types that wake the Conversation.
71
+ * Show one Watch as a single line with its resource. Clicking the line shows
72
+ * why the Watch exists and which events wake the Conversation. The expiry is
73
+ * an internal limit, so the row does not show it.
71
74
  */
72
75
  function WatchRow(props: { watch: ConversationWatch }) {
73
76
  const { watch } = props;
77
+ const [expanded, setExpanded] = useState(false);
74
78
  const Icon = watch.redacted
75
79
  ? LockKeyhole
76
80
  : (resourceIcons[watch.resourceType] ?? Eye);
77
- const isTimer = watch.firesAt !== undefined;
78
- const until = watch.firesAt ?? watch.expiresAt;
79
81
  const resource = `${watch.namespace} ${watch.resourceType.replaceAll("_", " ")}`;
80
- // A timer has one event, and the time already says when it fires.
81
- const events = isTimer ? [] : watch.events;
82
+ // A timer has one event, and its fire time already says when it wakes.
83
+ const events = watch.firesAt ? [] : watch.events;
82
84
  const filters = Object.entries(watch.match ?? {});
83
-
84
- return (
85
+ const row = (
85
86
  <DetailsRow
87
+ expanded={expanded}
86
88
  icon={<Icon aria-hidden="true" className="size-3.5" />}
87
89
  meta={
88
- <time dateTime={until}>
89
- {isTimer
90
- ? `in ${formatRemaining(until)}`
91
- : `${formatRemaining(until)} left`}
92
- </time>
90
+ <span className="grid size-5 place-items-center">
91
+ <ChevronRight
92
+ aria-hidden="true"
93
+ className={dashboardDisclosureChevronClass}
94
+ />
95
+ </span>
93
96
  }
97
+ onToggle={() => setExpanded((value) => !value)}
94
98
  title={watch.label ?? `Private ${resource}`}
95
- tooltip={`${isTimer ? "Fires" : "Expires"} ${formatTime(until, {
96
- dateStyle: "medium",
97
- timeStyle: "short",
98
- })}`}
99
- >
100
- {watch.intent ? (
101
- <DetailsRowLine className="line-clamp-2 break-words">
102
- {watch.intent}
103
- </DetailsRowLine>
104
- ) : null}
105
- {events.length > 0 || filters.length > 0 || watch.childAgent ? (
106
- <DetailsRowLine className="flex flex-wrap items-center gap-1 pt-0.5">
107
- {events.map((event) => (
108
- <WatchChip key={event}>
109
- {watchEventLabel(event, watch.resourceType)}
110
- </WatchChip>
111
- ))}
112
- {filters.map(([key, value]) => (
113
- <WatchChip filter key={`match:${key}`}>
114
- {key.replaceAll("_", " ")}: {[value].flat().join(", ")}
115
- </WatchChip>
116
- ))}
117
- {watch.childAgent ? (
118
- <span className="inline-flex min-w-0 items-center gap-1 px-0.5">
119
- <Bot aria-hidden="true" className="size-3 shrink-0" />
120
- <span className="truncate">
121
- via {watch.childAgent.name ?? "subagent"}
122
- </span>
99
+ />
100
+ );
101
+ if (!expanded) return row;
102
+
103
+ return (
104
+ <>
105
+ {row}
106
+ {/* Indent under the title: icon column, column gap, and row padding. */}
107
+ <div className="grid min-w-0 gap-1.5 pb-2 pl-9 pr-1.5 pt-0.5 text-xs leading-4 text-dashboard-text-muted">
108
+ {watch.intent ? (
109
+ <p className="m-0 break-words">{watch.intent}</p>
110
+ ) : null}
111
+ {watch.firesAt ? (
112
+ <p className="m-0">
113
+ Fires{" "}
114
+ <time dateTime={watch.firesAt}>
115
+ {formatTime(watch.firesAt, {
116
+ dateStyle: "medium",
117
+ timeStyle: "short",
118
+ })}
119
+ </time>
120
+ </p>
121
+ ) : null}
122
+ {events.length > 0 || filters.length > 0 ? (
123
+ <div className="flex flex-wrap items-center gap-1">
124
+ {events.map((event) => (
125
+ <WatchChip key={event}>
126
+ {watchEventLabel(event, watch.resourceType)}
127
+ </WatchChip>
128
+ ))}
129
+ {filters.map(([key, value]) => (
130
+ <WatchChip filter key={`match:${key}`}>
131
+ {key.replaceAll("_", " ")}: {[value].flat().join(", ")}
132
+ </WatchChip>
133
+ ))}
134
+ </div>
135
+ ) : null}
136
+ {watch.childAgent ? (
137
+ <p className="m-0 flex min-w-0 items-center gap-1">
138
+ <Bot aria-hidden="true" className="size-3 shrink-0" />
139
+ <span className="truncate">
140
+ via {watch.childAgent.name ?? "subagent"}
123
141
  </span>
124
- ) : null}
125
- </DetailsRowLine>
126
- ) : null}
127
- </DetailsRow>
142
+ </p>
143
+ ) : null}
144
+ </div>
145
+ </>
128
146
  );
129
147
  }
130
148
 
@@ -153,13 +171,3 @@ function watchEventLabel(event: string, resourceType: string): string {
153
171
  parts[0] === resourceType && parts.length > 1 ? parts.slice(1) : parts;
154
172
  return rest.join(" ").replaceAll("_", " ");
155
173
  }
156
-
157
- /** Format the time until a Watch fires or expires as one compact unit. */
158
- function formatRemaining(value: string): string {
159
- const ms = Math.max(0, Date.parse(value) - Date.now());
160
- const minutes = Math.round(ms / 60_000);
161
- if (minutes < 60) return `${Math.max(1, minutes)}m`;
162
- const hours = Math.round(minutes / 60);
163
- if (hours < 48) return `${hours}h`;
164
- return `${Math.round(hours / 24)}d`;
165
- }