@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.
- package/dist/app.js +38 -2
- package/dist/client.js +81 -82
- package/dist/index.js +38 -2
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/App.tsx +20 -0
- package/src/client/components/ParticipantAvatarStack.tsx +2 -1
- package/src/client/components/README.md +4 -0
- package/src/client/components/UserInputForm.tsx +283 -0
- package/src/client/components/layout/DashboardHeader.tsx +13 -0
- package/src/client/components/ui/button.tsx +11 -1
- package/src/client/conversations/ConversationHomeList.tsx +6 -0
- package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
- package/src/client/conversations/ConversationPage.tsx +4 -0
- package/src/client/conversations/ConversationSidebar.tsx +6 -0
- package/src/client/conversations/ConversationWorkspace.tsx +4 -0
- package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
- package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
- package/src/client/conversations/TranscriptMessageView.tsx +3 -0
- package/src/client/conversations/TranscriptToolView.tsx +13 -1
- package/src/client/conversations/TranscriptTurnContextView.tsx +142 -144
- package/src/client/conversations/UserInputRequestCard.tsx +14 -219
- package/src/client/conversations/turnContext.ts +15 -0
- package/src/client/format.ts +21 -5
- package/src/client/pages/SettingsPage.tsx +103 -3
- package/src/client/pages/code/RepositorySections.tsx +1 -0
- package/src/client/pages/code/RepositoryTabs.tsx +3 -0
- package/src/client/primaryNav.ts +15 -2
- package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
- package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
- package/src/client/review-desk/ReviewItemActions.tsx +165 -0
- package/src/client/review-desk/ReviewItemCard.tsx +47 -0
- package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
- package/src/client/review-desk/queries.ts +164 -0
- package/src/client/review-desk/reviewItemFormat.ts +61 -0
- package/src/mock-reporting/fixtures.ts +10 -0
- package/src/mock-reporting/routes.ts +20 -0
- 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.
|
|
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.
|
|
42
|
+
"@sentry/junior-plugin-api": "0.268.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@sentry/junior": "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.
|
|
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",
|
package/src/client/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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}
|