@sentry/junior-dashboard 0.267.0 → 0.268.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 (38) hide show
  1. package/dist/app.js +38 -2
  2. package/dist/client.js +81 -82
  3. package/dist/index.js +38 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/client/App.tsx +20 -0
  7. package/src/client/components/ParticipantAvatarStack.tsx +2 -1
  8. package/src/client/components/README.md +4 -0
  9. package/src/client/components/UserInputForm.tsx +283 -0
  10. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  11. package/src/client/components/ui/button.tsx +11 -1
  12. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  13. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  14. package/src/client/conversations/ConversationPage.tsx +4 -0
  15. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  16. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  17. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  18. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  19. package/src/client/conversations/TranscriptMessageView.tsx +3 -0
  20. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  21. package/src/client/conversations/TranscriptTurnContextView.tsx +142 -144
  22. package/src/client/conversations/UserInputRequestCard.tsx +14 -219
  23. package/src/client/conversations/turnContext.ts +15 -0
  24. package/src/client/format.ts +21 -5
  25. package/src/client/pages/SettingsPage.tsx +103 -3
  26. package/src/client/pages/code/RepositorySections.tsx +1 -0
  27. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  28. package/src/client/primaryNav.ts +15 -2
  29. package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
  30. package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
  31. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  32. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  33. package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
  34. package/src/client/review-desk/queries.ts +164 -0
  35. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  36. package/src/mock-reporting/fixtures.ts +10 -0
  37. package/src/mock-reporting/routes.ts +20 -0
  38. package/src/shell.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.267.0",
3
+ "version": "0.268.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -39,10 +39,10 @@
39
39
  "remark-gfm": "^4.0.1",
40
40
  "shiki": "4.1.0",
41
41
  "zod": "^4.5.4",
42
- "@sentry/junior-plugin-api": "0.267.0"
42
+ "@sentry/junior-plugin-api": "0.268.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@sentry/junior": "0.267.0"
45
+ "@sentry/junior": "0.268.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@oxlint/plugins": "1.79.0",
@@ -62,7 +62,7 @@
62
62
  "tw-animate-css": "^1.4.0",
63
63
  "typescript": "^7.0.2",
64
64
  "vitest": "^4.1.12",
65
- "@sentry/junior": "0.267.0"
65
+ "@sentry/junior": "0.268.0"
66
66
  },
