@sentry/junior-dashboard 0.276.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 +196 -72
- package/dist/client.js +94 -89
- package/dist/index.d.ts +1 -1
- package/dist/index.js +191 -70
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/api/schema.ts +20 -2
- package/src/app.ts +80 -12
- package/src/auth.ts +5 -1
- package/src/client/App.tsx +9 -4
- package/src/client/components/DetailsPanel.tsx +1 -1
- package/src/client/components/FormNotice.tsx +11 -14
- package/src/client/components/ProfileMenu.tsx +5 -6
- package/src/client/components/ui/resizable.tsx +50 -0
- package/src/client/conversations/AutoArchiveToggle.tsx +71 -0
- package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
- package/src/client/conversations/ConversationChildAgents.tsx +2 -1
- package/src/client/conversations/ConversationDetails.tsx +6 -3
- package/src/client/conversations/ConversationHeader.tsx +15 -2
- package/src/client/conversations/ConversationHeaderActions.tsx +27 -0
- package/src/client/conversations/ConversationHomeList.tsx +1 -1
- package/src/client/conversations/ConversationMemories.tsx +64 -67
- package/src/client/conversations/ConversationMeta.tsx +2 -7
- package/src/client/conversations/ConversationPage.tsx +175 -79
- package/src/client/conversations/ConversationSidebar.tsx +82 -25
- package/src/client/conversations/ConversationTranscript.tsx +11 -8
- package/src/client/conversations/ConversationWatches.tsx +15 -21
- package/src/client/conversations/ConversationWorkspace.tsx +132 -44
- package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
- package/src/client/conversations/DetailsList.tsx +8 -14
- package/src/client/conversations/EventDetails.tsx +2 -62
- package/src/client/conversations/MemoryIcons.tsx +23 -0
- package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
- package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
- package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
- package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
- package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
- package/src/client/conversations/TranscriptView.tsx +89 -20
- package/src/client/conversations/autoArchiveQueries.ts +53 -0
- package/src/client/conversations/conversationArchive.ts +255 -0
- package/src/client/conversations/conversationMemory.ts +2 -1
- package/src/client/conversations/conversationSections.ts +24 -18
- 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 +121 -250
- 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/conversations/usePinConversation.ts +88 -0
- package/src/client/display-text/DisplayText.tsx +7 -2
- package/src/client/display-text/codeBlocks.ts +92 -0
- package/src/client/format.ts +3 -67
- package/src/client/http.ts +5 -5
- package/src/client/markdownExport.ts +32 -42
- package/src/client/pages/code/RepositoryTabs.tsx +2 -2
- 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/pages/settings/AutoArchiveSettings.tsx +145 -0
- package/src/client/pages/settings/SettingsPage.tsx +2 -0
- package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
- package/src/client/review-desk/queries.ts +2 -1
- package/src/client/types.ts +19 -33
- package/src/conversation-cache.ts +7 -9
- package/src/mock-reporting/fixtures.ts +126 -60
- package/src/mock-reporting/routes.ts +50 -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,12 @@ 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
|
-
useConversationData,
|
|
18
19
|
type PendingArchiveConversationUpdate,
|
|
19
|
-
} from "./
|
|
20
|
+
} from "./conversationArchive";
|
|
21
|
+
import { usePinConversation } from "./usePinConversation";
|
|
20
22
|
import { conversationIsResponding } from "./transcript";
|
|
21
23
|
import { buildConversationMarkdown } from "../markdownExport";
|
|
22
24
|
import { CopyButton } from "../components/CopyButton";
|
|
@@ -31,7 +33,7 @@ import {
|
|
|
31
33
|
import { ConversationHeader } from "./ConversationHeader";
|
|
32
34
|
import { ConversationReplyFooter } from "./ConversationReplyFooter";
|
|
33
35
|
import { ConversationDetails } from "./ConversationDetails";
|
|
34
|
-
import {
|
|
36
|
+
import { AutoArchiveToggle } from "./AutoArchiveToggle";
|
|
35
37
|
import {
|
|
36
38
|
ConversationMemoryContext,
|
|
37
39
|
memoryAnchorKey,
|
|
@@ -96,6 +98,10 @@ import {
|
|
|
96
98
|
useReadPositionAtOpen,
|
|
97
99
|
} from "./transcriptReadPosition";
|
|
98
100
|
import { TranscriptLoading } from "./TranscriptLoading";
|
|
101
|
+
import {
|
|
102
|
+
useUnreadHistory,
|
|
103
|
+
type TranscriptHistory,
|
|
104
|
+
} from "./transcriptUnreadHistory";
|
|
99
105
|
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
100
106
|
import {
|
|
101
107
|
SubagentTranscriptDrawer,
|
|
@@ -121,8 +127,14 @@ function subscribeToDetailsLayout(onChange: () => void) {
|
|
|
121
127
|
export function ConversationPage(props: {
|
|
122
128
|
conversationId: string;
|
|
123
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;
|
|
124
135
|
onRead?(conversationId: string, lastReadAt: string): void;
|
|
125
136
|
pendingArchiveUpdate?: PendingArchiveConversationUpdate;
|
|
137
|
+
sidebarToggle?: ReactNode;
|
|
126
138
|
}) {
|
|
127
139
|
const detailsDefaultOpen = useSyncExternalStore(
|
|
128
140
|
subscribeToDetailsLayout,
|
|
@@ -197,7 +209,15 @@ export function ConversationPage(props: {
|
|
|
197
209
|
const onRead = props.onRead;
|
|
198
210
|
const summaries = props.data?.conversations.conversations ?? [];
|
|
199
211
|
const conversations = buildConversations(summaries);
|
|
200
|
-
|
|
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;
|
|
201
221
|
const memoryList = useConversationMemories(conversationId).data;
|
|
202
222
|
const memoryState = useMemo<ConversationMemoryState>(
|
|
203
223
|
() => ({
|
|
@@ -208,8 +228,41 @@ export function ConversationPage(props: {
|
|
|
208
228
|
[memoryList, memoryPopover],
|
|
209
229
|
);
|
|
210
230
|
const archive = useArchiveConversation(conversationId);
|
|
231
|
+
const pin = usePinConversation(conversationId);
|
|
211
232
|
const markRead = useMarkConversationRead(conversationId);
|
|
212
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);
|
|
213
266
|
const feedConversation = conversations.find(
|
|
214
267
|
(item) => item.id === conversationId,
|
|
215
268
|
);
|
|
@@ -232,7 +285,7 @@ export function ConversationPage(props: {
|
|
|
232
285
|
if (!conversation) return;
|
|
233
286
|
onRead?.(conversation.id, conversation.lastSeenAt);
|
|
234
287
|
}, [conversation, onRead]);
|
|
235
|
-
const transcript =
|
|
288
|
+
const transcript = history.data;
|
|
236
289
|
const selectedEvent =
|
|
237
290
|
transcript?.eventHistory.status === "expired"
|
|
238
291
|
? undefined
|
|
@@ -284,27 +337,31 @@ export function ConversationPage(props: {
|
|
|
284
337
|
},
|
|
285
338
|
[eventOpen, setSelectedEventSeq],
|
|
286
339
|
);
|
|
287
|
-
// A linked event can be
|
|
288
|
-
// it
|
|
289
|
-
const
|
|
290
|
-
|
|
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);
|
|
291
350
|
const oldestLoadedSeq = transcript?.events[0]?.seq;
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
// history has no such event. A history error keeps the link, so a retry
|
|
307
|
-
// 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.
|
|
308
365
|
const linkedTurnSeq = linkedTurnId
|
|
309
366
|
? turnStartedEventSeq(transcript?.events, linkedTurnId)
|
|
310
367
|
: undefined;
|
|
@@ -312,28 +369,21 @@ export function ConversationPage(props: {
|
|
|
312
369
|
linkedTurnId !== undefined &&
|
|
313
370
|
linkedTurnSeq === undefined &&
|
|
314
371
|
transcript !== undefined;
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
!detail.hasPreviousPage &&
|
|
323
|
-
!detail.isLoadingPreviousPage;
|
|
324
|
-
useEffect(() => {
|
|
325
|
-
if (loadLinkedTurn) loadPreviousPageRef.current();
|
|
326
|
-
}, [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";
|
|
327
379
|
useEffect(() => {
|
|
328
380
|
if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq);
|
|
329
381
|
else if (linkedTurnNotFound) setSelectedEventSeq(undefined);
|
|
330
382
|
}, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]);
|
|
331
383
|
// Show a chosen memory at its transcript row. Wait for the first page, then
|
|
332
|
-
// load
|
|
333
|
-
const
|
|
334
|
-
()
|
|
335
|
-
[transcript?.events],
|
|
336
|
-
);
|
|
384
|
+
// load pages toward the row until it renders, or stop when history ends.
|
|
385
|
+
const memorySearch =
|
|
386
|
+
memoryJump && transcript ? searchHistory("any") : undefined;
|
|
337
387
|
useEffect(() => {
|
|
338
388
|
if (!memoryJump || !transcript) return;
|
|
339
389
|
const anchor = pageRef.current?.querySelector(
|
|
@@ -343,33 +393,22 @@ export function ConversationPage(props: {
|
|
|
343
393
|
anchor.scrollIntoView({ block: "center" });
|
|
344
394
|
setMemoryPopover(memoryJump);
|
|
345
395
|
setMemoryJump(undefined);
|
|
346
|
-
} else if (
|
|
347
|
-
detail.hasPreviousPage &&
|
|
348
|
-
!detail.isLoadingPreviousPage &&
|
|
349
|
-
!detail.historyError
|
|
350
|
-
) {
|
|
351
|
-
loadPreviousPageRef.current();
|
|
352
|
-
} else if (!detail.isLoadingPreviousPage) {
|
|
396
|
+
} else if (memorySearch === "failed" || memorySearch === "end") {
|
|
353
397
|
setMemoryJump(undefined);
|
|
398
|
+
} else {
|
|
399
|
+
loadToward(memorySearch);
|
|
354
400
|
}
|
|
355
|
-
}, [
|
|
356
|
-
detail.hasPreviousPage,
|
|
357
|
-
detail.historyError,
|
|
358
|
-
detail.isLoadingPreviousPage,
|
|
359
|
-
memoryJump,
|
|
360
|
-
transcript,
|
|
361
|
-
view,
|
|
362
|
-
]);
|
|
401
|
+
}, [loadToward, memoryJump, memorySearch, transcript, view]);
|
|
363
402
|
// Keep the panel while the transcript or a linked event loads, and for the
|
|
364
403
|
// render before a found Turn link becomes an event link.
|
|
365
404
|
const eventLoading =
|
|
366
405
|
eventOpen &&
|
|
367
406
|
!selectedEvent &&
|
|
368
|
-
(
|
|
407
|
+
(transcriptPending ||
|
|
369
408
|
linkedTurnSeq !== undefined ||
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
409
|
+
linkedSearch === "load-earlier" ||
|
|
410
|
+
linkedSearch === "load-newer" ||
|
|
411
|
+
linkedSearch === "wait");
|
|
373
412
|
const showEvent = Boolean(selectedEvent) || eventLoading;
|
|
374
413
|
// Step through the same filtered rows that the event log shows.
|
|
375
414
|
const events = transcript?.events;
|
|
@@ -392,6 +431,7 @@ export function ConversationPage(props: {
|
|
|
392
431
|
[turnContext?.id],
|
|
393
432
|
);
|
|
394
433
|
const requestPin = useCallback(() => {
|
|
434
|
+
setShowLatest(true);
|
|
395
435
|
setPinRequestVersion((version) => version + 1);
|
|
396
436
|
}, []);
|
|
397
437
|
const onOpenSubagentTranscript = useCallback(
|
|
@@ -488,6 +528,14 @@ export function ConversationPage(props: {
|
|
|
488
528
|
<ConversationAnnotations detail={detail.data} />
|
|
489
529
|
) : null
|
|
490
530
|
}
|
|
531
|
+
pin={{
|
|
532
|
+
// The API lets only participants pin, the same as archive.
|
|
533
|
+
disabled:
|
|
534
|
+
!conversation || !detail.data?.isParticipant || pin.isPending,
|
|
535
|
+
error: Boolean(pin.error),
|
|
536
|
+
onClick: () => pin.mutate(!conversation?.pinnedAt),
|
|
537
|
+
pinned: Boolean(conversation?.pinnedAt),
|
|
538
|
+
}}
|
|
491
539
|
archive={{
|
|
492
540
|
archived: Boolean(conversation?.archivedAt),
|
|
493
541
|
disabled: !conversation || archive.isPending,
|
|
@@ -508,15 +556,17 @@ export function ConversationPage(props: {
|
|
|
508
556
|
}
|
|
509
557
|
search={search}
|
|
510
558
|
sentryConversationUrl={detail.data?.sentryConversationUrl}
|
|
559
|
+
sidebarToggle={props.sidebarToggle}
|
|
511
560
|
title={title}
|
|
512
561
|
view={view}
|
|
513
562
|
/>
|
|
514
563
|
}
|
|
515
564
|
scrollMinTall
|
|
516
565
|
scrollAriaLabel="Conversation transcript"
|
|
517
|
-
scrollClassName={
|
|
518
|
-
|
|
519
|
-
|
|
566
|
+
scrollClassName={cn(
|
|
567
|
+
"thin-scrollbar",
|
|
568
|
+
view !== "raw" && "px-4 pb-4 md:px-7 md:pb-6",
|
|
569
|
+
)}
|
|
520
570
|
scroll={
|
|
521
571
|
<section className="min-w-0">
|
|
522
572
|
<div
|
|
@@ -526,7 +576,7 @@ export function ConversationPage(props: {
|
|
|
526
576
|
: "mx-auto w-full max-w-[52.5rem] pt-5 md:pt-7"
|
|
527
577
|
}
|
|
528
578
|
>
|
|
529
|
-
{
|
|
579
|
+
{transcriptPending ? (
|
|
530
580
|
view === "raw" ? (
|
|
531
581
|
<ConversationEventLogLoading />
|
|
532
582
|
) : (
|
|
@@ -546,30 +596,36 @@ export function ConversationPage(props: {
|
|
|
546
596
|
) : null}
|
|
547
597
|
<ConversationMemoryContext value={memoryState}>
|
|
548
598
|
<Transcript
|
|
549
|
-
hasPreviousPage={
|
|
550
|
-
historyError={
|
|
551
|
-
historyVersion={
|
|
599
|
+
hasPreviousPage={history.hasPreviousPage}
|
|
600
|
+
historyError={history.historyError}
|
|
601
|
+
historyVersion={history.historyVersion}
|
|
552
602
|
lastReadSeq={readAtOpen.lastReadSeq}
|
|
553
603
|
live={live}
|
|
554
|
-
loadingPreviousPage={
|
|
555
|
-
|
|
556
|
-
|
|
604
|
+
loadingPreviousPage={history.isLoadingPreviousPage}
|
|
605
|
+
newerHistory={history.newer}
|
|
606
|
+
onLoadPreviousPage={history.loadPreviousPage}
|
|
607
|
+
onRead={holdCachedTranscript ? undefined : markRead}
|
|
557
608
|
onSelectedEventSeqChange={setSelectedEventSeq}
|
|
558
|
-
|
|
609
|
+
// Sends and the thinking row belong after the latest event.
|
|
610
|
+
pendingMessages={
|
|
611
|
+
history.newer ? undefined : detail.inlineMessages
|
|
612
|
+
}
|
|
559
613
|
pinRequestVersion={pinRequestVersion}
|
|
560
614
|
responding={
|
|
561
|
-
!detail.error &&
|
|
615
|
+
!detail.error &&
|
|
616
|
+
!history.newer &&
|
|
617
|
+
conversationIsResponding(detail.data)
|
|
562
618
|
}
|
|
563
619
|
onOpenSubagentTranscript={onOpenSubagentTranscript}
|
|
564
620
|
search={search}
|
|
565
621
|
onSearchChange={setSearch}
|
|
566
622
|
selectedEventSeq={selectedEventSeq}
|
|
567
|
-
startAtUnread={
|
|
568
|
-
readConversationStartPosition() === "unread"
|
|
569
|
-
}
|
|
623
|
+
startAtUnread={startAtUnread}
|
|
570
624
|
transcript={transcript}
|
|
571
625
|
turnContextPanel={turnContextPanel}
|
|
572
|
-
unread={
|
|
626
|
+
unread={
|
|
627
|
+
holdCachedTranscript ? undefined : readAtOpen.unread
|
|
628
|
+
}
|
|
573
629
|
view={view}
|
|
574
630
|
/>
|
|
575
631
|
</ConversationMemoryContext>
|
|
@@ -598,9 +654,12 @@ export function ConversationPage(props: {
|
|
|
598
654
|
// Without live history, an inline send would never leave the
|
|
599
655
|
// transcript tail. A started Turn reads a new send only later.
|
|
600
656
|
// Keep those sends in the queue. An earlier inline send that
|
|
601
|
-
// 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.
|
|
602
660
|
sendsInline={
|
|
603
661
|
detail.data.eventHistory.status === "available" &&
|
|
662
|
+
!history.newer &&
|
|
604
663
|
!conversationIsResponding(detail.data) &&
|
|
605
664
|
detail.inlineMessages.length === 0
|
|
606
665
|
}
|
|
@@ -612,13 +671,20 @@ export function ConversationPage(props: {
|
|
|
612
671
|
{showDetails ? (
|
|
613
672
|
<ConversationMemoryContext value={memoryState}>
|
|
614
673
|
<ConversationDetails
|
|
674
|
+
autoArchive={
|
|
675
|
+
detail.data?.autoArchive === undefined ? null : (
|
|
676
|
+
<AutoArchiveToggle
|
|
677
|
+
autoArchive={detail.data.autoArchive}
|
|
678
|
+
conversationId={conversationId}
|
|
679
|
+
/>
|
|
680
|
+
)
|
|
681
|
+
}
|
|
615
682
|
brief={detail.data?.brief}
|
|
616
683
|
conversationId={conversationId}
|
|
617
684
|
highlightReview={reviewOpen}
|
|
618
685
|
identity={identity}
|
|
619
686
|
onClose={closeDetails}
|
|
620
687
|
onOpenSubagent={setSubagentTarget}
|
|
621
|
-
memoryPrompts={memoryPrompts}
|
|
622
688
|
onSelectMemory={(entry) => {
|
|
623
689
|
// Memory rows show only in the Conversation view, and search
|
|
624
690
|
// can hide them.
|
|
@@ -740,3 +806,33 @@ function committedMessageIds(
|
|
|
740
806
|
}
|
|
741
807
|
|
|
742
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
|
+
}
|
|
@@ -6,11 +6,13 @@ import {
|
|
|
6
6
|
CircleAlert,
|
|
7
7
|
Inbox,
|
|
8
8
|
LockKeyhole,
|
|
9
|
+
Pin,
|
|
9
10
|
SquarePen,
|
|
10
11
|
} from "lucide-react";
|
|
11
12
|
import { Link } from "react-router";
|
|
12
13
|
|
|
13
|
-
import { useArchiveConversation } from "./
|
|
14
|
+
import { useArchiveConversation } from "./conversationArchive";
|
|
15
|
+
import { usePinConversation } from "./usePinConversation";
|
|
14
16
|
import { conversationPath } from "./conversationRoutes";
|
|
15
17
|
import {
|
|
16
18
|
conversationDisplayTitle,
|
|
@@ -51,9 +53,12 @@ type ConversationSidebarEntry =
|
|
|
51
53
|
}
|
|
52
54
|
| { conversation: Conversation; key: string; kind: "conversation" };
|
|
53
55
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
type
|
|
56
|
+
// The title names the attempted action, so a failed archive or pin never
|
|
57
|
+
// reads the row's already-optimistic state.
|
|
58
|
+
type RowActionError = { conversation: Conversation; title: string };
|
|
59
|
+
|
|
60
|
+
const ROW_ACTION_BUTTON_CLASS =
|
|
61
|
+
"absolute 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";
|
|
57
62
|
|
|
58
63
|
const conversationEntryKey = (entry: ConversationSidebarEntry) => entry.key;
|
|
59
64
|
|
|
@@ -81,23 +86,42 @@ export function ConversationSidebar(props: {
|
|
|
81
86
|
const isLanding = variant === "landing";
|
|
82
87
|
const [archivedConversation, setArchivedConversation] =
|
|
83
88
|
useState<Conversation>();
|
|
84
|
-
const [
|
|
89
|
+
const [rowError, setRowError] = useState<RowActionError>();
|
|
85
90
|
const dismissArchivedConversation = useCallback(
|
|
86
91
|
() => setArchivedConversation(undefined),
|
|
87
92
|
[],
|
|
88
93
|
);
|
|
89
94
|
const handleArchiveError = useCallback(
|
|
90
95
|
(conversation: Conversation, wasArchiving: boolean) => {
|
|
91
|
-
|
|
96
|
+
setRowError({
|
|
97
|
+
conversation,
|
|
98
|
+
title: wasArchiving ? "Could not archive" : "Could not restore",
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
[],
|
|
102
|
+
);
|
|
103
|
+
const handlePinError = useCallback(
|
|
104
|
+
(conversation: Conversation, wasPinning: boolean) => {
|
|
105
|
+
setRowError({
|
|
106
|
+
conversation,
|
|
107
|
+
title: wasPinning ? "Could not pin" : "Could not unpin",
|
|
108
|
+
});
|
|
92
109
|
},
|
|
93
110
|
[],
|
|
94
111
|
);
|
|
95
|
-
|
|
96
|
-
|
|
112
|
+
// A later success on the same row replaces its error notice.
|
|
113
|
+
const clearRowError = useCallback((conversation: Conversation) => {
|
|
114
|
+
setRowError((current) =>
|
|
97
115
|
current?.conversation.id === conversation.id ? undefined : current,
|
|
98
116
|
);
|
|
99
|
-
setArchivedConversation(conversation);
|
|
100
117
|
}, []);
|
|
118
|
+
const handleArchived = useCallback(
|
|
119
|
+
(conversation: Conversation) => {
|
|
120
|
+
clearRowError(conversation);
|
|
121
|
+
setArchivedConversation(conversation);
|
|
122
|
+
},
|
|
123
|
+
[clearRowError],
|
|
124
|
+
);
|
|
101
125
|
const { collapsedKeys, toggle: toggleSection } =
|
|
102
126
|
useCollapsedConversationSections();
|
|
103
127
|
// Rebuild section rows only when their inputs change. Avoid fresh Date.now()
|
|
@@ -167,6 +191,7 @@ export function ConversationSidebar(props: {
|
|
|
167
191
|
</div>
|
|
168
192
|
<div className="px-4 pb-2">
|
|
169
193
|
<SearchInput
|
|
194
|
+
className="border-dashboard-border-subtle bg-transparent hover:border-dashboard-border hover:bg-dashboard-fill-faint"
|
|
170
195
|
label="Search your conversations"
|
|
171
196
|
onChange={props.onQueryChange}
|
|
172
197
|
placeholder="Search conversations…"
|
|
@@ -179,7 +204,7 @@ export function ConversationSidebar(props: {
|
|
|
179
204
|
"px-3",
|
|
180
205
|
isLanding
|
|
181
206
|
? "pb-[max(1rem,env(safe-area-inset-bottom))]"
|
|
182
|
-
: "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))]",
|
|
183
208
|
)}
|
|
184
209
|
>
|
|
185
210
|
{props.error ? (
|
|
@@ -217,6 +242,8 @@ export function ConversationSidebar(props: {
|
|
|
217
242
|
conversation={entry.conversation}
|
|
218
243
|
onArchiveError={handleArchiveError}
|
|
219
244
|
onArchived={handleArchived}
|
|
245
|
+
onPinError={handlePinError}
|
|
246
|
+
onPinned={clearRowError}
|
|
220
247
|
selected={entry.conversation.id === props.selectedId}
|
|
221
248
|
unread={props.finishedConversationIds.has(
|
|
222
249
|
entry.conversation.id,
|
|
@@ -231,7 +258,7 @@ export function ConversationSidebar(props: {
|
|
|
231
258
|
/>
|
|
232
259
|
)}
|
|
233
260
|
</div>
|
|
234
|
-
{archivedConversation ||
|
|
261
|
+
{archivedConversation || rowError ? (
|
|
235
262
|
<div
|
|
236
263
|
className={cn(
|
|
237
264
|
// Landing is content-tall (parent scrolls). Keep notices on the
|
|
@@ -241,11 +268,11 @@ export function ConversationSidebar(props: {
|
|
|
241
268
|
: "absolute bottom-[max(0.75rem,env(safe-area-inset-bottom))] left-3 right-3 z-20 grid gap-2",
|
|
242
269
|
)}
|
|
243
270
|
>
|
|
244
|
-
{
|
|
245
|
-
<
|
|
246
|
-
conversation={
|
|
247
|
-
onDismiss={() =>
|
|
248
|
-
|
|
271
|
+
{rowError ? (
|
|
272
|
+
<ConversationActionErrorNotice
|
|
273
|
+
conversation={rowError.conversation}
|
|
274
|
+
onDismiss={() => setRowError(undefined)}
|
|
275
|
+
title={rowError.title}
|
|
249
276
|
/>
|
|
250
277
|
) : null}
|
|
251
278
|
{archivedConversation ? (
|
|
@@ -317,6 +344,8 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
|
|
|
317
344
|
conversation: Conversation;
|
|
318
345
|
onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
|
|
319
346
|
onArchived(conversation: Conversation): void;
|
|
347
|
+
onPinError(conversation: Conversation, wasPinning: boolean): void;
|
|
348
|
+
onPinned(conversation: Conversation): void;
|
|
320
349
|
selected: boolean;
|
|
321
350
|
unread: boolean;
|
|
322
351
|
waiting?: WaitingConversation;
|
|
@@ -334,6 +363,11 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
|
|
|
334
363
|
if (archived) props.onArchived(props.conversation);
|
|
335
364
|
},
|
|
336
365
|
});
|
|
366
|
+
const pin = usePinConversation(props.conversation.id, {
|
|
367
|
+
onError: (pinned) => props.onPinError(props.conversation, pinned),
|
|
368
|
+
onSuccess: () => props.onPinned(props.conversation),
|
|
369
|
+
});
|
|
370
|
+
const pinned = Boolean(props.conversation.pinnedAt);
|
|
337
371
|
const status = visualStatusForConversation(props.conversation);
|
|
338
372
|
const title = conversationDisplayTitle(props.conversation);
|
|
339
373
|
// Unread and waiting rows read like unread mail: a dot and a bold title.
|
|
@@ -405,12 +439,39 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
|
|
|
405
439
|
/>
|
|
406
440
|
</Link>
|
|
407
441
|
) : null}
|
|
442
|
+
{/* Mobile rows pin from the conversation menu, so only the archive
|
|
443
|
+
button takes row space there. Archived rows restore before they pin. */}
|
|
444
|
+
{props.conversation.archivedAt ? null : (
|
|
445
|
+
<Tooltip>
|
|
446
|
+
<TooltipTrigger
|
|
447
|
+
render={
|
|
448
|
+
<button
|
|
449
|
+
aria-label={`${pinned ? "Unpin" : "Pin"} ${title}`}
|
|
450
|
+
aria-pressed={pinned}
|
|
451
|
+
className={cn(ROW_ACTION_BUTTON_CLASS, "right-9 max-sm:hidden")}
|
|
452
|
+
disabled={pin.isPending}
|
|
453
|
+
onClick={() => pin.mutate(!pinned)}
|
|
454
|
+
type="button"
|
|
455
|
+
>
|
|
456
|
+
<Pin
|
|
457
|
+
aria-hidden="true"
|
|
458
|
+
fill={pinned ? "currentColor" : "none"}
|
|
459
|
+
size={14}
|
|
460
|
+
/>
|
|
461
|
+
</button>
|
|
462
|
+
}
|
|
463
|
+
/>
|
|
464
|
+
<TooltipContent size="compact">
|
|
465
|
+
{pinned ? "Unpin" : "Pin"}
|
|
466
|
+
</TooltipContent>
|
|
467
|
+
</Tooltip>
|
|
468
|
+
)}
|
|
408
469
|
<Tooltip>
|
|
409
470
|
<TooltipTrigger
|
|
410
471
|
render={
|
|
411
472
|
<button
|
|
412
473
|
aria-label={`${props.conversation.archivedAt ? "Restore" : "Archive"} ${title}`}
|
|
413
|
-
className="
|
|
474
|
+
className={cn(ROW_ACTION_BUTTON_CLASS, "right-1.5")}
|
|
414
475
|
disabled={archive.isPending}
|
|
415
476
|
onClick={() =>
|
|
416
477
|
archive.mutate({
|
|
@@ -459,15 +520,11 @@ function ConversationRowState(props: {
|
|
|
459
520
|
);
|
|
460
521
|
}
|
|
461
522
|
|
|
462
|
-
function
|
|
523
|
+
function ConversationActionErrorNotice(props: {
|
|
463
524
|
conversation: Conversation;
|
|
464
525
|
onDismiss(): void;
|
|
465
|
-
|
|
526
|
+
title: string;
|
|
466
527
|
}) {
|
|
467
|
-
const title = conversationDisplayTitle(props.conversation);
|
|
468
|
-
const actionTitle = props.wasArchiving
|
|
469
|
-
? "Could not archive"
|
|
470
|
-
: "Could not restore";
|
|
471
528
|
return (
|
|
472
529
|
<Notice
|
|
473
530
|
action={
|
|
@@ -475,9 +532,9 @@ function ArchiveConversationErrorNotice(props: {
|
|
|
475
532
|
Dismiss
|
|
476
533
|
</NoticeAction>
|
|
477
534
|
}
|
|
478
|
-
detail={
|
|
535
|
+
detail={conversationDisplayTitle(props.conversation)}
|
|
479
536
|
icon={CircleAlert}
|
|
480
|
-
title={
|
|
537
|
+
title={props.title}
|
|
481
538
|
tone="error"
|
|
482
539
|
/>
|
|
483
540
|
);
|