diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 42b98c65c..c2689fdb4 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -194,6 +194,7 @@ const InstanceShell2: Component = (props) => { unpinRight: unpinRightDrawer, closeLeft: closeLeftDrawer, closeRight: closeRightDrawer, + closeFloatingDrawersIfAny, leftAppBarButtonLabel, rightAppBarButtonLabel, leftAppBarButtonIcon, @@ -202,6 +203,45 @@ const InstanceShell2: Component = (props) => { handleRightAppBarButtonClick, } = drawerChrome + // When the user switches away from this instance (e.g., taps a different + // instance/project tab while a floating drawer is open on phone), close any + // open floating drawers so the previous instance's drawer doesn't remain + // visually or interactively open when its tab regains focus later. + let wasActiveInstance = Boolean(props.isActiveInstance) + createEffect(() => { + const isActive = Boolean(props.isActiveInstance) + if (wasActiveInstance && !isActive) { + closeFloatingDrawersIfAny() + } + wasActiveInstance = isActive + }) + + onMount(() => { + if (typeof document === "undefined") return + + const handleFloatingDrawerPointerDown = (event: PointerEvent) => { + if (!props.isActiveInstance) return + + const hasFloatingDrawerOpen = (!leftPinned() && leftOpen()) || (!rightPinned() && rightOpen()) + if (!hasFloatingDrawerOpen) return + + const target = event.target + if (!(target instanceof Node)) return + + const leftContent = leftDrawerContentEl() + const rightContent = rightDrawerContentEl() + const leftPaper = leftContent?.closest(".MuiDrawer-paper") + const rightPaper = rightContent?.closest(".MuiDrawer-paper") + if (leftPaper?.contains(target) || rightPaper?.contains(target)) return + + if (!leftPinned() && leftOpen()) setLeftOpen(false) + if (!rightPinned() && rightOpen()) setRightOpen(false) + } + + document.addEventListener("pointerdown", handleFloatingDrawerPointerDown, true) + onCleanup(() => document.removeEventListener("pointerdown", handleFloatingDrawerPointerDown, true)) + }) + createEffect(() => { const instanceId = props.instance.id loadBackgroundProcesses(instanceId).catch((error) => { @@ -607,7 +647,12 @@ const InstanceShell2: Component = (props) => { ModalProps={modalProps} sx={{ zIndex: 60, + // The tab bar sits outside the floating drawer. Let its controls + // receive the gesture; click-away handling above still closes the + // drawer when the target is not inside the drawer content. + pointerEvents: "none", "& .MuiDrawer-paper": { + pointerEvents: "auto", width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`, boxSizing: "border-box", borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)", @@ -620,8 +665,13 @@ const InstanceShell2: Component = (props) => { height: floatingHeight(), }, + // Keep backdrop dismissal for the area below the tab bar without + // covering the tab bar itself. "& .MuiBackdrop-root": { + pointerEvents: "auto", backgroundColor: "transparent", + top: floatingTopPx(), + height: floatingHeight(), }, }} > @@ -723,7 +773,10 @@ const InstanceShell2: Component = (props) => { ModalProps={modalProps} sx={{ zIndex: 60, + // See the matching override on the left drawer for rationale. + pointerEvents: "none", "& .MuiDrawer-paper": { + pointerEvents: "auto", width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`, boxSizing: "border-box", borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)", @@ -736,7 +789,10 @@ const InstanceShell2: Component = (props) => { height: floatingHeight(), }, "& .MuiBackdrop-root": { + pointerEvents: "auto", backgroundColor: "transparent", + top: floatingTopPx(), + height: floatingHeight(), }, }} > diff --git a/packages/ui/src/components/instance/shell/useDrawerChrome.ts b/packages/ui/src/components/instance/shell/useDrawerChrome.ts index bccc84441..4b1238176 100644 --- a/packages/ui/src/components/instance/shell/useDrawerChrome.ts +++ b/packages/ui/src/components/instance/shell/useDrawerChrome.ts @@ -44,6 +44,7 @@ export interface DrawerChromeApi { unpinRight: () => void closeLeft: () => void closeRight: () => void + closeFloatingDrawersIfAny: () => boolean leftAppBarButtonLabel: Accessor rightAppBarButtonLabel: Accessor leftAppBarButtonIcon: Accessor @@ -250,6 +251,7 @@ export function useDrawerChrome(options: UseDrawerChromeOptions): DrawerChromeAp unpinRight, closeLeft, closeRight, + closeFloatingDrawersIfAny, leftAppBarButtonLabel, rightAppBarButtonLabel, leftAppBarButtonIcon, diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 07d6a9af7..1779b4c51 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -1076,6 +1076,7 @@ export default function MessageBlock(props: MessageBlockProps) { onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} selectedMessageIds={props.selectedMessageIds} onToggleSelectedMessage={props.onToggleSelectedMessage} + onContentRendered={props.onContentRendered} /> @@ -1135,6 +1136,7 @@ interface StepCardProps { onDeleteMessagesUpTo?: (messageId: string) => void | Promise selectedMessageIds?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void + onContentRendered?: () => void } interface CompactionCardProps { @@ -1317,6 +1319,12 @@ function StepCard(props: StepCardProps) { const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined) + createEffect(() => { + if (props.kind !== "finish") return + if (!usageStats()) return + props.onContentRendered?.() + }) + const canDeleteMessage = () => Boolean(props.showDeleteMessage && props.instanceId && props.sessionId && props.messageId) && !deletingMessage() diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index ea9fac707..a1142a2b6 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -5,12 +5,11 @@ import BrandedEmptyState from "./branded-empty-state" import MessageBlock from "./message-block" import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" -import VirtualFollowList, { type VirtualFollowListApi, type VirtualFollowListState } from "./virtual-follow-list" +import VirtualFollowList, { type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list" import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import { useI18n } from "../lib/i18n" -import { useScrollCache } from "../lib/hooks/use-scroll-cache" import { copyToClipboard } from "../lib/clipboard" import { showToastNotification } from "../lib/notifications" import { showAlertDialog } from "../stores/alerts" @@ -39,12 +38,13 @@ export interface MessageSectionProps { onRevert?: (messageId: string) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise onFork?: (messageId?: string) => void - registerScrollToBottom?: (fn: () => void) => void + registerScrollToBottom?: (fn: (() => void) | null) => void showSidebarToggle?: boolean onSidebarToggle?: () => void forceCompactStatusLayout?: boolean onQuoteSelection?: (text: string, mode: "quote" | "code") => void isActive?: boolean + sessionStreamingActive?: boolean } export default function MessageSection(props: MessageSectionProps) { @@ -85,12 +85,6 @@ export default function MessageSection(props: MessageSectionProps) { }) }) - const scrollCache = useScrollCache({ - instanceId: props.instanceId, - sessionId: props.sessionId, - scope: MESSAGE_SCROLL_CACHE_SCOPE, - }) - const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId)) const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId)) const sessionInfo = createMemo(() => @@ -664,39 +658,113 @@ export default function MessageSection(props: MessageSectionProps) { const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true) const [didRestoreScroll, setDidRestoreScroll] = createSignal(false) + const lastGoodScrollSnapshots = new Map() + let restoringScrollSnapshot = false + + function getLastGoodScrollSnapshot(sessionId: string) { + return lastGoodScrollSnapshots.get(sessionId) ?? store().getScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE) + } + + function setLastGoodScrollSnapshot(sessionId: string, snapshot: VirtualFollowScrollSnapshot) { + lastGoodScrollSnapshots.set(sessionId, snapshot) + } + createEffect( on( () => props.sessionId, () => { setDidRestoreScroll(false) + const snapshot = store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE) + if (snapshot) setLastGoodScrollSnapshot(props.sessionId, snapshot) + }, + ), + ) + + createEffect( + on( + isActive, + (active, wasActive) => { + if (active) { + if (wasActive === false) { + setDidRestoreScroll(false) + } + return + } + persistMessageScrollSnapshot({ requireActive: false }) }, ), ) + function canCaptureScrollSnapshot(options?: { requireActive?: boolean }) { + const element = streamElement() + if (!element) return false + if ((options?.requireActive ?? true) && !isActive()) return false + if (restoringScrollSnapshot) return false + if (!element.isConnected) return false + if (element.clientHeight <= 0) return false + if (typeof getComputedStyle === "function" && getComputedStyle(element).display === "none") return false + return true + } + + function persistMessageScrollSnapshot(options?: { sessionId?: string; allowCapture?: boolean; requireActive?: boolean }) { + if (restoringScrollSnapshot) return + + const sessionId = options?.sessionId ?? props.sessionId + const allowCapture = options?.allowCapture ?? true + const canCapture = canCaptureScrollSnapshot({ requireActive: options?.requireActive }) + if (allowCapture && canCapture) { + const snapshot = listApi()?.captureScrollSnapshot() + if (snapshot) { + setLastGoodScrollSnapshot(sessionId, snapshot) + store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot) + return + } + } + + const lastGoodScrollSnapshot = getLastGoodScrollSnapshot(sessionId) + if (lastGoodScrollSnapshot) { + store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, lastGoodScrollSnapshot) + return + } + + const element = streamElement() + if (!allowCapture || !canCapture) return + if (!element) return + const scrollTop = element.scrollTop + const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0) + const scrollRatio = maxScrollTop > 0 ? scrollTop / maxScrollTop : 0 + const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48 + const snapshot = { scrollTop, scrollRatio, maxScrollTop, atBottom } + setLastGoodScrollSnapshot(sessionId, snapshot) + store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot) + } + // Persist scroll position when switching sessions. This effect's cleanup runs // when `props.sessionId` changes, before the next session is rendered. createEffect(() => { const sessionId = props.sessionId onCleanup(() => { - const element = streamElement() - if (!element) return - const scrollTop = element.scrollTop - const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48 - store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, { scrollTop, atBottom }) + persistMessageScrollSnapshot({ sessionId, requireActive: false }) }) }) const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null) - const lastVisibleMessageId = createMemo(() => { - const ids = visibleMessageIds() - return ids[ids.length - 1] ?? null + const streamingAssistantTextMessageId = createMemo(() => { + const ids = messageIds() + for (let index = ids.length - 1; index >= 0; index -= 1) { + const messageId = ids[index] + if (isStreamingAssistantTextMessage(messageId)) return messageId + } + return null }) + const streamingActive = createMemo(() => Boolean(props.sessionStreamingActive) && streamingAssistantTextMessageId() !== null) + const autoPinHoldTargetKey = createMemo(() => { if (!holdLongAssistantRepliesEnabled()) return null - const messageId = lastVisibleMessageId() - return isStreamingAssistantTextMessage(messageId) ? messageId : null + if (!streamingActive()) return null + return streamingAssistantTextMessageId() }) function toggleHoldLongAssistantReplies() { @@ -711,10 +779,8 @@ export default function MessageSection(props: MessageSectionProps) { if (record.status !== "streaming") return false const info = resolvedStore.getMessageInfo(messageId) - if (!info) return false const timeInfo = info?.time as { end?: number } | undefined - const isStreaming = timeInfo?.end === undefined || timeInfo.end === 0 - if (!isStreaming) return false + if (typeof timeInfo?.end === "number" && timeInfo.end > 0) return false const { orderedParts } = buildRecordDisplayData(props.instanceId, record) return orderedParts.some((part) => { @@ -728,7 +794,8 @@ export default function MessageSection(props: MessageSectionProps) { const api = listApi() if (!api) return if (props.registerScrollToBottom) { - props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true })) + props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true, suppressHold: true })) + onCleanup(() => props.registerScrollToBottom?.(null)) } }) @@ -737,26 +804,38 @@ export default function MessageSection(props: MessageSectionProps) { const element = streamElement() const api = listApi() if (!element || !api) return + if (!isActive()) return if (props.loading) return if (visibleMessageIds().length === 0) return if (didRestoreScroll()) return - scrollCache.restore(element, { + const snapshot = store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE) + if (!snapshot) { + api.setAutoScroll(true) + api.scrollToBottom({ immediate: true }) + setDidRestoreScroll(true) + return + } + + restoringScrollSnapshot = true + api.restoreScrollSnapshot(snapshot, { behavior: "auto", fallback: () => { api.setAutoScroll(true) api.scrollToBottom({ immediate: true }) }, - onApplied: (snapshot) => { + onApplied: () => { // Keep follow mode consistent with the restored state. - api.setAutoScroll(snapshot?.atBottom ?? true) + api.setAutoScroll(snapshot.atBottom) + restoringScrollSnapshot = false + setLastGoodScrollSnapshot(props.sessionId, snapshot) setDidRestoreScroll(true) }, }) }) onCleanup(() => { - scrollCache.persist(streamElement()) + persistMessageScrollSnapshot({ requireActive: false }) }) function clearQuoteSelection() { @@ -868,7 +947,6 @@ export default function MessageSection(props: MessageSectionProps) { } function handleContentRendered() { - if (props.loading) return listApi()?.notifyContentRendered() } @@ -1246,11 +1324,10 @@ export default function MessageSection(props: MessageSectionProps) { items={visibleMessageIds} getKey={(messageId) => messageId} getAnchorId={getMessageAnchorId} - getKeyFromAnchorId={getMessageIdFromAnchorId} overscanPx={800} scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX} suspendMeasurements={() => !isActive()} - loading={() => Boolean(props.loading)} + streamingActive={streamingActive} isActive={isActive} scrollToBottomOnActivate={() => false} initialScrollToBottom={() => false} @@ -1265,7 +1342,7 @@ export default function MessageSection(props: MessageSectionProps) { }} onScroll={() => { clearQuoteSelection() - scrollCache.persist(streamElement()) + persistMessageScrollSnapshot() }} onMouseUp={() => handleStreamMouseUp()} onClick={(e) => { @@ -1300,6 +1377,7 @@ export default function MessageSection(props: MessageSectionProps) { class="message-scroll-button" data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"} onClick={toggleHoldLongAssistantReplies} + aria-pressed={holdLongAssistantRepliesEnabled()} aria-label={ holdLongAssistantRepliesEnabled() ? t("messageSection.scroll.disableHoldAriaLabel") @@ -1329,7 +1407,7 @@ export default function MessageSection(props: MessageSectionProps) { -