67
67
  "scripts": {
68
68
  "build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:client && pnpm run build:css && pnpm run build:server",
@@ -66,6 +66,8 @@ import {
66
66
  } from "./pages/user/PluginUserPage";
67
67
  import { AdminPage } from "./pages/admin/AdminPage";
68
68
  import { buildPrimaryNavItems } from "./primaryNav";
69
+ import { ReviewDeskPage } from "./review-desk/ReviewDeskPage";
70
+ import { useReviewItemCountData } from "./review-desk/queries";
69
71
  import type { DashboardCoreData } from "./types";
70
72
 
71
73
  const dashboardNoise = {
@@ -111,11 +113,13 @@ export function DashboardShell() {
111
113
  ? (conversationDetailTitle(conversationDetail.data) ??
112
114
  conversationDisplayTitle(mobileConversation))
113
115
  : undefined;
116
+ const reviewCountQuery = useReviewItemCountData(loggedIn);
114
117
  const primaryNavItems = buildPrimaryNavItems({
115
118
  loading,
116
119
  loggedIn,
117
120
  pathname: location.pathname,
118
121
  primaryUserPages,
122
+ reviewCount: reviewCountQuery.data?.mine,
119
123
  userPagesPending: userPagesQuery.isPending,
120
124
  });
121
125
 
@@ -362,6 +366,22 @@ export function DashboardShell() {
362
366
  }
363
367
  path="/"
364
368
  />
369
+ <Route
370
+ element={
371
+ loading ? (
372
+ <PageRouteLoading
373
+ description="Items that need a person, and who can act on them."
374
+ label="Loading review desk"
375
+ title="Review desk"
376
+ />
377
+ ) : loggedIn ? (
378
+ <ReviewDeskPage />
379
+ ) : (
380
+ <Navigate replace to="/" />
381
+ )
382
+ }
383
+ path="/review"
384
+ />
365
385
  <Route element={<CodePage />} path="/code" />
366
386
  {codeRepositoryTabs.map((tab) => (
367
387
  <Route
@@ -13,7 +13,8 @@ type ParticipantAvatarStackProps = {
13
13
  size: "detail" | "list";
14
14
  };
15
15
 
16
- function participantName(participant: ActorIdentity): string {
16
+ /** Name an actor for display: full name first, then handle, email, or ID. */
17
+ export function participantName(participant: ActorIdentity): string {
17
18
  return (
18
19
  participant.fullName?.trim() ||
19
20
  participant.slackUserName?.trim() ||
@@ -90,6 +90,10 @@ Rules for adopted components:
90
90
  - For a link that looks like a button, put `buttonVariants()` on the router
91
91
  `Link`. A form page has one `Button variant="primary"` for its main save
92
92
  action.
93
+ - Use `Button variant="quiet"` for a secondary action beside the primary one,
94
+ such as Dismiss. It has no border, so the primary action stays dominant.
95
+ - In a form, show each choice as a whole bordered row around `RadioGroupItem`
96
+ or `Checkbox`. The row shows hover and selected state. See `UserInputForm`.
93
97
 
94
98
  ## Foundations
95
99
 
@@ -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 a `requestUserInput` 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
+ }
@@ -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",
@@ -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
@@ -93,6 +93,7 @@ import {
93
93
  } from "../components/ParticipantAvatarStack";
94
94
  import { ChatLayout } from "./ChatLayout";
95
95
  import { ComposerDock } from "./ComposerDock";
96
+ import { ConversationReviewBar } from "../review-desk/ConversationReviewBar";
96
97
  import { Transcript } from "./TranscriptView";
97
98
  import { TranscriptLoading } from "./TranscriptLoading";
98
99
  import type { TranscriptViewMode } from "./transcriptRenderModel";
@@ -547,9 +548,11 @@ export function ConversationPage(props: {
547
548
  {turnContext ? (
548
549
  <TranscriptSearchProvider query={search}>
549
550
  <TurnContextDetails
551
+ actor={turnContext.actor}
550
552
  contexts={turnContext.contexts}
551
553
  key={turnContext.id}
552
554
  onClose={closeTurnContext}
555
+ timestamp={turnContext.timestamp}
553
556
  />
554
557
  </TranscriptSearchProvider>
555
558
  ) : null}
@@ -801,6 +804,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
801
804
  request={props.userInputRequest}
802
805
  />
803
806
  ) : null}
807
+ <ConversationReviewBar conversationId={props.conversationId} />
804
808
  <PendingMailboxStack
805
809
  cancelError={cancelError}
806
810
  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
 
@@ -3,7 +3,11 @@ import { useState } from "react";
3
3
 
4
4
  import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
5
5
  import { dashboardDisclosureChevronClass } from "../styles";
6
- import { formatMessageTimestamp, transcriptMessageActorLabel } from "../format";
6
+ import {
7
+ formatMessageTimestamp,
8
+ transcriptMessageActor,
9
+ transcriptMessageActorLabel,
10
+ } from "../format";
7
11
  import type { ConversationTranscript, TranscriptViewMessage } from "../types";
8
12
  import {
9
13
  TranscriptHeadingMeta,
@@ -61,8 +65,13 @@ export function TranscriptMessageContextView(props: {
61
65
  {!props.redacted && props.message.contexts?.length ? (
62
66
  <div className="mt-2">
63
67
  <TranscriptTurnContextView
68
+ actor={transcriptMessageActor(
69
+ props.conversation,
70
+ props.message,
71
+ )}
64
72
  contexts={props.message.contexts}
65
73
  messageId={props.message.messageId}
74
+ timestamp={props.message.timestamp}
66
75
  />
67
76
  </div>
68
77
  ) : null}
@@ -93,8 +102,10 @@ export function TranscriptMessageContextView(props: {
93
102
  {!props.redacted && props.message.contexts?.length ? (
94
103
  <div className="mt-2">
95
104
  <TranscriptTurnContextView
105
+ actor={transcriptMessageActor(props.conversation, props.message)}
96
106
  contexts={props.message.contexts}
97
107
  messageId={props.message.messageId}
108
+ timestamp={props.message.timestamp}
98
109
  />
99
110
  </div>
100
111
  ) : null}