@sentry/junior-dashboard 0.273.0 → 0.275.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 +3 -3
- package/dist/client.js +76 -76
- package/dist/index.js +3 -3
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/App.tsx +11 -0
- package/src/client/components/Metric.tsx +0 -32
- package/src/client/components/ProfileMenu.tsx +56 -4
- package/src/client/components/layout/DashboardChrome.tsx +3 -3
- package/src/client/components/layout/DashboardHeader.tsx +41 -4
- package/src/client/components/layout/NavRail.tsx +43 -40
- package/src/client/conversations/ConversationComposer.tsx +7 -4
- package/src/client/conversations/ConversationDetails.tsx +117 -162
- package/src/client/conversations/ConversationHeader.tsx +119 -133
- package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
- package/src/client/conversations/ConversationMeta.tsx +49 -44
- package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
- package/src/client/conversations/ConversationPage.tsx +33 -368
- package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
- package/src/client/conversations/ConversationSidebar.tsx +119 -60
- package/src/client/conversations/ConversationWorkspace.tsx +8 -3
- package/src/client/conversations/TelemetryMetrics.tsx +0 -14
- package/src/client/conversations/TranscriptView.tsx +15 -32
- package/src/client/conversations/UsageCard.tsx +1 -15
- package/src/client/conversations/composerAutocorrect.ts +12 -0
- package/src/client/conversations/conversationAge.ts +24 -0
- package/src/client/conversations/conversationRoutes.ts +14 -5
- package/src/client/conversations/conversationSections.ts +66 -48
- package/src/client/conversations/useConversationFinishedIndicators.ts +40 -8
- package/src/client/pages/settings/SettingsPage.tsx +28 -0
- package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
- package/src/client/review-desk/queries.ts +30 -6
- package/src/mock-reporting/routes.ts +1 -1
- package/src/tailwind.css +4 -0
- package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
- package/src/client/review-desk/ConversationReviewBar.tsx +0 -67
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
ConversationDetailReport,
|
|
4
|
+
ConversationPendingMessagesReport,
|
|
5
|
+
InputImage,
|
|
6
|
+
} from "@sentry/junior/api/schema";
|
|
7
|
+
|
|
8
|
+
import { useDashboardCoreData } from "../api";
|
|
9
|
+
import { useReviewItemData } from "../review-desk/queries";
|
|
10
|
+
import { reviewItemWaitsForAnswer } from "../review-desk/reviewItemFormat";
|
|
11
|
+
import { useConfirmUnsentAnswers } from "../review-desk/UnsentAnswers";
|
|
12
|
+
import { readStoredComposerImages } from "./ComposerImages";
|
|
13
|
+
import { ComposerDock } from "./ComposerDock";
|
|
14
|
+
import {
|
|
15
|
+
ConversationComposer,
|
|
16
|
+
restoreStoredDraftText,
|
|
17
|
+
type ConversationComposerHandle,
|
|
18
|
+
} from "./ConversationComposer";
|
|
19
|
+
import type { ConversationMailboxMessage } from "./conversationOutbox";
|
|
20
|
+
import { PendingAuthorization } from "./ConversationMeta";
|
|
21
|
+
import { PendingMailboxStack } from "./PendingMailboxStack";
|
|
22
|
+
import {
|
|
23
|
+
useAppendConversationMessage,
|
|
24
|
+
useCancelConversationPendingMessages,
|
|
25
|
+
useSteerConversationPendingMessages,
|
|
26
|
+
useStopConversationTurn,
|
|
27
|
+
} from "./queries";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Own mutation state and mailbox chrome outside the page tree that re-renders
|
|
31
|
+
* on every live transcript poll. Keeps composer props stable while typing.
|
|
32
|
+
*
|
|
33
|
+
* Memoized so metadata-only polls that keep mailbox identity stable skip the
|
|
34
|
+
* footer tree. Fast chat UIs isolate the composer the same way.
|
|
35
|
+
*/
|
|
36
|
+
export const ConversationReplyFooter = memo(ReplyFooter);
|
|
37
|
+
|
|
38
|
+
function ReplyFooter(props: {
|
|
39
|
+
authors?: ConversationDetailReport["authors"];
|
|
40
|
+
committedMessageIds: readonly string[];
|
|
41
|
+
conversationId: string;
|
|
42
|
+
onPinRequest: () => void;
|
|
43
|
+
pendingAuthorization?: ConversationPendingMessagesReport["authorization"];
|
|
44
|
+
pendingGeneratedAtRef: { current: string | undefined };
|
|
45
|
+
pendingMessages: readonly ConversationMailboxMessage[];
|
|
46
|
+
/** The last Review item that Junior added in the transcript. */
|
|
47
|
+
reviewItemId?: string;
|
|
48
|
+
/**
|
|
49
|
+
* True when history is live and no Turn has started, so the next Turn will
|
|
50
|
+
* read a new send at once.
|
|
51
|
+
*/
|
|
52
|
+
sendsInline: boolean;
|
|
53
|
+
/** True while the conversation has a Turn that a stop can end. */
|
|
54
|
+
stoppable: boolean;
|
|
55
|
+
}) {
|
|
56
|
+
const appendMessage = useAppendConversationMessage(props.conversationId);
|
|
57
|
+
// A message closes the review items that it answers. Ask before it drops
|
|
58
|
+
// answers that the reader did not send.
|
|
59
|
+
const unsentAnswers = useConfirmUnsentAnswers();
|
|
60
|
+
const stopTurn = useStopConversationTurn(props.conversationId);
|
|
61
|
+
const stopTurnRef = useRef(stopTurn);
|
|
62
|
+
stopTurnRef.current = stopTurn;
|
|
63
|
+
const cancelPendingMessages = useCancelConversationPendingMessages(
|
|
64
|
+
props.conversationId,
|
|
65
|
+
);
|
|
66
|
+
const [editTargetInboundMessageId, setEditTargetInboundMessageId] =
|
|
67
|
+
useState<string>();
|
|
68
|
+
const [queueActionError, setQueueActionError] = useState<string>();
|
|
69
|
+
// While Junior waits for an answer, hide the message box and the queued
|
|
70
|
+
// messages, so the reader answers in the question and does not type the
|
|
71
|
+
// answer as a new message.
|
|
72
|
+
// Dismiss on the question closes the item and shows the message box again.
|
|
73
|
+
// The message box stays mounted, so its text and images stay, and it does
|
|
74
|
+
// not hide while it has focus.
|
|
75
|
+
const reviewItem = useReviewItemData(props.reviewItemId).data;
|
|
76
|
+
const [composerShownForItemId, setComposerShownForItemId] =
|
|
77
|
+
useState<string>();
|
|
78
|
+
const [composerFocused, setComposerFocused] = useState(false);
|
|
79
|
+
const question =
|
|
80
|
+
reviewItem &&
|
|
81
|
+
!props.stoppable &&
|
|
82
|
+
!composerFocused &&
|
|
83
|
+
reviewItemWaitsForAnswer(reviewItem) &&
|
|
84
|
+
composerShownForItemId !== reviewItem.id
|
|
85
|
+
? reviewItem
|
|
86
|
+
: undefined;
|
|
87
|
+
// The message box can show only because it has focus. Using the queued
|
|
88
|
+
// messages moves focus out of it, so keep them shown for this item.
|
|
89
|
+
const keepComposerShown = () => {
|
|
90
|
+
if (composerFocused && reviewItem && reviewItemWaitsForAnswer(reviewItem)) {
|
|
91
|
+
setComposerShownForItemId(reviewItem.id);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// Keep submit identity stable across mutation status flips so the memoized
|
|
95
|
+
// composer does not re-render while the reader is still typing.
|
|
96
|
+
const appendMessageRef = useRef(appendMessage);
|
|
97
|
+
appendMessageRef.current = appendMessage;
|
|
98
|
+
const cancelPendingMessagesRef = useRef(cancelPendingMessages);
|
|
99
|
+
cancelPendingMessagesRef.current = cancelPendingMessages;
|
|
100
|
+
const onPinRequestRef = useRef(props.onPinRequest);
|
|
101
|
+
onPinRequestRef.current = props.onPinRequest;
|
|
102
|
+
const pendingMessageVersion = props.pendingMessages
|
|
103
|
+
.map((message) =>
|
|
104
|
+
[
|
|
105
|
+
message.inboundMessageId,
|
|
106
|
+
message.messageId,
|
|
107
|
+
message.clientStatus,
|
|
108
|
+
message.delivery,
|
|
109
|
+
].join(":"),
|
|
110
|
+
)
|
|
111
|
+
.join("|");
|
|
112
|
+
const pendingMessageVersionRef = useRef(pendingMessageVersion);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (pendingMessageVersionRef.current === pendingMessageVersion) return;
|
|
115
|
+
pendingMessageVersionRef.current = pendingMessageVersion;
|
|
116
|
+
if (!window.matchMedia("(max-width: 767px)").matches) return;
|
|
117
|
+
onPinRequestRef.current();
|
|
118
|
+
}, [pendingMessageVersion]);
|
|
119
|
+
// No Turn runs and nothing waits in the queue, so the next send would
|
|
120
|
+
// leave the queue almost at once. Show it in the transcript instead. A
|
|
121
|
+
// conversation can be active before its Turn starts; that still counts.
|
|
122
|
+
const committedIds = new Set(props.committedMessageIds);
|
|
123
|
+
const sendInlineRef = useRef(false);
|
|
124
|
+
sendInlineRef.current =
|
|
125
|
+
props.sendsInline &&
|
|
126
|
+
!props.pendingMessages.some(
|
|
127
|
+
(message) => !committedIds.has(message.messageId),
|
|
128
|
+
);
|
|
129
|
+
const onSubmit = useCallback(
|
|
130
|
+
async (message: string, idempotencyKey: string, images?: InputImage[]) => {
|
|
131
|
+
await appendMessageRef.current.mutateAsync({
|
|
132
|
+
idempotencyKey,
|
|
133
|
+
inline: sendInlineRef.current,
|
|
134
|
+
message,
|
|
135
|
+
images,
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
[],
|
|
139
|
+
);
|
|
140
|
+
const onRetry = useCallback((message: ConversationMailboxMessage) => {
|
|
141
|
+
if (!message.idempotencyKey || (!message.text && !message.images?.length))
|
|
142
|
+
return;
|
|
143
|
+
void appendMessageRef.current.mutateAsync({
|
|
144
|
+
idempotencyKey: message.idempotencyKey,
|
|
145
|
+
message: message.text ?? "",
|
|
146
|
+
images: message.images,
|
|
147
|
+
});
|
|
148
|
+
}, []);
|
|
149
|
+
const onSubmitStart = useCallback(() => {
|
|
150
|
+
// Keep an in-flight remove intact so optimistic cache rollback stays coherent.
|
|
151
|
+
if (!cancelPendingMessagesRef.current.isPending) {
|
|
152
|
+
cancelPendingMessagesRef.current.reset();
|
|
153
|
+
}
|
|
154
|
+
if (!stopTurnRef.current.isPending) stopTurnRef.current.reset();
|
|
155
|
+
setQueueActionError(undefined);
|
|
156
|
+
onPinRequestRef.current();
|
|
157
|
+
}, []);
|
|
158
|
+
const cancellableMessageIds = props.pendingMessages
|
|
159
|
+
.filter((message) => message.clientStatus === undefined)
|
|
160
|
+
.map((message) => message.inboundMessageId);
|
|
161
|
+
const onCancelMessage = useCallback(
|
|
162
|
+
(message: ConversationMailboxMessage) => {
|
|
163
|
+
const receivedBefore = props.pendingGeneratedAtRef.current;
|
|
164
|
+
if (!receivedBefore) return;
|
|
165
|
+
cancelPendingMessagesRef.current.mutate({
|
|
166
|
+
inboundMessageIds: [message.inboundMessageId],
|
|
167
|
+
receivedBefore,
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
[props.pendingGeneratedAtRef],
|
|
171
|
+
);
|
|
172
|
+
const cancelTargetInboundMessageId =
|
|
173
|
+
cancelPendingMessages.variables?.inboundMessageIds[0];
|
|
174
|
+
const cancelError = Boolean(
|
|
175
|
+
cancelPendingMessages.error &&
|
|
176
|
+
cancelPendingMessages.variables?.inboundMessageIds.some((id) =>
|
|
177
|
+
cancellableMessageIds.includes(id),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Edit uses its own cancel mutation so remove errors stay on remove.
|
|
182
|
+
const editCancel = useCancelConversationPendingMessages(props.conversationId);
|
|
183
|
+
const editCancelRef = useRef(editCancel);
|
|
184
|
+
editCancelRef.current = editCancel;
|
|
185
|
+
const editingRef = useRef(false);
|
|
186
|
+
const composerRef = useRef<ConversationComposerHandle>(null);
|
|
187
|
+
const viewerEmail = useDashboardCoreData().data?.me.user.email;
|
|
188
|
+
const onEditMessage = useCallback(
|
|
189
|
+
async (message: ConversationMailboxMessage) => {
|
|
190
|
+
const receivedBefore = props.pendingGeneratedAtRef.current;
|
|
191
|
+
// The image read runs before the cancel starts, so isPending alone does
|
|
192
|
+
// not stop a second click.
|
|
193
|
+
if (!receivedBefore || editingRef.current) return;
|
|
194
|
+
editingRef.current = true;
|
|
195
|
+
const inboundMessageId = message.inboundMessageId;
|
|
196
|
+
setEditTargetInboundMessageId(inboundMessageId);
|
|
197
|
+
setQueueActionError(undefined);
|
|
198
|
+
try {
|
|
199
|
+
// Read images first. A failed read must leave the message queued.
|
|
200
|
+
const images = await readStoredComposerImages(
|
|
201
|
+
props.conversationId,
|
|
202
|
+
message.attachments,
|
|
203
|
+
);
|
|
204
|
+
// The reader left during the read. Keep the message in the queue.
|
|
205
|
+
if (!composerRef.current) return;
|
|
206
|
+
// Check before the cancel. After it, images that do not fit are lost.
|
|
207
|
+
if (!composerRef.current.canRestoreImages(images)) {
|
|
208
|
+
setQueueActionError("Remove some draft images to edit that message.");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const result = await editCancelRef.current.mutateAsync({
|
|
212
|
+
inboundMessageIds: [inboundMessageId],
|
|
213
|
+
receivedBefore,
|
|
214
|
+
});
|
|
215
|
+
// Junior can read the message before the cancel lands. Then it is
|
|
216
|
+
// already in the Conversation and there is nothing to edit.
|
|
217
|
+
if (!result.cancelledInboundMessageIds.includes(inboundMessageId)) {
|
|
218
|
+
setQueueActionError("Junior already read that message.");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const restored = { images, text: message.text ?? "" };
|
|
222
|
+
if (composerRef.current) {
|
|
223
|
+
composerRef.current.restoreMessage(restored);
|
|
224
|
+
} else {
|
|
225
|
+
// The reader left during the cancel. Keep the text in the saved
|
|
226
|
+
// draft so it is there when they come back.
|
|
227
|
+
restoreStoredDraftText(props.conversationId, restored.text);
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
setQueueActionError("Could not edit the queued message. Try again.");
|
|
231
|
+
} finally {
|
|
232
|
+
editingRef.current = false;
|
|
233
|
+
setEditTargetInboundMessageId(undefined);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
[props.conversationId, props.pendingGeneratedAtRef],
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const steerPendingMessages = useSteerConversationPendingMessages(
|
|
240
|
+
props.conversationId,
|
|
241
|
+
);
|
|
242
|
+
const steerPendingMessagesRef = useRef(steerPendingMessages);
|
|
243
|
+
steerPendingMessagesRef.current = steerPendingMessages;
|
|
244
|
+
const onSteerMessage = useCallback((message: ConversationMailboxMessage) => {
|
|
245
|
+
if (steerPendingMessagesRef.current.isPending) return;
|
|
246
|
+
setQueueActionError(undefined);
|
|
247
|
+
steerPendingMessagesRef.current.mutate(
|
|
248
|
+
{ inboundMessageIds: [message.inboundMessageId] },
|
|
249
|
+
{
|
|
250
|
+
onError: () =>
|
|
251
|
+
setQueueActionError(
|
|
252
|
+
"Could not send the queued message now. Try again.",
|
|
253
|
+
),
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
}, []);
|
|
257
|
+
const steerTargetInboundMessageId = steerPendingMessages.isPending
|
|
258
|
+
? steerPendingMessages.variables?.inboundMessageIds[0]
|
|
259
|
+
: undefined;
|
|
260
|
+
|
|
261
|
+
const onStop = useCallback(async () => {
|
|
262
|
+
const result = await stopTurnRef.current.mutateAsync();
|
|
263
|
+
const restored = result.stoppedMessages.map((message) => message.text);
|
|
264
|
+
return restored.length ? restored.join("\n") : undefined;
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
const onMailboxLayoutChange = useCallback(() => {
|
|
268
|
+
if (!window.matchMedia("(max-width: 767px)").matches) return;
|
|
269
|
+
onPinRequestRef.current();
|
|
270
|
+
}, []);
|
|
271
|
+
// Mobile keyboard open shrinks the transcript. Focus must re-pin the latest
|
|
272
|
+
// message so the reader does not stay stuck above the docked composer.
|
|
273
|
+
const onComposerFocus = useCallback(() => {
|
|
274
|
+
if (!window.matchMedia("(max-width: 767px)").matches) return;
|
|
275
|
+
onPinRequestRef.current();
|
|
276
|
+
}, []);
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<ComposerDock
|
|
280
|
+
above={
|
|
281
|
+
<>
|
|
282
|
+
{props.pendingAuthorization ? (
|
|
283
|
+
<PendingAuthorization authorization={props.pendingAuthorization} />
|
|
284
|
+
) : null}
|
|
285
|
+
{/* Queued messages hide with the message box, so the question is
|
|
286
|
+
the last thing above the dock. */}
|
|
287
|
+
<div
|
|
288
|
+
hidden={Boolean(question)}
|
|
289
|
+
onFocus={keepComposerShown}
|
|
290
|
+
onPointerDown={keepComposerShown}
|
|
291
|
+
>
|
|
292
|
+
<PendingMailboxStack
|
|
293
|
+
authors={props.authors}
|
|
294
|
+
cancelError={cancelError}
|
|
295
|
+
cancelPending={cancelPendingMessages.isPending}
|
|
296
|
+
cancelTargetInboundMessageId={cancelTargetInboundMessageId}
|
|
297
|
+
committedMessageIds={props.committedMessageIds}
|
|
298
|
+
conversationId={props.conversationId}
|
|
299
|
+
editTargetInboundMessageId={editTargetInboundMessageId}
|
|
300
|
+
messages={props.pendingMessages}
|
|
301
|
+
onCancelMessage={onCancelMessage}
|
|
302
|
+
onEditMessage={onEditMessage}
|
|
303
|
+
onLayoutChange={onMailboxLayoutChange}
|
|
304
|
+
onRetry={onRetry}
|
|
305
|
+
onSteerMessage={props.stoppable ? onSteerMessage : undefined}
|
|
306
|
+
steerTargetInboundMessageId={steerTargetInboundMessageId}
|
|
307
|
+
viewerEmail={viewerEmail}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
{unsentAnswers.dialog}
|
|
311
|
+
</>
|
|
312
|
+
}
|
|
313
|
+
>
|
|
314
|
+
<div
|
|
315
|
+
hidden={Boolean(question)}
|
|
316
|
+
onBlur={(event) => {
|
|
317
|
+
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
318
|
+
setComposerFocused(false);
|
|
319
|
+
}
|
|
320
|
+
}}
|
|
321
|
+
onFocus={() => setComposerFocused(true)}
|
|
322
|
+
>
|
|
323
|
+
<ConversationComposer
|
|
324
|
+
confirmSubmit={unsentAnswers.confirmSubmit}
|
|
325
|
+
draftId={props.conversationId}
|
|
326
|
+
error={
|
|
327
|
+
queueActionError ??
|
|
328
|
+
(stopTurn.error ? "Could not stop Junior. Try again." : undefined)
|
|
329
|
+
}
|
|
330
|
+
ref={composerRef}
|
|
331
|
+
label="Continue this conversation"
|
|
332
|
+
submitLabel="Send"
|
|
333
|
+
onFocus={onComposerFocus}
|
|
334
|
+
onStop={props.stoppable ? onStop : undefined}
|
|
335
|
+
stopPending={stopTurn.isPending}
|
|
336
|
+
onSubmitStart={onSubmitStart}
|
|
337
|
+
onSubmit={onSubmit}
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
</ComposerDock>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Archive,
|
|
4
|
+
ArchiveRestore,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
CircleAlert,
|
|
7
|
+
Inbox,
|
|
8
|
+
LockKeyhole,
|
|
9
|
+
SquarePen,
|
|
10
|
+
} from "lucide-react";
|
|
3
11
|
import { Link } from "react-router";
|
|
4
12
|
|
|
5
13
|
import { useArchiveConversation } from "./queries";
|
|
6
14
|
import { conversationPath } from "./conversationRoutes";
|
|
7
15
|
import {
|
|
8
16
|
conversationDisplayTitle,
|
|
9
|
-
slackLocationLabel,
|
|
10
17
|
visualStatusForConversation,
|
|
11
18
|
} from "../format";
|
|
19
|
+
import { formatCompactAge } from "./conversationAge";
|
|
12
20
|
import { cn } from "../styles";
|
|
13
21
|
import type { Conversation } from "../types";
|
|
14
22
|
import { Notice, NoticeAction } from "../components/Notice";
|
|
15
23
|
import { AnimatedList } from "./AnimatedList";
|
|
16
24
|
import {
|
|
17
|
-
attentionConversationIds,
|
|
18
25
|
buildConversationSections,
|
|
19
26
|
type ConversationSection,
|
|
20
27
|
} from "./conversationSections";
|
|
@@ -29,8 +36,8 @@ import {
|
|
|
29
36
|
TooltipContent,
|
|
30
37
|
TooltipTrigger,
|
|
31
38
|
} from "../components/ui/tooltip";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
39
|
+
import { Spinner } from "../components/ui/spinner";
|
|
40
|
+
import type { WaitingConversation } from "../review-desk/queries";
|
|
34
41
|
|
|
35
42
|
type ConversationSidebarEntry =
|
|
36
43
|
| {
|
|
@@ -55,11 +62,13 @@ export function ConversationSidebar(props: {
|
|
|
55
62
|
conversations: Conversation[];
|
|
56
63
|
error?: string;
|
|
57
64
|
finishedConversationIds: ReadonlySet<string>;
|
|
65
|
+
/** Opened while unread; keeps its unread place until another one opens. */
|
|
66
|
+
heldConversationId?: string;
|
|
58
67
|
loading: boolean;
|
|
59
68
|
query: string;
|
|
60
69
|
selectedId?: string;
|
|
61
70
|
timeZone: string;
|
|
62
|
-
|
|
71
|
+
waitingConversations: ReadonlyMap<string, WaitingConversation>;
|
|
63
72
|
/**
|
|
64
73
|
* `panel` = split-pane dock with internal scroll.
|
|
65
74
|
* `landing` = flow nav under create compose (parent owns scroll).
|
|
@@ -97,10 +106,11 @@ export function ConversationSidebar(props: {
|
|
|
97
106
|
() =>
|
|
98
107
|
conversationSidebarEntries(
|
|
99
108
|
buildConversationSections(props.conversations, {
|
|
100
|
-
attentionIds:
|
|
101
|
-
props.
|
|
102
|
-
props.finishedConversationIds,
|
|
103
|
-
|
|
109
|
+
attentionIds: new Set([
|
|
110
|
+
...props.waitingConversations.keys(),
|
|
111
|
+
...props.finishedConversationIds,
|
|
112
|
+
...(props.heldConversationId ? [props.heldConversationId] : []),
|
|
113
|
+
]),
|
|
104
114
|
nowMs: Date.now(),
|
|
105
115
|
timeZone: props.timeZone,
|
|
106
116
|
}),
|
|
@@ -110,8 +120,9 @@ export function ConversationSidebar(props: {
|
|
|
110
120
|
collapsedKeys,
|
|
111
121
|
props.conversations,
|
|
112
122
|
props.finishedConversationIds,
|
|
123
|
+
props.heldConversationId,
|
|
113
124
|
props.timeZone,
|
|
114
|
-
props.
|
|
125
|
+
props.waitingConversations,
|
|
115
126
|
],
|
|
116
127
|
);
|
|
117
128
|
return (
|
|
@@ -204,13 +215,13 @@ export function ConversationSidebar(props: {
|
|
|
204
215
|
) : (
|
|
205
216
|
<ConversationSidebarRow
|
|
206
217
|
conversation={entry.conversation}
|
|
207
|
-
finishedSinceSeen={props.finishedConversationIds.has(
|
|
208
|
-
entry.conversation.id,
|
|
209
|
-
)}
|
|
210
218
|
onArchiveError={handleArchiveError}
|
|
211
219
|
onArchived={handleArchived}
|
|
212
220
|
selected={entry.conversation.id === props.selectedId}
|
|
213
|
-
|
|
221
|
+
unread={props.finishedConversationIds.has(
|
|
222
|
+
entry.conversation.id,
|
|
223
|
+
)}
|
|
224
|
+
waiting={props.waitingConversations.get(
|
|
214
225
|
entry.conversation.id,
|
|
215
226
|
)}
|
|
216
227
|
/>
|
|
@@ -262,12 +273,15 @@ function ConversationRowsLoading() {
|
|
|
262
273
|
>
|
|
263
274
|
<span className="sr-only">Loading your conversations</span>
|
|
264
275
|
<Skeleton className="mb-1 ml-2.5 mt-1.5 h-2.5 w-12" />
|
|
265
|
-
{Array.from({ length:
|
|
266
|
-
<div
|
|
276
|
+
{Array.from({ length: 10 }, (_, index) => (
|
|
277
|
+
<div
|
|
278
|
+
className="flex items-center justify-between gap-3 rounded-lg py-2 pl-[1.625rem] pr-3"
|
|
279
|
+
key={index}
|
|
280
|
+
>
|
|
267
281
|
<Skeleton
|
|
268
282
|
className={cn("h-4", index % 3 === 0 ? "w-4/5" : "w-3/5")}
|
|
269
283
|
/>
|
|
270
|
-
<Skeleton className="h-3 w-
|
|
284
|
+
<Skeleton className="h-3 w-6 opacity-70" />
|
|
271
285
|
</div>
|
|
272
286
|
))}
|
|
273
287
|
</div>
|
|
@@ -301,11 +315,11 @@ function conversationSidebarEntries(
|
|
|
301
315
|
|
|
302
316
|
const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
|
|
303
317
|
conversation: Conversation;
|
|
304
|
-
finishedSinceSeen: boolean;
|
|
305
318
|
onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
|
|
306
319
|
onArchived(conversation: Conversation): void;
|
|
307
320
|
selected: boolean;
|
|
308
|
-
|
|
321
|
+
unread: boolean;
|
|
322
|
+
waiting?: WaitingConversation;
|
|
309
323
|
}) {
|
|
310
324
|
const archive = useArchiveConversation(props.conversation.id, {
|
|
311
325
|
// Read the attempted direction from the mutation call, not the (possibly
|
|
@@ -321,60 +335,82 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
|
|
|
321
335
|
},
|
|
322
336
|
});
|
|
323
337
|
const status = visualStatusForConversation(props.conversation);
|
|
324
|
-
const location = slackLocationLabel(props.conversation, {
|
|
325
|
-
includeId: false,
|
|
326
|
-
});
|
|
327
338
|
const title = conversationDisplayTitle(props.conversation);
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
// Linked work is denser and more actionable than channel; hide channel when
|
|
331
|
-
// annotations own the meta row. Also skip a location that only restates the
|
|
332
|
-
// title (common for redacted private destinations).
|
|
333
|
-
const showLocation =
|
|
334
|
-
Boolean(location) &&
|
|
335
|
-
!hasAnnotations &&
|
|
336
|
-
location?.toLocaleLowerCase() !== title.toLocaleLowerCase();
|
|
337
|
-
const hasMeta = showLocation || hasAnnotations;
|
|
339
|
+
// Unread and waiting rows read like unread mail: a dot and a bold title.
|
|
340
|
+
const emphasized = props.unread || Boolean(props.waiting);
|
|
338
341
|
return (
|
|
339
|
-
<div
|
|
342
|
+
<div
|
|
343
|
+
className={cn(
|
|
344
|
+
"mobile-conversation-row group relative min-w-0 rounded-lg transition-colors hover:bg-dashboard-fill-hover",
|
|
345
|
+
props.selected && "bg-dashboard-surface-active",
|
|
346
|
+
)}
|
|
347
|
+
>
|
|
340
348
|
<Link
|
|
341
349
|
aria-current={props.selected ? "page" : undefined}
|
|
342
|
-
className=
|
|
343
|
-
"block min-w-0 rounded-lg px-3 py-2 text-inherit no-underline transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus max-sm:pr-10",
|
|
344
|
-
props.selected && "bg-dashboard-surface-active",
|
|
345
|
-
)}
|
|
350
|
+
className="grid min-w-0 grid-cols-[0.375rem_minmax(0,1fr)_auto] items-center gap-x-2 rounded-lg px-3 py-1.5 text-inherit no-underline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus max-sm:pr-10"
|
|
346
351
|
to={conversationPath(props.conversation.id)}
|
|
347
352
|
>
|
|
348
|
-
<
|
|
349
|
-
|
|
350
|
-
<
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
353
|
+
<span className="grid place-items-center">
|
|
354
|
+
{emphasized ? (
|
|
355
|
+
<span
|
|
356
|
+
aria-label={props.waiting ? "Waiting on you" : "Unread"}
|
|
357
|
+
className="size-1.5 shrink-0 rounded-full bg-dashboard-accent"
|
|
358
|
+
role="img"
|
|
359
|
+
/>
|
|
360
|
+
) : props.conversation.visibility === "private" ? (
|
|
361
|
+
<LockKeyhole
|
|
362
|
+
aria-label="Private conversation"
|
|
363
|
+
className="size-3 shrink-0 text-dashboard-text-faint"
|
|
355
364
|
/>
|
|
356
|
-
</div>
|
|
357
|
-
<div className="col-start-2 row-start-1 min-w-0 truncate font-sans text-sm font-medium leading-snug text-dashboard-text">
|
|
358
|
-
{title}
|
|
359
|
-
</div>
|
|
360
|
-
{hasMeta ? (
|
|
361
|
-
<div className="col-start-2 row-start-2 mt-1 flex min-w-0 items-center gap-1.5 font-sans text-xs leading-tight text-dashboard-text-muted">
|
|
362
|
-
{showLocation ? (
|
|
363
|
-
<span className="truncate">{location}</span>
|
|
364
|
-
) : null}
|
|
365
|
-
<ConversationSidebarAnnotations
|
|
366
|
-
annotations={props.conversation.sidebarAnnotations}
|
|
367
|
-
/>
|
|
368
|
-
</div>
|
|
369
365
|
) : null}
|
|
370
|
-
</
|
|
366
|
+
</span>
|
|
367
|
+
<span
|
|
368
|
+
className={cn(
|
|
369
|
+
"min-w-0 truncate font-sans text-sm leading-snug",
|
|
370
|
+
emphasized
|
|
371
|
+
? "font-semibold text-dashboard-text"
|
|
372
|
+
: status === "active"
|
|
373
|
+
? "text-dashboard-text"
|
|
374
|
+
: "text-dashboard-text-muted",
|
|
375
|
+
)}
|
|
376
|
+
>
|
|
377
|
+
{title}
|
|
378
|
+
</span>
|
|
379
|
+
<ConversationRowState
|
|
380
|
+
lastSeenAt={props.conversation.lastSeenAt}
|
|
381
|
+
status={status}
|
|
382
|
+
/>
|
|
371
383
|
</Link>
|
|
384
|
+
{props.waiting ? (
|
|
385
|
+
<Link
|
|
386
|
+
className="mb-1.5 ml-[1.625rem] mr-3 flex min-w-0 items-center gap-1.5 rounded-md border border-dashboard-border-strong bg-dashboard-surface-raised px-2 py-1 font-sans text-xs text-dashboard-text no-underline transition-colors hover:border-dashboard-border-interactive focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus"
|
|
387
|
+
to={conversationPath(props.conversation.id, { reviewItems: true })}
|
|
388
|
+
>
|
|
389
|
+
<Inbox
|
|
390
|
+
aria-hidden="true"
|
|
391
|
+
className="size-3 shrink-0 text-dashboard-text-muted"
|
|
392
|
+
/>
|
|
393
|
+
<span className="min-w-0 truncate">{props.waiting.title}</span>
|
|
394
|
+
{props.waiting.count > 1 ? (
|
|
395
|
+
<span className="ml-auto shrink-0 text-dashboard-text-faint">
|
|
396
|
+
+{props.waiting.count - 1} more
|
|
397
|
+
</span>
|
|
398
|
+
) : null}
|
|
399
|
+
<ChevronRight
|
|
400
|
+
aria-hidden="true"
|
|
401
|
+
className={cn(
|
|
402
|
+
"size-3 shrink-0 text-dashboard-text-muted",
|
|
403
|
+
props.waiting.count > 1 ? undefined : "ml-auto",
|
|
404
|
+
)}
|
|
405
|
+
/>
|
|
406
|
+
</Link>
|
|
407
|
+
) : null}
|
|
372
408
|
<Tooltip>
|
|
373
409
|
<TooltipTrigger
|
|
374
410
|
render={
|
|
375
411
|
<button
|
|
376
412
|
aria-label={`${props.conversation.archivedAt ? "Restore" : "Archive"} ${title}`}
|
|
377
|
-
className="absolute right-1.5 top-
|
|
413
|
+
className="absolute right-1.5 top-0.5 z-10 grid size-7 cursor-pointer place-items-center rounded-md bg-dashboard-surface-active text-dashboard-text-muted transition hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus sm:pointer-events-none sm:opacity-0 sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 disabled:cursor-not-allowed"
|
|
378
414
|
disabled={archive.isPending}
|
|
379
415
|
onClick={() =>
|
|
380
416
|
archive.mutate({
|
|
@@ -400,6 +436,29 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
|
|
|
400
436
|
);
|
|
401
437
|
});
|
|
402
438
|
|
|
439
|
+
/** Show the row age, or the status when the conversation runs or failed. */
|
|
440
|
+
function ConversationRowState(props: {
|
|
441
|
+
lastSeenAt: string;
|
|
442
|
+
status: ReturnType<typeof visualStatusForConversation>;
|
|
443
|
+
}) {
|
|
444
|
+
if (props.status === "active") {
|
|
445
|
+
return (
|
|
446
|
+
<Spinner
|
|
447
|
+
aria-label="Running"
|
|
448
|
+
className="size-3.5 text-dashboard-text-muted"
|
|
449
|
+
/>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (props.status === "failed") {
|
|
453
|
+
return <span className="font-sans text-xs text-rose-400">Failed</span>;
|
|
454
|
+
}
|
|
455
|
+
return (
|
|
456
|
+
<span className="font-sans text-xs tabular-nums text-dashboard-text-faint">
|
|
457
|
+
{formatCompactAge(props.lastSeenAt)}
|
|
458
|
+
</span>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
403
462
|
function ArchiveConversationErrorNotice(props: {
|
|
404
463
|
conversation: Conversation;
|
|
405
464
|
onDismiss(): void;
|
|
@@ -24,7 +24,10 @@ import { buildConversations, getDashboardTimeZone } from "../format";
|
|
|
24
24
|
import { useDebouncedValue } from "../debouncedValue";
|
|
25
25
|
import type { Conversation } from "../types";
|
|
26
26
|
import { ConversationPage } from "./ConversationPage";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
useWaitingConversationIds,
|
|
29
|
+
useWaitingConversations,
|
|
30
|
+
} from "../review-desk/queries";
|
|
28
31
|
import { UnsentAnswersProvider } from "../review-desk/UnsentAnswers";
|
|
29
32
|
import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";
|
|
30
33
|
|
|
@@ -56,8 +59,9 @@ export function ConversationWorkspace() {
|
|
|
56
59
|
if (selectedId) navigate(NEW_CONVERSATION_PATH);
|
|
57
60
|
};
|
|
58
61
|
|
|
62
|
+
const waitingConversations = useWaitingConversations();
|
|
59
63
|
const waitingConversationIds = useWaitingConversationIds();
|
|
60
|
-
const { finishedConversationIds, markRead } =
|
|
64
|
+
const { finishedConversationIds, heldConversationId, markRead } =
|
|
61
65
|
useConversationFinishedIndicators(
|
|
62
66
|
conversations,
|
|
63
67
|
selectedId,
|
|
@@ -149,13 +153,14 @@ export function ConversationWorkspace() {
|
|
|
149
153
|
conversations={conversations}
|
|
150
154
|
error={feed.error?.message}
|
|
151
155
|
finishedConversationIds={finishedConversationIds}
|
|
156
|
+
heldConversationId={heldConversationId}
|
|
152
157
|
loading={feed.isPending}
|
|
153
158
|
onNewConversation={openCreate}
|
|
154
159
|
onQueryChange={setQuery}
|
|
155
160
|
query={query}
|
|
156
161
|
selectedId={selectedId}
|
|
157
162
|
timeZone={getDashboardTimeZone()}
|
|
158
|
-
|
|
163
|
+
waitingConversations={waitingConversations}
|
|
159
164
|
/>
|
|
160
165
|
</div>
|
|
161
166
|
<section
|