From 59d7e8d850ecfe74d3d84e2f3fd4445b231f876d Mon Sep 17 00:00:00 2001 From: North Wang Date: Thu, 20 Aug 2026 19:19:15 +0800 Subject: [PATCH] feat(notice): show full toast text by default, move toasts to top-right, hover-pause dismiss - Toasts render expanded by default: pre-line preserves embedded newlines and long lines wrap within max 620px instead of truncating with an ellipsis - Toasts move from top-center to the top-right corner; right edges stay pinned (flex-end on both the wrapper and the shelf column) and widths extend leftward - Content taller than the 500px cap scrolls inside the text area (thin scrollbar); the type dot stays pinned at the top and does not scroll - Hover no longer drives layout (an earlier hover-to-expand approach could enter an enter/leave flicker loop when the expanded toast narrowed and ejected the cursor); hover now only pauses the dismiss timer via setNoticeHover/onHoverChange - The dismiss countdown is a true pause: elapsed time is accrued via remainingMs/startedAt refs and the timer resumes from the remaining time; it resets whenever the oldest visible notice changes - Each toast opts back into pointer events: the floating wrapper is click-through by design (pointer-events: none), so hover events never reached the toast itself - Entrance animation fill changed from both to backwards: its 60px keyframe would otherwise stick after the animation ends and permanently clamp expanded toasts - Type indicator dot is top-aligned with the first text line on multi-line toasts; font size reduced from 18 to 14 --- components/ChatWindow.tsx | 50 +++++++++++++++++++++++++++++---------- hooks/useAgentSession.ts | 37 +++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 7b31d0337..129b61c69 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -281,7 +281,7 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD retryInfo, contextUsage, forkingEntryId, isCompacting, compactError, compactResult, displayModel: displayModelValue, modelSwitching, sessionStats, slashCommands, slashCommandsLoading, queuedMessages, - notices, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput, + notices, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput, setNoticeHover, isAutoModelSelection, agentPhase, isNew, @@ -654,12 +654,13 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD right: isMobile ? 0 : CHAT_MINIMAP_WIDTH, zIndex: 40, display: "flex", - justifyContent: "center", + // Toasts live in the top-right corner + justifyContent: "flex-end", padding: `0 ${CHAT_COLUMN_PADDING}px`, pointerEvents: "none", }} > - + {isEmptyNew ? ( @@ -942,14 +943,19 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD ); } -function NoticeShelf({ notices, floating = false }: { notices: NoticeItem[]; floating?: boolean }) { +// Toast 整体高度上限;文本区高度上限 = 整体上限 - 上下 padding(14*2) - 上下边框(1*2) +const NOTICE_MAX_HEIGHT_PX = 500; +const NOTICE_TEXT_MAX_HEIGHT_PX = NOTICE_MAX_HEIGHT_PX - 30; + +function NoticeShelf({ notices, floating = false, onHoverChange }: { notices: NoticeItem[]; floating?: boolean; onHoverChange?: (id: string | null) => void }) { if (notices.length === 0) return null; return (
@@ -965,13 +971,22 @@ function NoticeShelf({ notices, floating = false }: { notices: NoticeItem[]; flo
onHoverChange?.(notice.id)} + onMouseLeave={() => onHoverChange?.(null)} style={{ display: "flex", - alignItems: "center", + // Top-align children so the type dot sits by the first line on multi-line toasts + alignItems: "flex-start", gap: 10, minHeight: 60, - height: 60, - maxHeight: 60, + height: "auto", + // 整体高度上限:超出后由文本区内部滚动承担(见下方 span 的 overflowY), + // 容器自身保持 hidden,小圆点固定在顶部不随文本滚动 + maxHeight: NOTICE_MAX_HEIGHT_PX, + // The floating wrapper is pointerEvents:"none" (click-through by design), + // so the toast itself must opt back into interactivity or hover events never reach it + pointerEvents: "auto", marginBottom: index === notices.length - 1 ? 0 : 6, overflow: "hidden", borderRadius: 14, @@ -983,12 +998,15 @@ function NoticeShelf({ notices, floating = false }: { notices: NoticeItem[]; flo boxShadow: floating ? "0 1px 2px rgba(15,23,42,0.05), 0 10px 28px -14px rgba(15,23,42,0.24)" : "0 1px 2px rgba(15,23,42,0.04), 0 8px 24px -12px rgba(15,23,42,0.10)", - fontSize: 18, - lineHeight: 1.45, - transformOrigin: "top center", + fontSize: 14, + lineHeight: 1.5, + transformOrigin: "top right", + // Use backwards fill for the entrance animation so height styles return to + // inline styles once it finishes; otherwise the keyframe's fixed 60px would + // stick around in fill mode and permanently clamp the expanded toast animation: notice.exiting ? "notice-shelf-out 0.18s ease-in forwards" - : "notice-shelf-in 0.18s ease-out both", + : "notice-shelf-in 0.18s ease-out backwards", padding: "0 12px", }} > @@ -999,9 +1017,15 @@ function NoticeShelf({ notices, floating = false }: { notices: NoticeItem[]; flo borderRadius: "50%", background: color, flexShrink: 0, + // Align with the optical center of the first text line: 14px vertical + // padding + (21px line box - 7px dot) / 2 + marginTop: 21, }} /> - + {/* Full text by default: pre-line preserves \n (nowrap/normal collapse + newlines into spaces) and long lines wrap instead of truncating; + content taller than the cap scrolls inside the text area */} + {notice.message}
diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 1a5eb10a2..591ef855a 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -1898,8 +1898,18 @@ export function useAgentSession(opts: UseAgentSessionOptions) { return () => clearTimeout(t); }, [compactResult]); + // Pause notice expiry while hovered: no countdown runs when hoveredNoticeId is set. + // The remainingMs/startedAt/oldestId refs implement a true pause-and-resume instead of resetting the 5s timer. + const [hoveredNoticeId, setHoveredNoticeId] = useState(null); + const noticeRemainingMsRef = useRef(NOTICE_VISIBLE_MS); + const noticeTimerStartedAtRef = useRef(null); + const noticeOldestIdRef = useRef(null); + useEffect(() => { - if (noticeState.visible.length === 0) return; + if (noticeState.visible.length === 0) { + noticeOldestIdRef.current = null; + return; + } const exiting = noticeState.visible.find((notice) => notice.exiting); if (exiting) { const t = setTimeout(() => { @@ -1909,11 +1919,29 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } const oldest = noticeState.visible[0]; if (!oldest) return; + // Oldest visible notice changed; restart the countdown + if (noticeOldestIdRef.current !== oldest.id) { + noticeOldestIdRef.current = oldest.id; + noticeRemainingMsRef.current = NOTICE_VISIBLE_MS; + } + // Hovered: countdown paused + if (hoveredNoticeId !== null) return; + noticeTimerStartedAtRef.current = Date.now(); const t = setTimeout(() => { dispatchNotice({ type: "mark_oldest_exiting" }); - }, NOTICE_VISIBLE_MS); - return () => clearTimeout(t); - }, [noticeState.visible]); + }, noticeRemainingMsRef.current); + return () => { + clearTimeout(t); + // Accrue the elapsed time so the countdown resumes from the remaining time + if (noticeTimerStartedAtRef.current !== null) { + noticeRemainingMsRef.current = Math.max( + 0, + noticeRemainingMsRef.current - (Date.now() - noticeTimerStartedAtRef.current), + ); + noticeTimerStartedAtRef.current = null; + } + }; + }, [noticeState.visible, hoveredNoticeId]); useEffect(() => { setSessionStatsOverride(null); @@ -1939,6 +1967,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleRecallQueue, handleBuiltinSlashCommand, + setNoticeHover: setHoveredNoticeId, handleToolPresetChange, handleThinkingLevelChange, loadTools, loadSlashCommands, setActiveLeafId, setData, setMessages, scrollToBottom, scrollUserMsgToTop, dispatch, setAgentRunning, setForkingEntryId,