@sentry/junior-dashboard 0.267.0 → 0.269.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 (59) hide show
  1. package/README.md +7 -105
  2. package/dist/app.js +47 -8
  3. package/dist/client.js +74 -78
  4. package/dist/index.js +47 -8
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +4 -4
  7. package/src/api/schema.ts +2 -1
  8. package/src/app.ts +11 -7
  9. package/src/client/App.tsx +20 -4
  10. package/src/client/api.ts +4 -19
  11. package/src/client/components/AGENTS.md +65 -0
  12. package/src/client/components/ActorAvatar.tsx +1 -1
  13. package/src/client/components/ParticipantAvatarStack.tsx +3 -1
  14. package/src/client/components/ProfileMenu.tsx +88 -29
  15. package/src/client/components/UserInputForm.tsx +283 -0
  16. package/src/client/components/charts/AGENTS.md +15 -0
  17. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  18. package/src/client/components/ui/button.tsx +11 -1
  19. package/src/client/conversations/AGENTS.md +42 -0
  20. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  21. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  22. package/src/client/conversations/ConversationPage.tsx +4 -27
  23. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  24. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  25. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  26. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  27. package/src/client/conversations/TranscriptMessageView.tsx +3 -0
  28. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  29. package/src/client/conversations/TranscriptTurnContextView.tsx +142 -144
  30. package/src/client/conversations/turnContext.ts +15 -0
  31. package/src/client/format.ts +28 -28
  32. package/src/client/pages/SettingsPage.tsx +103 -3
  33. package/src/client/pages/automations/AGENTS.md +34 -0
  34. package/src/client/pages/automations/AutomationActions.tsx +18 -20
  35. package/src/client/pages/automations/AutomationEditor.tsx +3 -4
  36. package/src/client/pages/automations/AutomationOverview.tsx +3 -3
  37. package/src/client/pages/automations/AutomationStatus.tsx +2 -2
  38. package/src/client/pages/code/RepositorySections.tsx +1 -0
  39. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  40. package/src/client/pages/system/AGENTS.md +12 -0
  41. package/src/client/primaryNav.ts +15 -2
  42. package/src/client/review-desk/ConversationReviewBar.tsx +68 -0
  43. package/src/client/review-desk/ReviewDeskPage.tsx +297 -0
  44. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  45. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  46. package/src/client/review-desk/ReviewItemDetail.tsx +123 -0
  47. package/src/client/review-desk/queries.ts +173 -0
  48. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  49. package/src/mock-reporting/fixtures.ts +10 -0
  50. package/src/mock-reporting/routes.ts +20 -0
  51. package/src/shell.ts +3 -0
  52. package/src/tailwind.css +1 -1
  53. package/src/client/components/README.md +0 -215
  54. package/src/client/components/charts/README.md +0 -38
  55. package/src/client/conversations/README.md +0 -159
  56. package/src/client/conversations/UserInputRequestCard.tsx +0 -244
  57. package/src/client/conversations/userInputRequest.ts +0 -56
  58. package/src/client/pages/automations/README.md +0 -97
  59. package/src/client/pages/system/README.md +0 -23
