@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.
- package/README.md +7 -105
- package/dist/app.js +47 -8
- package/dist/client.js +74 -78
- package/dist/index.js +47 -8
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/api/schema.ts +2 -1
- package/src/app.ts +11 -7
- package/src/client/App.tsx +20 -4
- package/src/client/api.ts +4 -19
- package/src/client/components/AGENTS.md +65 -0
- package/src/client/components/ActorAvatar.tsx +1 -1
- package/src/client/components/ParticipantAvatarStack.tsx +3 -1
- package/src/client/components/ProfileMenu.tsx +88 -29
- package/src/client/components/UserInputForm.tsx +283 -0
- package/src/client/components/charts/AGENTS.md +15 -0
- package/src/client/components/layout/DashboardHeader.tsx +13 -0
- package/src/client/components/ui/button.tsx +11 -1
- package/src/client/conversations/AGENTS.md +42 -0
- package/src/client/conversations/ConversationHomeList.tsx +6 -0
- package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
- package/src/client/conversations/ConversationPage.tsx +4 -27
- 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/turnContext.ts +15 -0
- package/src/client/format.ts +28 -28
- package/src/client/pages/SettingsPage.tsx +103 -3
- package/src/client/pages/automations/AGENTS.md +34 -0
- package/src/client/pages/automations/AutomationActions.tsx +18 -20
- package/src/client/pages/automations/AutomationEditor.tsx +3 -4
- package/src/client/pages/automations/AutomationOverview.tsx +3 -3
- package/src/client/pages/automations/AutomationStatus.tsx +2 -2
- package/src/client/pages/code/RepositorySections.tsx +1 -0
- package/src/client/pages/code/RepositoryTabs.tsx +3 -0
- package/src/client/pages/system/AGENTS.md +12 -0
- package/src/client/primaryNav.ts +15 -2
- package/src/client/review-desk/ConversationReviewBar.tsx +68 -0
- package/src/client/review-desk/ReviewDeskPage.tsx +297 -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 +123 -0
- package/src/client/review-desk/queries.ts +173 -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/src/tailwind.css +1 -1
- package/src/client/components/README.md +0 -215
- package/src/client/components/charts/README.md +0 -38
- package/src/client/conversations/README.md +0 -159
- package/src/client/conversations/UserInputRequestCard.tsx +0 -244
- package/src/client/conversations/userInputRequest.ts +0 -56
- package/src/client/pages/automations/README.md +0 -97
- 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
|
-
/**
|
|
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
|
-
{
|
|
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
|
|