diff --git a/components/ChatWindow.notices.test.mjs b/components/ChatWindow.notices.test.mjs index de4172c6e..62ce3bc3f 100644 --- a/components/ChatWindow.notices.test.mjs +++ b/components/ChatWindow.notices.test.mjs @@ -3,13 +3,28 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; const source = await readFile(new URL("./ChatWindow.tsx", import.meta.url), "utf8"); +const hookSource = await readFile(new URL("../hooks/useAgentSession.ts", import.meta.url), "utf8"); -test("renders temporary notices once at the top center of the chat column", () => { +test("renders temporary notices once at the top right of the chat column", () => { const noticeShelfUsages = source.match(//, + /position: "absolute",\s*top: 12,\s*left: 0,\s*right: isMobile \? 0 : CHAT_MINIMAP_WIDTH,[\s\S]*?justifyContent: "flex-end",[\s\S]*?/, ); }); + +test("pauses only for a visible notice", () => { + assert.match( + hookSource, + /noticeState\.visible\.some\(\(notice\) => notice\.id === pausedNoticeId\)\) return/, + ); +}); + +test("lets keyboard users pause and scroll long notices", () => { + assert.match(source, /onFocus=\{\(\) => onPauseChange\?\.\(notice\.id\)\}/); + assert.match(source, /onBlur=\{\(event\) => \{\s*if \(!event\.currentTarget\.matches\(":hover"\)\) onPauseChange\?\.\(null\)/); + assert.match(source, /onMouseLeave=\{\(event\) => \{\s*if \(!event\.currentTarget\.contains\(document\.activeElement\)\) onPauseChange\?\.\(null\)/); + assert.match(source, / - + {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, onPauseChange }: { notices: NoticeItem[]; floating?: boolean; onPauseChange?: (id: string | null) => void }) { if (notices.length === 0) return null; return (
@@ -965,13 +971,27 @@ function NoticeShelf({ notices, floating = false }: { notices: NoticeItem[]; flo
onPauseChange?.(notice.id)} + onMouseLeave={(event) => { + if (!event.currentTarget.contains(document.activeElement)) onPauseChange?.(null); + }} + onFocus={() => onPauseChange?.(notice.id)} + onBlur={(event) => { + if (!event.currentTarget.matches(":hover")) onPauseChange?.(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 +1003,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 +1022,18 @@ 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..38533c2eb 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 or focused. + // The remainingMs/startedAt/oldestId refs implement a true pause-and-resume instead of resetting the 5s timer. + const [pausedNoticeId, setPausedNoticeId] = 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,28 @@ 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; + } + if (noticeState.visible.some((notice) => notice.id === pausedNoticeId)) 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, pausedNoticeId]); useEffect(() => { setSessionStatsOverride(null); @@ -1939,6 +1966,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleRecallQueue, handleBuiltinSlashCommand, + setNoticePaused: setPausedNoticeId, handleToolPresetChange, handleThinkingLevelChange, loadTools, loadSlashCommands, setActiveLeafId, setData, setMessages, scrollToBottom, scrollUserMsgToTop, dispatch, setAgentRunning, setForkingEntryId,