Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 37 additions & 13 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
}}
>
<NoticeShelf notices={notices} floating />
<NoticeShelf notices={notices} floating onHoverChange={setNoticeHover} />
</div>

{isEmptyNew ? (
Expand Down Expand Up @@ -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 (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
// Right-anchored: every toast's right edge aligns here, widths extend leftward
alignItems: "flex-end",
marginBottom: floating ? 0 : 10,
}}
>
Expand All @@ -965,13 +971,22 @@ function NoticeShelf({ notices, floating = false }: { notices: NoticeItem[]; flo
<div
key={notice.id}
className="notice-shelf-item"
// Hover only pauses the dismiss timer (via onHoverChange); it no longer drives layout
onMouseEnter={() => 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,
Expand All @@ -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",
}}
>
Expand All @@ -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,
}}
/>
<span style={{ padding: "14px 0", minWidth: 0, maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{/* 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 */}
<span style={{ padding: "14px 0", minWidth: 0, maxWidth: "100%", maxHeight: NOTICE_TEXT_MAX_HEIGHT_PX, overflowY: "auto", scrollbarWidth: "thin", whiteSpace: "pre-line", wordBreak: "break-word" }}>
{notice.message}
</span>
</div>
Expand Down
37 changes: 33 additions & 4 deletions hooks/useAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const noticeRemainingMsRef = useRef(NOTICE_VISIBLE_MS);
const noticeTimerStartedAtRef = useRef<number | null>(null);
const noticeOldestIdRef = useRef<string | null>(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(() => {
Expand All @@ -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);
Expand All @@ -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,
Expand Down