@sentry/junior-dashboard 0.277.0 → 0.278.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 +30 -41
- package/dist/client.js +90 -90
- package/dist/index.js +30 -41
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/app.ts +1 -1
- package/src/client/App.tsx +9 -4
- package/src/client/components/DetailsPanel.tsx +1 -1
- package/src/client/components/ui/resizable.tsx +50 -0
- package/src/client/conversations/AutoArchiveToggle.tsx +50 -14
- package/src/client/conversations/ConversationHeader.tsx +6 -2
- package/src/client/conversations/ConversationHeaderActions.tsx +4 -0
- package/src/client/conversations/ConversationMeta.tsx +2 -7
- package/src/client/conversations/ConversationPage.tsx +155 -72
- package/src/client/conversations/ConversationSidebar.tsx +2 -1
- package/src/client/conversations/ConversationTranscript.tsx +11 -8
- package/src/client/conversations/ConversationWorkspace.tsx +129 -41
- package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
- package/src/client/conversations/EventDetails.tsx +2 -62
- package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
- package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
- package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
- package/src/client/conversations/TranscriptView.tsx +89 -20
- package/src/client/conversations/conversationSidebarLayout.ts +61 -0
- package/src/client/conversations/eventLog.ts +2 -31
- package/src/client/conversations/eventTranscript.ts +9 -42
- package/src/client/conversations/queries.ts +76 -15
- package/src/client/conversations/transcript.ts +46 -16
- package/src/client/conversations/transcriptBottomPinning.ts +40 -24
- package/src/client/conversations/transcriptReadPosition.ts +31 -7
- package/src/client/conversations/transcriptRenderModel.ts +1 -14
- package/src/client/conversations/transcriptSearch.tsx +3 -11
- package/src/client/conversations/transcriptUnreadHistory.ts +248 -0
- package/src/client/http.ts +5 -5
- package/src/client/markdownExport.ts +32 -42
- package/src/client/pages/memory/MemoryDetails.tsx +6 -33
- package/src/client/pages/memory/MemoryPage.tsx +7 -92
- package/src/client/pages/memory/MemoryTimeline.tsx +16 -32
- package/src/client/pages/memory/memoryDashboard.ts +2 -38
- package/src/client/pages/memory/memoryRecord.ts +1 -6
- package/src/client/types.ts +18 -33
- package/src/conversation-cache.ts +6 -8
- package/src/mock-reporting/fixtures.ts +23 -33
- package/src/mock-reporting/routes.ts +3 -1
- package/src/tailwind.css +30 -0
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
useRef,
|
|
6
6
|
useState,
|
|
7
7
|
useSyncExternalStore,
|
|
8
|
+
type ReactNode,
|
|
8
9
|
} from "react";
|
|
9
10
|
import { useSearchParams } from "react-router";
|
|
10
11
|
import type {
|
|
@@ -12,11 +13,11 @@ import type {
|
|
|
12
13
|
ConversationFeed,
|
|
13
14
|
} from "@sentry/junior/api/schema";
|
|
14
15
|
|
|
16
|
+
import type { ConversationData } from "./queries";
|
|
15
17
|
import {
|
|
16
18
|
useArchiveConversation,
|
|
17
19
|
type PendingArchiveConversationUpdate,
|
|
18
20
|
} from "./conversationArchive";
|
|
19
|
-
import { useConversationData } from "./queries";
|
|
20
21
|
import { usePinConversation } from "./usePinConversation";
|
|
21
22
|
import { conversationIsResponding } from "./transcript";
|
|
22
23
|
import { buildConversationMarkdown } from "../markdownExport";
|
|
@@ -97,6 +98,10 @@ import {
|
|
|
97
98
|
useReadPositionAtOpen,
|
|
98
99
|
} from "./transcriptReadPosition";
|
|
99
100
|
import { TranscriptLoading } from "./TranscriptLoading";
|
|
101
|
+
import {
|
|
102
|
+
useUnreadHistory,
|
|
103
|
+
type TranscriptHistory,
|
|
104
|
+
} from "./transcriptUnreadHistory";
|
|
100
105
|
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
101
106
|
import {
|
|
102
107
|
SubagentTranscriptDrawer,
|
|
@@ -122,8 +127,14 @@ function subscribeToDetailsLayout(onChange: () => void) {
|
|
|
122
127
|
export function ConversationPage(props: {
|
|
123
128
|
conversationId: string;
|
|
124
129
|
data?: { conversations: ConversationFeed };
|
|
130
|
+
/**
|
|
131
|
+
* This Conversation's data. The shell fetches it, so a direct load does
|
|
132
|
+
* not wait for the dashboard core reads, and each visit fetches it once.
|
|
133
|
+
*/
|
|
134
|
+
detail: ConversationData;
|
|
125
135
|
onRead?(conversationId: string, lastReadAt: string): void;
|
|
126
136
|
pendingArchiveUpdate?: PendingArchiveConversationUpdate;
|
|
137
|
+
sidebarToggle?: ReactNode;
|
|
127
138
|
}) {
|
|
128
139
|
const detailsDefaultOpen = useSyncExternalStore(
|
|
129
140
|
subscribeToDetailsLayout,
|
|
@@ -198,7 +209,15 @@ export function ConversationPage(props: {
|
|
|
198
209
|
const onRead = props.onRead;
|
|
199
210
|
const summaries = props.data?.conversations.conversations ?? [];
|
|
200
211
|
const conversations = buildConversations(summaries);
|
|
201
|
-
|
|
212
|
+
// The page keeps the start preference from when the Conversation opened.
|
|
213
|
+
const [startAtUnread] = useState(
|
|
214
|
+
() => readConversationStartPosition() === "unread",
|
|
215
|
+
);
|
|
216
|
+
const detail = props.detail;
|
|
217
|
+
// Jump to latest, a send, or the mobile keyboard skips the newer pages for
|
|
218
|
+
// the rest of the visit. It can come before the unread page loads.
|
|
219
|
+
const [showLatest, setShowLatest] = useState(false);
|
|
220
|
+
const opensAtUnread = startAtUnread && !showLatest;
|
|
202
221
|
const memoryList = useConversationMemories(conversationId).data;
|
|
203
222
|
const memoryState = useMemo<ConversationMemoryState>(
|
|
204
223
|
() => ({
|
|
@@ -212,6 +231,38 @@ export function ConversationPage(props: {
|
|
|
212
231
|
const pin = usePinConversation(conversationId);
|
|
213
232
|
const markRead = useMarkConversationRead(conversationId);
|
|
214
233
|
const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
|
|
234
|
+
const unreadHistory = useUnreadHistory({
|
|
235
|
+
conversationId,
|
|
236
|
+
// Cached earlier pages from a past visit would look like newer events.
|
|
237
|
+
detail: detail.latest,
|
|
238
|
+
onShowLatest: () => setShowLatest(true),
|
|
239
|
+
unreadCursor: opensAtUnread ? readAtOpen.unreadCursor : undefined,
|
|
240
|
+
unreadPage: readAtOpen.unreadPage,
|
|
241
|
+
});
|
|
242
|
+
// The page is keyed by Conversation, so this is the cached copy at open.
|
|
243
|
+
const [cachedAtOpen] = useState(() => detail.data);
|
|
244
|
+
// A reader far behind sees the events from their read position. Others
|
|
245
|
+
// see the latest page and the pages before it. A cached transcript stays
|
|
246
|
+
// on screen until this visit's fetch tells where to open and the unread
|
|
247
|
+
// events load. It loads no earlier pages and shows no marker: both would
|
|
248
|
+
// come from the cached read position. It reports no reads, because it sits
|
|
249
|
+
// at rows the reader has not read yet. Then the transcript opens at the
|
|
250
|
+
// "New" marker, if the reader has not moved.
|
|
251
|
+
const holdCachedTranscript = Boolean(
|
|
252
|
+
cachedAtOpen &&
|
|
253
|
+
(unreadHistory?.isPending || (opensAtUnread && !detail.fetchedAtOpen)),
|
|
254
|
+
);
|
|
255
|
+
const history: TranscriptHistory = holdCachedTranscript
|
|
256
|
+
? {
|
|
257
|
+
...detail,
|
|
258
|
+
data: cachedAtOpen,
|
|
259
|
+
hasPreviousPage: false,
|
|
260
|
+
isLoadingPreviousPage: false,
|
|
261
|
+
loadPreviousPage: () => {},
|
|
262
|
+
}
|
|
263
|
+
: (unreadHistory ?? detail);
|
|
264
|
+
const transcriptPending =
|
|
265
|
+
detail.isPending || Boolean(unreadHistory?.isPending && !cachedAtOpen);
|
|
215
266
|
const feedConversation = conversations.find(
|
|
216
267
|
(item) => item.id === conversationId,
|
|
217
268
|
);
|
|
@@ -234,7 +285,7 @@ export function ConversationPage(props: {
|
|
|
234
285
|
if (!conversation) return;
|
|
235
286
|
onRead?.(conversation.id, conversation.lastSeenAt);
|
|
236
287
|
}, [conversation, onRead]);
|
|
237
|
-
const transcript =
|
|
288
|
+
const transcript = history.data;
|
|
238
289
|
const selectedEvent =
|
|
239
290
|
transcript?.eventHistory.status === "expired"
|
|
240
291
|
? undefined
|
|
@@ -286,27 +337,31 @@ export function ConversationPage(props: {
|
|
|
286
337
|
},
|
|
287
338
|
[eventOpen, setSelectedEventSeq],
|
|
288
339
|
);
|
|
289
|
-
// A linked event can be
|
|
290
|
-
// it
|
|
291
|
-
const
|
|
292
|
-
|
|
340
|
+
// A linked event, Turn, or memory can be outside the loaded pages. Look
|
|
341
|
+
// for it one page at a time, in the direction where it can be.
|
|
342
|
+
const historyRef = useRef(history);
|
|
343
|
+
historyRef.current = history;
|
|
344
|
+
const loadToward = useCallback((search: HistorySearch | undefined) => {
|
|
345
|
+
if (search === "load-earlier") historyRef.current.loadPreviousPage();
|
|
346
|
+
else if (search === "load-newer") historyRef.current.newer?.load();
|
|
347
|
+
}, []);
|
|
348
|
+
const searchHistory = (direction: HistorySearchDirection) =>
|
|
349
|
+
nextHistorySearch(history, direction, holdCachedTranscript);
|
|
293
350
|
const oldestLoadedSeq = transcript?.events[0]?.seq;
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
// history has no such event. A history error keeps the link, so a retry
|
|
309
|
-
// or reload can still find the Turn.
|
|
351
|
+
const newestLoadedSeq = transcript?.events.at(-1)?.seq;
|
|
352
|
+
const linkedEventDirection: HistorySearchDirection | undefined =
|
|
353
|
+
selectedEventSeq === undefined ||
|
|
354
|
+
oldestLoadedSeq === undefined ||
|
|
355
|
+
newestLoadedSeq === undefined
|
|
356
|
+
? undefined
|
|
357
|
+
: selectedEventSeq < oldestLoadedSeq
|
|
358
|
+
? "earlier"
|
|
359
|
+
: selectedEventSeq > newestLoadedSeq &&
|
|
360
|
+
(history.newer || holdCachedTranscript)
|
|
361
|
+
? "newer"
|
|
362
|
+
: undefined;
|
|
363
|
+
// Replace a found Turn link with a link to its first event, or drop it
|
|
364
|
+
// when history has no such event.
|
|
310
365
|
const linkedTurnSeq = linkedTurnId
|
|
311
366
|
? turnStartedEventSeq(transcript?.events, linkedTurnId)
|
|
312
367
|
: undefined;
|
|
@@ -314,24 +369,21 @@ export function ConversationPage(props: {
|
|
|
314
369
|
linkedTurnId !== undefined &&
|
|
315
370
|
linkedTurnSeq === undefined &&
|
|
316
371
|
transcript !== undefined;
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
!detail.hasPreviousPage &&
|
|
325
|
-
!detail.isLoadingPreviousPage;
|
|
326
|
-
useEffect(() => {
|
|
327
|
-
if (loadLinkedTurn) loadPreviousPageRef.current();
|
|
328
|
-
}, [loadLinkedTurn, oldestLoadedSeq]);
|
|
372
|
+
const linkedSearch = linkedEventDirection
|
|
373
|
+
? searchHistory(linkedEventDirection)
|
|
374
|
+
: linkedTurnMissing
|
|
375
|
+
? searchHistory("any")
|
|
376
|
+
: undefined;
|
|
377
|
+
useEffect(() => loadToward(linkedSearch), [linkedSearch, loadToward]);
|
|
378
|
+
const linkedTurnNotFound = linkedTurnMissing && linkedSearch === "end";
|
|
329
379
|
useEffect(() => {
|
|
330
380
|
if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq);
|
|
331
381
|
else if (linkedTurnNotFound) setSelectedEventSeq(undefined);
|
|
332
382
|
}, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]);
|
|
333
383
|
// Show a chosen memory at its transcript row. Wait for the first page, then
|
|
334
|
-
// load
|
|
384
|
+
// load pages toward the row until it renders, or stop when history ends.
|
|
385
|
+
const memorySearch =
|
|
386
|
+
memoryJump && transcript ? searchHistory("any") : undefined;
|
|
335
387
|
useEffect(() => {
|
|
336
388
|
if (!memoryJump || !transcript) return;
|
|
337
389
|
const anchor = pageRef.current?.querySelector(
|
|
@@ -341,33 +393,22 @@ export function ConversationPage(props: {
|
|
|
341
393
|
anchor.scrollIntoView({ block: "center" });
|
|
342
394
|
setMemoryPopover(memoryJump);
|
|
343
395
|
setMemoryJump(undefined);
|
|
344
|
-
} else if (
|
|
345
|
-
detail.hasPreviousPage &&
|
|
346
|
-
!detail.isLoadingPreviousPage &&
|
|
347
|
-
!detail.historyError
|
|
348
|
-
) {
|
|
349
|
-
loadPreviousPageRef.current();
|
|
350
|
-
} else if (!detail.isLoadingPreviousPage) {
|
|
396
|
+
} else if (memorySearch === "failed" || memorySearch === "end") {
|
|
351
397
|
setMemoryJump(undefined);
|
|
398
|
+
} else {
|
|
399
|
+
loadToward(memorySearch);
|
|
352
400
|
}
|
|
353
|
-
}, [
|
|
354
|
-
detail.hasPreviousPage,
|
|
355
|
-
detail.historyError,
|
|
356
|
-
detail.isLoadingPreviousPage,
|
|
357
|
-
memoryJump,
|
|
358
|
-
transcript,
|
|
359
|
-
view,
|
|
360
|
-
]);
|
|
401
|
+
}, [loadToward, memoryJump, memorySearch, transcript, view]);
|
|
361
402
|
// Keep the panel while the transcript or a linked event loads, and for the
|
|
362
403
|
// render before a found Turn link becomes an event link.
|
|
363
404
|
const eventLoading =
|
|
364
405
|
eventOpen &&
|
|
365
406
|
!selectedEvent &&
|
|
366
|
-
(
|
|
407
|
+
(transcriptPending ||
|
|
367
408
|
linkedTurnSeq !== undefined ||
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
409
|
+
linkedSearch === "load-earlier" ||
|
|
410
|
+
linkedSearch === "load-newer" ||
|
|
411
|
+
linkedSearch === "wait");
|
|
371
412
|
const showEvent = Boolean(selectedEvent) || eventLoading;
|
|
372
413
|
// Step through the same filtered rows that the event log shows.
|
|
373
414
|
const events = transcript?.events;
|
|
@@ -390,6 +431,7 @@ export function ConversationPage(props: {
|
|
|
390
431
|
[turnContext?.id],
|
|
391
432
|
);
|
|
392
433
|
const requestPin = useCallback(() => {
|
|
434
|
+
setShowLatest(true);
|
|
393
435
|
setPinRequestVersion((version) => version + 1);
|
|
394
436
|
}, []);
|
|
395
437
|
const onOpenSubagentTranscript = useCallback(
|
|
@@ -514,15 +556,17 @@ export function ConversationPage(props: {
|
|
|
514
556
|
}
|
|
515
557
|
search={search}
|
|
516
558
|
sentryConversationUrl={detail.data?.sentryConversationUrl}
|
|
559
|
+
sidebarToggle={props.sidebarToggle}
|
|
517
560
|
title={title}
|
|
518
561
|
view={view}
|
|
519
562
|
/>
|
|
520
563
|
}
|
|
521
564
|
scrollMinTall
|
|
522
565
|
scrollAriaLabel="Conversation transcript"
|
|
523
|
-
scrollClassName={
|
|
524
|
-
|
|
525
|
-
|
|
566
|
+
scrollClassName={cn(
|
|
567
|
+
"thin-scrollbar",
|
|
568
|
+
view !== "raw" && "px-4 pb-4 md:px-7 md:pb-6",
|
|
569
|
+
)}
|
|
526
570
|
scroll={
|
|
527
571
|
<section className="min-w-0">
|
|
528
572
|
<div
|
|
@@ -532,7 +576,7 @@ export function ConversationPage(props: {
|
|
|
532
576
|
: "mx-auto w-full max-w-[52.5rem] pt-5 md:pt-7"
|
|
533
577
|
}
|
|
534
578
|
>
|
|
535
|
-
{
|
|
579
|
+
{transcriptPending ? (
|
|
536
580
|
view === "raw" ? (
|
|
537
581
|
<ConversationEventLogLoading />
|
|
538
582
|
) : (
|
|
@@ -552,30 +596,36 @@ export function ConversationPage(props: {
|
|
|
552
596
|
) : null}
|
|
553
597
|
<ConversationMemoryContext value={memoryState}>
|
|
554
598
|
<Transcript
|
|
555
|
-
hasPreviousPage={
|
|
556
|
-
historyError={
|
|
557
|
-
historyVersion={
|
|
599
|
+
hasPreviousPage={history.hasPreviousPage}
|
|
600
|
+
historyError={history.historyError}
|
|
601
|
+
historyVersion={history.historyVersion}
|
|
558
602
|
lastReadSeq={readAtOpen.lastReadSeq}
|
|
559
603
|
live={live}
|
|
560
|
-
loadingPreviousPage={
|
|
561
|
-
|
|
562
|
-
|
|
604
|
+
loadingPreviousPage={history.isLoadingPreviousPage}
|
|
605
|
+
newerHistory={history.newer}
|
|
606
|
+
onLoadPreviousPage={history.loadPreviousPage}
|
|
607
|
+
onRead={holdCachedTranscript ? undefined : markRead}
|
|
563
608
|
onSelectedEventSeqChange={setSelectedEventSeq}
|
|
564
|
-
|
|
609
|
+
// Sends and the thinking row belong after the latest event.
|
|
610
|
+
pendingMessages={
|
|
611
|
+
history.newer ? undefined : detail.inlineMessages
|
|
612
|
+
}
|
|
565
613
|
pinRequestVersion={pinRequestVersion}
|
|
566
614
|
responding={
|
|
567
|
-
!detail.error &&
|
|
615
|
+
!detail.error &&
|
|
616
|
+
!history.newer &&
|
|
617
|
+
conversationIsResponding(detail.data)
|
|
568
618
|
}
|
|
569
619
|
onOpenSubagentTranscript={onOpenSubagentTranscript}
|
|
570
620
|
search={search}
|
|
571
621
|
onSearchChange={setSearch}
|
|
572
622
|
selectedEventSeq={selectedEventSeq}
|
|
573
|
-
startAtUnread={
|
|
574
|
-
readConversationStartPosition() === "unread"
|
|
575
|
-
}
|
|
623
|
+
startAtUnread={startAtUnread}
|
|
576
624
|
transcript={transcript}
|
|
577
625
|
turnContextPanel={turnContextPanel}
|
|
578
|
-
unread={
|
|
626
|
+
unread={
|
|
627
|
+
holdCachedTranscript ? undefined : readAtOpen.unread
|
|
628
|
+
}
|
|
579
629
|
view={view}
|
|
580
630
|
/>
|
|
581
631
|
</ConversationMemoryContext>
|
|
@@ -604,9 +654,12 @@ export function ConversationPage(props: {
|
|
|
604
654
|
// Without live history, an inline send would never leave the
|
|
605
655
|
// transcript tail. A started Turn reads a new send only later.
|
|
606
656
|
// Keep those sends in the queue. An earlier inline send that
|
|
607
|
-
// history does not have yet can start that Turn first.
|
|
657
|
+
// history does not have yet can start that Turn first. While
|
|
658
|
+
// newer pages are not loaded, the transcript tail is not the
|
|
659
|
+
// latest event, so a send waits in the queue too.
|
|
608
660
|
sendsInline={
|
|
609
661
|
detail.data.eventHistory.status === "available" &&
|
|
662
|
+
!history.newer &&
|
|
610
663
|
!conversationIsResponding(detail.data) &&
|
|
611
664
|
detail.inlineMessages.length === 0
|
|
612
665
|
}
|
|
@@ -753,3 +806,33 @@ function committedMessageIds(
|
|
|
753
806
|
}
|
|
754
807
|
|
|
755
808
|
const EMPTY_MESSAGE_IDS: readonly string[] = [];
|
|
809
|
+
|
|
810
|
+
type HistorySearchDirection = "any" | "earlier" | "newer";
|
|
811
|
+
type HistorySearch = "end" | "failed" | "load-earlier" | "load-newer" | "wait";
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* The next step to find a row that the loaded pages do not have: load one
|
|
815
|
+
* more page toward it, or wait for the page that loads. "any" looks at newer
|
|
816
|
+
* pages first. A failed page stops the search, but the link stays, so a
|
|
817
|
+
* retry can still find the row.
|
|
818
|
+
*/
|
|
819
|
+
function nextHistorySearch(
|
|
820
|
+
history: TranscriptHistory,
|
|
821
|
+
direction: HistorySearchDirection,
|
|
822
|
+
held: boolean,
|
|
823
|
+
): HistorySearch {
|
|
824
|
+
if (held || history.isLoadingPreviousPage || history.newer?.loading) {
|
|
825
|
+
return "wait";
|
|
826
|
+
}
|
|
827
|
+
if (direction !== "earlier" && history.newer && !history.newer.error) {
|
|
828
|
+
return "load-newer";
|
|
829
|
+
}
|
|
830
|
+
if (
|
|
831
|
+
direction !== "newer" &&
|
|
832
|
+
history.hasPreviousPage &&
|
|
833
|
+
!history.historyError
|
|
834
|
+
) {
|
|
835
|
+
return "load-earlier";
|
|
836
|
+
}
|
|
837
|
+
return history.historyError || history.newer?.error ? "failed" : "end";
|
|
838
|
+
}
|
|
@@ -191,6 +191,7 @@ export function ConversationSidebar(props: {
|
|
|
191
191
|
</div>
|
|
192
192
|
<div className="px-4 pb-2">
|
|
193
193
|
<SearchInput
|
|
194
|
+
className="border-dashboard-border-subtle bg-transparent hover:border-dashboard-border hover:bg-dashboard-fill-faint"
|
|
194
195
|
label="Search your conversations"
|
|
195
196
|
onChange={props.onQueryChange}
|
|
196
197
|
placeholder="Search conversations…"
|
|
@@ -203,7 +204,7 @@ export function ConversationSidebar(props: {
|
|
|
203
204
|
"px-3",
|
|
204
205
|
isLanding
|
|
205
206
|
? "pb-[max(1rem,env(safe-area-inset-bottom))]"
|
|
206
|
-
: "min-h-0 overflow-y-auto overscroll-contain pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
|
207
|
+
: "thin-scrollbar min-h-0 overflow-y-auto overscroll-contain pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
|
207
208
|
)}
|
|
208
209
|
>
|
|
209
210
|
{props.error ? (
|
|
@@ -22,6 +22,7 @@ import { TranscriptSubagentView } from "./TranscriptSubagentView";
|
|
|
22
22
|
import { TranscriptContextEventView } from "./TranscriptContextEventView";
|
|
23
23
|
import {
|
|
24
24
|
isCollapsibleActivityEntry,
|
|
25
|
+
quietTurnEntryKeys,
|
|
25
26
|
TranscriptActivityGroup,
|
|
26
27
|
} from "./TranscriptActivityGroup";
|
|
27
28
|
import { TranscriptToolView } from "./TranscriptToolView";
|
|
@@ -232,9 +233,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
|
|
|
232
233
|
keyPrefix={props.conversation.conversationId}
|
|
233
234
|
unread={props.unread}
|
|
234
235
|
renderContext={(entry) => (
|
|
235
|
-
<TranscriptRailEvent
|
|
236
|
-
kind={entry.part.event.type === "handoff" ? "handoff" : "compaction"}
|
|
237
|
-
>
|
|
236
|
+
<TranscriptRailEvent kind="compaction">
|
|
238
237
|
<TranscriptContextEventView
|
|
239
238
|
part={entry.part}
|
|
240
239
|
timestamp={entry.timestamp}
|
|
@@ -359,6 +358,12 @@ function TranscriptEntryList(props: {
|
|
|
359
358
|
}) {
|
|
360
359
|
const search = useTranscriptSearch();
|
|
361
360
|
const activityKeys = useRef(new Map<string, string>());
|
|
361
|
+
const quietKeys = useMemo(
|
|
362
|
+
() => quietTurnEntryKeys(props.entries),
|
|
363
|
+
[props.entries],
|
|
364
|
+
);
|
|
365
|
+
const collapsible = (entry: TranscriptEntry) =>
|
|
366
|
+
isCollapsibleActivityEntry(entry) || quietKeys.has(entry.key);
|
|
362
367
|
const claimedActivityKeys = new Set<string>();
|
|
363
368
|
const rows: ReactNode[] = [];
|
|
364
369
|
// Search results are not a reading order, so they get no marker.
|
|
@@ -394,11 +399,11 @@ function TranscriptEntryList(props: {
|
|
|
394
399
|
for (let index = 0; index < props.entries.length; ) {
|
|
395
400
|
const entry = props.entries[index]!;
|
|
396
401
|
|
|
397
|
-
if (
|
|
402
|
+
if (collapsible(entry)) {
|
|
398
403
|
const activityEntries: RenderedTranscriptEntry[] = [];
|
|
399
404
|
while (
|
|
400
405
|
index < props.entries.length &&
|
|
401
|
-
|
|
406
|
+
collapsible(props.entries[index]!)
|
|
402
407
|
) {
|
|
403
408
|
activityEntries.push(props.entries[index]!);
|
|
404
409
|
index += 1;
|
|
@@ -523,9 +528,7 @@ function RedactedTranscriptView(props: {
|
|
|
523
528
|
entries={groupTranscriptMessages(props.messages)}
|
|
524
529
|
keyPrefix={`${props.conversation.conversationId}:redacted`}
|
|
525
530
|
renderContext={(entry) => (
|
|
526
|
-
<TranscriptRailEvent
|
|
527
|
-
kind={entry.part.event.type === "handoff" ? "handoff" : "compaction"}
|
|
528
|
-
>
|
|
531
|
+
<TranscriptRailEvent kind="compaction">
|
|
529
532
|
<TranscriptContextEventView
|
|
530
533
|
part={entry.part}
|
|
531
534
|
timestamp={entry.timestamp}
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import type { InputImage } from "@sentry/junior/api/schema";
|
|
2
|
-
import { useEffect, useMemo, useState } from "react";
|
|
3
|
-
import { Globe2, LockKeyhole } from "lucide-react";
|
|
2
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Globe2, LockKeyhole, PanelLeft } from "lucide-react";
|
|
4
4
|
import { useNavigate, useParams } from "react-router";
|
|
5
|
+
import type { PanelImperativeHandle } from "react-resizable-panels";
|
|
5
6
|
|
|
6
7
|
import { useConversationsData } from "../api";
|
|
7
8
|
import { ConversationSidebar } from "./ConversationSidebar";
|
|
8
9
|
import { SearchInput } from "../components/SearchInput";
|
|
10
|
+
import {
|
|
11
|
+
ResizableHandle,
|
|
12
|
+
ResizablePanel,
|
|
13
|
+
ResizablePanelGroup,
|
|
14
|
+
} from "../components/ui/resizable";
|
|
9
15
|
import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
|
|
10
16
|
import { pageCount, pageItems, PagePagination } from "../components/Pagination";
|
|
11
17
|
import { ConversationHomeList } from "./ConversationHomeList";
|
|
12
18
|
import { ConversationComposer } from "./ConversationComposer";
|
|
19
|
+
import { HeaderIconButton } from "./ConversationHeaderActions";
|
|
13
20
|
import {
|
|
14
21
|
usePendingArchiveConversationUpdates,
|
|
15
22
|
type PendingArchiveConversationUpdate,
|
|
16
23
|
} from "./conversationArchive";
|
|
17
|
-
import { useCreateConversation } from "./queries";
|
|
24
|
+
import { useCreateConversation, type ConversationData } from "./queries";
|
|
18
25
|
import { conversationPath, NEW_CONVERSATION_PATH } from "./conversationRoutes";
|
|
19
26
|
import {
|
|
20
27
|
attentionConversationIds,
|
|
@@ -22,6 +29,7 @@ import {
|
|
|
22
29
|
} from "./conversationSections";
|
|
23
30
|
import { buildConversations, getDashboardTimeZone } from "../format";
|
|
24
31
|
import { useDebouncedValue } from "../debouncedValue";
|
|
32
|
+
import { cn } from "../styles";
|
|
25
33
|
import type { Conversation } from "../types";
|
|
26
34
|
import { ConversationPage } from "./ConversationPage";
|
|
27
35
|
import {
|
|
@@ -30,13 +38,29 @@ import {
|
|
|
30
38
|
} from "../review-desk/queries";
|
|
31
39
|
import { UnsentAnswersProvider } from "../review-desk/UnsentAnswers";
|
|
32
40
|
import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";
|
|
41
|
+
import {
|
|
42
|
+
readSidebarWidth,
|
|
43
|
+
saveSidebarWidth,
|
|
44
|
+
SIDEBAR_WIDTH,
|
|
45
|
+
useConversationSidebarOpen,
|
|
46
|
+
useSidebarFits,
|
|
47
|
+
} from "./conversationSidebarLayout";
|
|
33
48
|
|
|
34
49
|
const CONVERSATION_PAGE_SIZE = 20;
|
|
50
|
+
const SIDEBAR_PANEL_ID = "conversation-list";
|
|
35
51
|
|
|
36
52
|
/** Render the conversation home page or one selected conversation. */
|
|
37
|
-
export function ConversationWorkspace(
|
|
53
|
+
export function ConversationWorkspace(props: {
|
|
54
|
+
/** The selected Conversation's data, from the shell. */
|
|
55
|
+
detail: ConversationData;
|
|
56
|
+
}) {
|
|
38
57
|
const [page, setPage] = useState(1);
|
|
39
58
|
const [query, setQuery] = useState("");
|
|
59
|
+
const [sidebarOpen, setSidebarOpen] = useConversationSidebarOpen();
|
|
60
|
+
const sidebarFits = useSidebarFits();
|
|
61
|
+
const sidebarPanel = useRef<PanelImperativeHandle>(null);
|
|
62
|
+
const sidebarGroup = useRef<HTMLDivElement>(null);
|
|
63
|
+
const sidebarId = useId();
|
|
40
64
|
const search = useDebouncedValue(query.trim());
|
|
41
65
|
const params = useParams();
|
|
42
66
|
const navigate = useNavigate();
|
|
@@ -146,45 +170,109 @@ export function ConversationWorkspace() {
|
|
|
146
170
|
);
|
|
147
171
|
}
|
|
148
172
|
|
|
173
|
+
const toggleSidebar = () => {
|
|
174
|
+
const panel = sidebarPanel.current;
|
|
175
|
+
if (!panel) return;
|
|
176
|
+
if (panel.isCollapsed()) panel.resize(readSidebarWidth());
|
|
177
|
+
else panel.collapse();
|
|
178
|
+
};
|
|
179
|
+
|
|
149
180
|
return (
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
<ResizablePanelGroup
|
|
182
|
+
className="min-h-0 overflow-hidden bg-dashboard-bg"
|
|
183
|
+
elementRef={sidebarGroup}
|
|
184
|
+
onLayoutChanged={(layout) => {
|
|
185
|
+
// Save after the drag ends. A drag that collapses the list passes
|
|
186
|
+
// the minimum width first, and that width is not the reader's pick.
|
|
187
|
+
const percent = layout[SIDEBAR_PANEL_ID] ?? 0;
|
|
188
|
+
const groupWidth = sidebarGroup.current?.offsetWidth ?? 0;
|
|
189
|
+
if (percent > 0) saveSidebarWidth((percent / 100) * groupWidth);
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
{sidebarFits ? (
|
|
193
|
+
<>
|
|
194
|
+
<ResizablePanel
|
|
195
|
+
collapsible
|
|
196
|
+
defaultSize={sidebarOpen ? readSidebarWidth() : 0}
|
|
197
|
+
groupResizeBehavior="preserve-pixel-size"
|
|
198
|
+
id={SIDEBAR_PANEL_ID}
|
|
199
|
+
maxSize={SIDEBAR_WIDTH.max}
|
|
200
|
+
minSize={SIDEBAR_WIDTH.min}
|
|
201
|
+
onResize={(size) => {
|
|
202
|
+
// Dragging past the minimum width collapses the list too, so the
|
|
203
|
+
// panel size is the one source for the open state.
|
|
204
|
+
const open = size.inPixels > 0;
|
|
205
|
+
if (open !== sidebarOpen) setSidebarOpen(open);
|
|
206
|
+
}}
|
|
207
|
+
panelRef={sidebarPanel}
|
|
208
|
+
>
|
|
209
|
+
<div
|
|
210
|
+
className={cn(
|
|
211
|
+
"h-full min-h-0 overflow-hidden",
|
|
212
|
+
!sidebarOpen && "hidden",
|
|
213
|
+
)}
|
|
214
|
+
id={sidebarId}
|
|
215
|
+
>
|
|
216
|
+
<ConversationSidebar
|
|
217
|
+
conversations={conversations}
|
|
218
|
+
error={feed.error?.message}
|
|
219
|
+
finishedConversationIds={finishedConversationIds}
|
|
220
|
+
heldConversationId={heldConversationId}
|
|
221
|
+
loading={feed.isPending}
|
|
222
|
+
onNewConversation={openCreate}
|
|
223
|
+
onQueryChange={setQuery}
|
|
224
|
+
query={query}
|
|
225
|
+
selectedId={selectedId}
|
|
226
|
+
timeZone={getDashboardTimeZone()}
|
|
227
|
+
waitingConversations={waitingConversations}
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
</ResizablePanel>
|
|
231
|
+
<ResizableHandle
|
|
232
|
+
aria-label="Resize conversation list"
|
|
233
|
+
className="w-px bg-transparent transition-colors after:w-2 focus-visible:ring-dashboard-border-interactive data-[separator=active]:bg-dashboard-border-interactive data-[separator=hover]:bg-dashboard-border-interactive"
|
|
184
234
|
/>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
235
|
+
</>
|
|
236
|
+
) : null}
|
|
237
|
+
<ResizablePanel id="selected-conversation">
|
|
238
|
+
<section
|
|
239
|
+
aria-label="Selected conversation"
|
|
240
|
+
className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-dashboard-bg"
|
|
241
|
+
>
|
|
242
|
+
<UnsentAnswersProvider conversationId={selectedId} key={selectedId}>
|
|
243
|
+
<ConversationPage
|
|
244
|
+
conversationId={selectedId}
|
|
245
|
+
detail={props.detail}
|
|
246
|
+
data={
|
|
247
|
+
feed.data
|
|
248
|
+
? {
|
|
249
|
+
conversations: feed.data,
|
|
250
|
+
}
|
|
251
|
+
: undefined
|
|
252
|
+
}
|
|
253
|
+
onRead={markRead}
|
|
254
|
+
pendingArchiveUpdate={pendingArchiveUpdates.find(
|
|
255
|
+
(update) => update.conversationId === selectedId,
|
|
256
|
+
)}
|
|
257
|
+
sidebarToggle={
|
|
258
|
+
<HeaderIconButton
|
|
259
|
+
controls={sidebarId}
|
|
260
|
+
expanded={sidebarOpen}
|
|
261
|
+
label={
|
|
262
|
+
sidebarOpen
|
|
263
|
+
? "Hide conversation list"
|
|
264
|
+
: "Show conversation list"
|
|
265
|
+
}
|
|
266
|
+
onClick={toggleSidebar}
|
|
267
|
+
>
|
|
268
|
+
<PanelLeft aria-hidden="true" size={15} strokeWidth={2} />
|
|
269
|
+
</HeaderIconButton>
|
|
270
|
+
}
|
|
271
|
+
/>
|
|
272
|
+
</UnsentAnswersProvider>
|
|
273
|
+
</section>
|
|
274
|
+
</ResizablePanel>
|
|
275
|
+
</ResizablePanelGroup>
|
|
188
276
|
);
|
|
189
277
|
}
|
|
190
278
|
|