@@ -0,0 +1,283 @@
1
+ import { useId, useRef, useState, type FormEvent, type ReactNode } from "react";
2
+ import {
3
+ buttonUserInputField,
4
+ isEmptyUserInputAnswer,
5
+ normalizeUserInputAnswer,
6
+ type UserInputAnswer,
7
+ type UserInputField,
8
+ type UserInputRequest,
9
+ } from "@sentry/junior/api/schema";
10
+
11
+ import { cn } from "../styles";
12
+ import { Button } from "./ui/button";
13
+ import { Checkbox } from "./ui/checkbox";
14
+ import { Field, FieldDescription, FieldLabel } from "./ui/field";
15
+ import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
16
+ import { Textarea } from "./ui/textarea";
17
+
18
+ // Each choice is a whole selectable row, so the hit area and the selected
19
+ // state read at a glance. The control inside keeps its own focus ring.
20
+ const optionClass =
21
+ "flex cursor-pointer items-start gap-3 rounded-md border border-dashboard-border bg-dashboard-surface-panel px-3 py-2.5 text-sm transition-colors hover:border-dashboard-border-emphasis hover:bg-dashboard-surface-hover has-[[data-checked]]:border-dashboard-accent/60 has-[[data-checked]]:bg-dashboard-accent/10 has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:opacity-60";
22
+
23
+ function OptionText(props: { description?: string; label: string }) {
24
+ return (
25
+ <span className="grid min-w-0 gap-0.5 break-words">
26
+ <span className="font-medium text-dashboard-text">{props.label}</span>
27
+ {props.description ? (
28
+ <span className="text-xs text-dashboard-text-muted">
29
+ {props.description}
30
+ </span>
31
+ ) : null}
32
+ </span>
33
+ );
34
+ }
35
+
36
+ function ChoiceField(props: {
37
+ disabled: boolean;
38
+ field: Extract<UserInputField, { kind: "choice" | "multi_choice" }>;
39
+ onChange(values: string[]): void;
40
+ values: string[];
41
+ }) {
42
+ const id = useId();
43
+ const { field } = props;
44
+ const legend = (
45
+ <legend className="mb-2 p-0 text-sm font-semibold text-dashboard-text">
46
+ {field.label}
47
+ </legend>
48
+ );
49
+ const description = field.description ? (
50
+ <FieldDescription className="-mt-1 mb-2" id={`${id}-help`}>
51
+ {field.description}
52
+ </FieldDescription>
53
+ ) : null;
54
+ const describedBy = field.description ? `${id}-help` : undefined;
55
+ if (field.kind === "choice") {
56
+ return (
57
+ <fieldset className="m-0 min-w-0 border-0 p-0">
58
+ {legend}
59
+ {description}
60
+ <RadioGroup
61
+ aria-describedby={describedBy}
62
+ aria-label={field.label}
63
+ className="grid gap-2"
64
+ disabled={props.disabled}
65
+ onValueChange={(value) => props.onChange([String(value)])}
66
+ value={props.values[0] ?? null}
67
+ >
68
+ {field.options.map((option) => (
69
+ <label className={optionClass} key={option.label}>
70
+ <RadioGroupItem className="mt-0.5" value={option.label} />
71
+ <OptionText
72
+ description={option.description}
73
+ label={option.label}
74
+ />
75
+ </label>
76
+ ))}
77
+ </RadioGroup>
78
+ </fieldset>
79
+ );
80
+ }
81
+ return (
82
+ <fieldset
83
+ aria-describedby={describedBy}
84
+ className="m-0 min-w-0 border-0 p-0"
85
+ >
86
+ {legend}
87
+ {description}
88
+ <div className="grid gap-2">
89
+ {field.options.map((option) => (
90
+ <label className={optionClass} key={option.label}>
91
+ <Checkbox
92
+ checked={props.values.includes(option.label)}
93
+ className="mt-0.5"
94
+ disabled={props.disabled}
95
+ onCheckedChange={(checked) =>
96
+ props.onChange(
97
+ checked
98
+ ? [...props.values, option.label]
99
+ : props.values.filter((value) => value !== option.label),
100
+ )
101
+ }
102
+ />
103
+ <OptionText description={option.description} label={option.label} />
104
+ </label>
105
+ ))}
106
+ </div>
107
+ </fieldset>
108
+ );
109
+ }
110
+
111
+ function TextField(props: {
112
+ className?: string;
113
+ description?: string;
114
+ disabled: boolean;
115
+ label: string;
116
+ onChange(value: string): void;
117
+ value: string;
118
+ }) {
119
+ const id = useId();
120
+ return (
121
+ <Field>
122
+ <FieldLabel htmlFor={id}>{props.label}</FieldLabel>
123
+ {props.description ? (
124
+ <FieldDescription className="-mt-1" id={`${id}-help`}>
125
+ {props.description}
126
+ </FieldDescription>
127
+ ) : null}
128
+ <Textarea
129
+ aria-describedby={props.description ? `${id}-help` : undefined}
130
+ className={cn("min-h-20 leading-relaxed", props.className)}
131
+ disabled={props.disabled}
132
+ id={id}
133
+ onChange={(event) => props.onChange(event.target.value)}
134
+ value={props.value}
135
+ />
136
+ </Field>
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Render the answer controls of an action item request. A request with
142
+ * one short choice field shows one button per choice, and a click is the
143
+ * answer. Other requests show a form that the reader submits once. A failed
144
+ * `onSubmit` lets the reader try again. `secondaryActions`, such as Dismiss,
145
+ * sit in the same action row, after the answer controls.
146
+ */
147
+ export function UserInputForm(props: {
148
+ onSubmit(answer: UserInputAnswer): Promise<void>;
149
+ request: UserInputRequest;
150
+ requestId: string;
151
+ secondaryActions?: ReactNode;
152
+ }) {
153
+ const [sending, setSending] = useState(false);
154
+ const [failed, setFailed] = useState(false);
155
+ const [values, setValues] = useState<Record<string, string[]>>({});
156
+ const [other, setOther] = useState("");
157
+ const buttonField = buttonUserInputField(props.request);
158
+
159
+ // A ref blocks a second click before React renders the disabled controls.
160
+ // The lock stays after a sent answer; only a failed send can retry.
161
+ const sendLock = useRef(false);
162
+
163
+ async function send(answer: UserInputAnswer) {
164
+ if (sendLock.current) return;
165
+ sendLock.current = true;
166
+ setSending(true);
167
+ setFailed(false);
168
+ try {
169
+ await props.onSubmit(answer);
170
+ } catch {
171
+ sendLock.current = false;
172
+ setSending(false);
173
+ setFailed(true);
174
+ }
175
+ }
176
+
177
+ const answer: UserInputAnswer = {
178
+ other,
179
+ requestId: props.requestId,
180
+ values,
181
+ };
182
+ function submit(event: FormEvent) {
183
+ event.preventDefault();
184
+ if (
185
+ !isEmptyUserInputAnswer(normalizeUserInputAnswer(props.request, answer))
186
+ ) {
187
+ void send(answer);
188
+ }
189
+ }
190
+
191
+ const secondary = props.secondaryActions ? (
192
+ <div className="flex flex-wrap gap-1 sm:ml-auto">
193
+ {props.secondaryActions}
194
+ </div>
195
+ ) : null;
196
+
197
+ return (
198
+ <>
199
+ {buttonField ? (
200
+ <>
201
+ <div className="mt-3 flex flex-wrap items-center gap-2">
202
+ {buttonField.options.map((option) => (
203
+ <Button
204
+ disabled={sending}
205
+ key={option.label}
206
+ onClick={() =>
207
+ void send({
208
+ requestId: props.requestId,
209
+ values: { [buttonField.id]: [option.label] },
210
+ })
211
+ }
212
+ title={option.description}
213
+ >
214
+ {option.label}
215
+ </Button>
216
+ ))}
217
+ {secondary}
218
+ </div>
219
+ {buttonField.options.some((option) => option.description) ? (
220
+ <ul className="mb-0 mt-2 list-none p-0 text-xs text-dashboard-text-muted">
221
+ {buttonField.options
222
+ .filter((option) => option.description)
223
+ .map((option) => (
224
+ <li key={option.label}>
225
+ <span className="font-medium text-dashboard-text">
226
+ {option.label}
227
+ </span>
228
+ : {option.description}
229
+ </li>
230
+ ))}
231
+ </ul>
232
+ ) : null}
233
+ </>
234
+ ) : (
235
+ <form className="mt-4 grid gap-5" onSubmit={submit}>
236
+ {props.request.fields.map((field) =>
237
+ field.kind === "text" ? (
238
+ <TextField
239
+ description={field.description}
240
+ disabled={sending}
241
+ key={field.id}
242
+ label={field.label}
243
+ onChange={(value) =>
244
+ setValues((current) => ({ ...current, [field.id]: [value] }))
245
+ }
246
+ value={values[field.id]?.[0] ?? ""}
247
+ />
248
+ ) : (
249
+ <ChoiceField
250
+ disabled={sending}
251
+ field={field}
252
+ key={field.id}
253
+ onChange={(picked) =>
254
+ setValues((current) => ({ ...current, [field.id]: picked }))
255
+ }
256
+ values={values[field.id] ?? []}
257
+ />
258
+ ),
259
+ )}
260
+ <TextField
261
+ className="min-h-16"
262
+ description="Optional. Anything else, or an answer that is not listed."
263
+ disabled={sending}
264
+ label="Other"
265
+ onChange={setOther}
266
+ value={other}
267
+ />
268
+ <div className="flex flex-wrap items-center gap-2">
269
+ <Button disabled={sending} type="submit" variant="primary">
270
+ Send
271
+ </Button>
272
+ {secondary}
273
+ </div>
274
+ </form>
275
+ )}
276
+ {failed ? (
277
+ <p className="mb-0 mt-2 text-xs text-rose-300" role="alert">
278
+ Could not send your answer. Try again.
279
+ </p>
280
+ ) : null}
281
+ </>
282
+ );
283
+ }
@@ -0,0 +1,15 @@
1
+ # System cache reporting
2
+
3
+ - `cache-input.ts` owns the cache input calculation.
4
+ - `ConversationMetricDay` comes from stored Run metrics, not Sentry spans.
5
+ Its input, cached input, and cache-creation counters are disjoint. Add
6
+ them once for total input. Do not include output. Never use this transform
7
+ for Sentry span totals.
8
+ - Period shares use summed token counts, not the mean of bucket shares.
9
+ - A missing counter is not a zero. When an active bucket has no counter, omit
10
+ its share and the whole-period share.
11
+ - Do not call these values a cache hit rate or a cache failure rate. Field
12
+ presence is not call-level coverage.
13
+ - The page time control selects the buckets for the chart and `CacheWrites`.
14
+ Do not add a local time selector.
15
+ - Add a reporting contract before you add per-call filters or drilldowns.
@@ -15,11 +15,22 @@ import {
15
15
  import { cn, dashboardInteractiveTextClass } from "../../styles";
16
16
 
17
17
  export type DashboardHeaderNavItem = {
18
+ /** Shown beside the label when it is above zero. */
19
+ count?: number;
18
20
  key: string;
19
21
  label: string;
20
22
  to: string;
21
23
  };
22
24
 
25
+ function NavCount(props: { count?: number }) {
26
+ if (!props.count) return null;
27
+ return (
28
+ <span className="ml-1.5 rounded-full bg-violet-300/20 px-1.5 py-0.5 text-xs text-violet-200">
29
+ {props.count.toLocaleString("en-US")}
30
+ </span>
31
+ );
32
+ }
33
+
23
34
  /** Render the primary dashboard shell header and optional mobile nav sheet. */
24
35
  export function DashboardHeader(props: {
25
36
  /** Back target used on mobile conversation detail. */
@@ -151,6 +162,7 @@ export function DashboardHeader(props: {
151
162
  {props.navItems.map((item) => (
152
163
  <NavLink className={navLinkClass} key={item.key} to={item.to}>
153
164
  {item.label}
165
+ <NavCount count={item.count} />
154
166
  </NavLink>
155
167
  ))}
156
168
  </nav>
@@ -204,6 +216,7 @@ export function DashboardHeader(props: {
204
216
  to={item.to}
205
217
  >
206
218
  {item.label}
219
+ <NavCount count={item.count} />
207
220
  </NavLink>
208
221
  ))}
209
222
  </nav>
@@ -5,7 +5,7 @@ import { cn } from "@dashboard/lib/utils";
5
5
  const commandButtonClass =
6
6
  "inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-md border px-3 text-sm font-medium leading-none focus-visible:outline-2 focus-visible:outline-offset-2";
7
7
 
8
- /** Style the dashboard command, primary, danger, icon, and menu row button surfaces. Links use it with `className`. */
8
+ /** Style the dashboard command, primary, quiet, danger, icon, and menu row button surfaces. Links use it with `className`. */
9
9
  const buttonVariants = cva(
10
10
  "cursor-pointer font-sans no-underline transition-colors data-disabled:cursor-not-allowed data-disabled:opacity-50",
11
11
  {
@@ -13,6 +13,8 @@ const buttonVariants = cva(
13
13
  variant: {
14
14
  default: "",
15
15
  primary: "",
16
+ /** A secondary action beside a primary one, such as Dismiss. */
17
+ quiet: "",
16
18
  danger: "",
17
19
  },
18
20
  size: {
@@ -38,6 +40,14 @@ const buttonVariants = cva(
38
40
  "border-dashboard-text-solid bg-dashboard-text-solid text-dashboard-text-inverse hover:border-dashboard-text-subtle hover:bg-dashboard-text-subtle focus-visible:outline-dashboard-focus data-disabled:hover:border-dashboard-text-solid data-disabled:hover:bg-dashboard-text-solid",
39
41
  ),
40
42
  },
43
+ {
44
+ size: "default",
45
+ variant: "quiet",
46
+ className: cn(
47
+ commandButtonClass,
48
+ "border-transparent bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-strong hover:text-dashboard-text focus-visible:outline-dashboard-focus data-disabled:hover:bg-transparent data-disabled:hover:text-dashboard-text-muted",
49
+ ),
50
+ },
41
51
  {
42
52
  size: "default",
43
53
  variant: "danger",
@@ -0,0 +1,42 @@
1
+ # Transcript components
2
+
3
+ Keep transcript layout in these feature components. Use the UI kit in
4
+ `../components/` for shared controls and overlays.
5
+
6
+ ## Ownership
7
+
8
+ - `TranscriptRows` and `TranscriptRow` own row gaps, padding, and indent.
9
+ Keep horizontal padding inside each row, so focus outlines are not clipped.
10
+ - `TranscriptMessageShell` owns the avatar column and message gutter.
11
+ - `TranscriptSummaryTrigger` owns disclosure padding and focus. Do not repeat
12
+ negative margins at call sites.
13
+ - `ChatLayout` keeps the Conversation header outside the transcript scroll.
14
+ - `eventLogRows` and `filterEventLogRows` own row text and search match. The
15
+ page step controls use them too.
16
+
17
+ ## Rules
18
+
19
+ - Display text keeps Slack tokens such as `<@U123>`. The API returns
20
+ `mentions` beside the owning object. Render with `TranscriptText` or
21
+ `MentionText`. Do not resolve names in the dashboard, and do not rewrite
22
+ tokens into links on the server.
23
+ - The Automation trigger card shows the credentials from the dispatch record
24
+ of the run, not the current setting. Keep the run input collapsed, because
25
+ it is not from a person.
26
+ - Each `Transcript` sets the panel for its own rows. A nested transcript never
27
+ uses the panel of the page behind it.
28
+ - The page has one right panel. Conversation details, event details, and turn
29
+ context never show at the same time. Closing a panel returns focus to the
30
+ control that opened it.
31
+ - The URL owns the selected event in the main Conversation. Standalone
32
+ transcripts keep selection in `ConversationEventLog`.
33
+ - A pending exit must not close a new panel or another Conversation.
34
+ - A pending fork blocks dismissal of its dialog.
35
+ - Raw JSON keeps the original ISO values. Persist the UTC/local mode, not the
36
+ timezone.
37
+
38
+ ## Checks
39
+
40
+ - Review with `JUNIOR_DASHBOARD_MOCK_CONVERSATIONS=true pnpm dev`.
41
+ - Keep visual checks out of automated assertions. Browser tests cover
42
+ opening, closing, and keyboard interaction.
@@ -35,6 +35,7 @@ export function ConversationHomeList(props: {
35
35
  finishedConversationIds: ReadonlySet<string>;
36
36
  loading?: boolean;
37
37
  timeZone: string;
38
+ waitingConversationIds: ReadonlySet<string>;
38
39
  }) {
39
40
  const [archivedConversation, setArchivedConversation] =
40
41
  useState<Conversation>();
@@ -94,6 +95,7 @@ export function ConversationHomeList(props: {
94
95
  key={section.key}
95
96
  onArchiveError={handleArchiveError}
96
97
  onArchived={handleArchived}
98
+ waitingConversationIds={props.waitingConversationIds}
97
99
  section={section}
98
100
  />
99
101
  ))}
@@ -169,6 +171,7 @@ function ConversationCardSection(props: {
169
171
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
170
172
  onArchived(conversation: Conversation): void;
171
173
  section: ConversationSection;
174
+ waitingConversationIds: ReadonlySet<string>;
172
175
  }) {
173
176
  return (
174
177
  <section aria-labelledby={`conversation-section-${props.section.key}`}>
@@ -188,6 +191,7 @@ function ConversationCardSection(props: {
188
191
  key={conversation.id}
189
192
  onArchiveError={props.onArchiveError}
190
193
  onArchived={props.onArchived}
194
+ waitingOnViewer={props.waitingConversationIds.has(conversation.id)}
191
195
  />
192
196
  ))}
193
197
  </div>
@@ -200,6 +204,7 @@ function ConversationCard(props: {
200
204
  finishedSinceSeen: boolean;
201
205
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
202
206
  onArchived(conversation: Conversation): void;
207
+ waitingOnViewer: boolean;
203
208
  }) {
204
209
  const conversation = props.conversation;
205
210
  const archive = useArchiveConversation(conversation.id, {
@@ -231,6 +236,7 @@ function ConversationCard(props: {
231
236
  finishedSinceSeen={props.finishedSinceSeen}
232
237
  isPrivate={isPrivate}
233
238
  status={status}
239
+ waitingOnViewer={props.waitingOnViewer}
234
240
  />
235
241
  </span>
236
242
  <h4 className="m-0 truncate font-display text-base font-medium leading-snug text-dashboard-text">
@@ -4,12 +4,26 @@ import { ActiveIndicator } from "../components/ActiveIndicator";
4
4
  import { cn } from "../styles";
5
5
  import type { VisualStatus } from "../types";
6
6
 
7
- /** Render a conversation status marker, with new completions taking priority. */
7
+ /**
8
+ * Render a conversation status marker. "Waiting on you" replaces every other
9
+ * marker, then new completions take priority.
10
+ */
8
11
  export function ConversationListStatusIcon(props: {
9
12
  finishedSinceSeen: boolean;
10
13
  isPrivate: boolean;
11
14
  status: VisualStatus;
15
+ waitingOnViewer: boolean;
12
16
  }) {
17
+ if (props.waitingOnViewer) {
18
+ return (
19
+ <span
20
+ aria-label="Waiting on you"
21
+ className="size-2 shrink-0 rounded-full bg-violet-300 shadow-[0_0_6px_rgba(196,181,253,0.7)]"
22
+ role="img"
23
+ />
24
+ );
25
+ }
26
+
13
27
  if (props.finishedSinceSeen) {
14
28
  return (
15
29
  <span
@@ -73,11 +73,6 @@ import {
73
73
  hasConversationUsageBreakdown,
74
74
  } from "./ConversationUsageBreakdown";
75
75
  import { PendingMailboxStack } from "./PendingMailboxStack";
76
- import { UserInputRequestCard } from "./UserInputRequestCard";
77
- import {
78
- openUserInputRequest,
79
- type OpenUserInputRequest,
80
- } from "./userInputRequest";
81
76
  import {
82
77
  buildConversations,
83
78
  conversationDetailTitle,
@@ -93,6 +88,7 @@ import {
93
88
  } from "../components/ParticipantAvatarStack";
94
89
  import { ChatLayout } from "./ChatLayout";
95
90
  import { ComposerDock } from "./ComposerDock";
91
+ import { ConversationReviewBar } from "../review-desk/ConversationReviewBar";
96
92
  import { Transcript } from "./TranscriptView";
97
93
  import { TranscriptLoading } from "./TranscriptLoading";
98
94
  import type { TranscriptViewMode } from "./transcriptRenderModel";
@@ -196,10 +192,6 @@ export function ConversationPage(props: {
196
192
  () => committedMessageIds(detail.data?.events),
197
193
  [detail.data?.events],
198
194
  );
199
- const userInputRequest = useMemo(
200
- () => openUserInputRequest(detail.data?.events),
201
- [detail.data?.events],
202
- );
203
195
  // Cancel needs the latest mailbox watermark, but not as a render prop. Live
204
196
  // polls refresh generatedAt every 2s even when the queue is unchanged.
205
197
  const pendingGeneratedAtRef = useRef(detail.pendingGeneratedAt);
@@ -493,7 +485,6 @@ export function ConversationPage(props: {
493
485
  detail.inlineMessages.length === 0
494
486
  }
495
487
  stoppable={live}
496
- userInputRequest={userInputRequest}
497
488
  />
498
489
  ) : undefined
499
490
  }
@@ -547,9 +538,11 @@ export function ConversationPage(props: {
547
538
  {turnContext ? (
548
539
  <TranscriptSearchProvider query={search}>
549
540
  <TurnContextDetails
541
+ actor={turnContext.actor}
550
542
  contexts={turnContext.contexts}
551
543
  key={turnContext.id}
552
544
  onClose={closeTurnContext}
545
+ timestamp={turnContext.timestamp}
553
546
  />
554
547
  </TranscriptSearchProvider>
555
548
  ) : null}
@@ -582,8 +575,6 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
582
575
  sendsInline: boolean;
583
576
  /** True while the conversation has a Turn that a stop can end. */
584
577
  stoppable: boolean;
585
- /** Junior's open question, if no user message answers it yet. */
586
- userInputRequest?: OpenUserInputRequest;
587
578
  }) {
588
579
  const appendMessage = useAppendConversationMessage(props.conversationId);
589
580
  const stopTurn = useStopConversationTurn(props.conversationId);
@@ -662,13 +653,6 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
662
653
  const cancellableMessageIds = props.pendingMessages
663
654
  .filter((message) => message.clientStatus === undefined)
664
655
  .map((message) => message.inboundMessageId);
665
- const onAnswerUserInput = useCallback(async (answer: string) => {
666
- onPinRequestRef.current();
667
- await appendMessageRef.current.mutateAsync({
668
- idempotencyKey: crypto.randomUUID(),
669
- message: answer,
670
- });
671
- }, []);
672
656
  const onCancelMessage = useCallback(
673
657
  (message: ConversationMailboxMessage) => {
674
658
  const receivedBefore = props.pendingGeneratedAtRef.current;
@@ -793,14 +777,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
793
777
  {props.pendingAuthorization ? (
794
778
  <PendingAuthorization authorization={props.pendingAuthorization} />
795
779
  ) : null}
796
- {/* A queued message already answers the question. */}
797
- {props.userInputRequest && props.pendingMessages.length === 0 ? (
798
- <UserInputRequestCard
799
- key={props.userInputRequest.toolCallId}
800
- onAnswer={onAnswerUserInput}
801
- request={props.userInputRequest}
802
- />
803
- ) : null}
780
+ <ConversationReviewBar conversationId={props.conversationId} />
804
781
  <PendingMailboxStack
805
782
  cancelError={cancelError}
806
783
  cancelPending={cancelPendingMessages.isPending}
@@ -48,6 +48,7 @@ export function ConversationSidebar(props: {
48
48
  query: string;
49
49
  selectedId?: string;
50
50
  timeZone: string;
51
+ waitingConversationIds: ReadonlySet<string>;
51
52
  /**
52
53
  * `panel` = split-pane dock with internal scroll.
53
54
  * `landing` = flow nav under create compose (parent owns scroll).
@@ -186,6 +187,9 @@ export function ConversationSidebar(props: {
186
187
  onArchiveError={handleArchiveError}
187
188
  onArchived={handleArchived}
188
189
  selected={entry.conversation.id === props.selectedId}
190
+ waitingOnViewer={props.waitingConversationIds.has(
191
+ entry.conversation.id,
192
+ )}
189
193
  />
190
194
  )
191
195
  }
@@ -271,6 +275,7 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
271
275
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
272
276
  onArchived(conversation: Conversation): void;
273
277
  selected: boolean;
278
+ waitingOnViewer: boolean;
274
279
  }) {
275
280
  const archive = useArchiveConversation(props.conversation.id, {
276
281
  // Read the attempted direction from the mutation call, not the (possibly
@@ -316,6 +321,7 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
316
321
  finishedSinceSeen={props.finishedSinceSeen}
317
322
  isPrivate={isPrivate}
318
323
  status={status}
324
+ waitingOnViewer={props.waitingOnViewer}
319
325
  />
320
326
  </div>
321
327
  <div className="col-start-2 row-start-1 min-w-0 truncate font-sans text-sm font-medium leading-snug text-dashboard-text">
@@ -20,6 +20,7 @@ import { buildConversations, getDashboardTimeZone } from "../format";
20
20
  import { useDebouncedValue } from "../debouncedValue";
21
21
  import type { Conversation } from "../types";
22
22
  import { ConversationPage } from "./ConversationPage";
23
+ import { useWaitingConversationIds } from "../review-desk/queries";
23
24
  import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";
24
25
 
25
26
  const CONVERSATION_PAGE_SIZE = 20;
@@ -61,6 +62,7 @@ export function ConversationWorkspace() {
61
62
  useEffect(() => {
62
63
  if (page > totalPages) setPage(totalPages);
63
64
  }, [page, totalPages]);
65
+ const waitingConversationIds = useWaitingConversationIds();
64
66
  const { finishedConversationIds, markRead } =
65
67
  useConversationFinishedIndicators(
66
68
  conversations,
@@ -107,6 +109,7 @@ export function ConversationWorkspace() {
107
109
  finishedConversationIds={finishedConversationIds}
108
110
  loading={feed.isPending}
109
111
  timeZone={getDashboardTimeZone()}
112
+ waitingConversationIds={waitingConversationIds}
110
113
  />
111
114
  <PagePagination
112
115
  className="pt-1"
@@ -135,6 +138,7 @@ export function ConversationWorkspace() {
135
138
  query={query}
136
139
  selectedId={selectedId}
137
140
  timeZone={getDashboardTimeZone()}
141
+ waitingConversationIds={waitingConversationIds}
138
142
  />
139
143
  </div>
140
144
  <section
@@ -9,6 +9,7 @@ import {
9
9
  TooltipTrigger,
10
10
  } from "../components/ui/tooltip";
11
11
  import { formatMs } from "../format";
12
+ import { reviewItemIdFromToolCall } from "../review-desk/reviewItemFormat";
12
13
  import type { RenderedTranscriptEntry } from "./transcriptRenderModel";
13
14
  import { cn } from "../styles";
14
15
  import { useTranscriptSearch } from "./transcriptSearch";
@@ -29,6 +30,10 @@ export function isCollapsibleActivityEntry(
29
30
  // Child agent rows stay in the main view, like their result events.
30
31
  if (entry.kind === "subagent") return false;
31
32
  if (entry.kind === "message") return false;
33
+ // A review card stays visible where the agent added the item.
34
+ if (entry.kind === "tool" && reviewItemIdFromToolCall(entry.part)) {
35
+ return false;
36
+ }
32
37
  return true;
33
38
  }
34
39