Skip to content
Merged
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
19 changes: 17 additions & 2 deletions components/ChatWindow.notices.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<NoticeShelf notices=\{notices\}/g) ?? [];

assert.equal(noticeShelfUsages.length, 1);
assert.match(
source,
/position: "absolute",\s*top: 12,\s*left: 0,\s*right: isMobile \? 0 : CHAT_MINIMAP_WIDTH,[\s\S]*?justifyContent: "center",[\s\S]*?<NoticeShelf notices=\{notices\} floating \/>/,
/position: "absolute",\s*top: 12,\s*left: 0,\s*right: isMobile \? 0 : CHAT_MINIMAP_WIDTH,[\s\S]*?justifyContent: "flex-end",[\s\S]*?<NoticeShelf notices=\{notices\} floating onPauseChange=\{setNoticePaused\} \/>/,
);
});

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, /<span\s+tabIndex=\{0\}\s+style=\{\{[^}]*overflowY: "auto"/);
});
58 changes: 45 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, setNoticePaused,
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 onPauseChange={setNoticePaused} />
</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, onPauseChange }: { notices: NoticeItem[]; floating?: boolean; onPauseChange?: (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,27 @@ function NoticeShelf({ notices, floating = false }: { notices: NoticeItem[]; flo
<div
key={notice.id}
className="notice-shelf-item"
onMouseEnter={() => 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,
Expand All @@ -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",
}}
>
Expand All @@ -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,
}}
/>
<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
tabIndex={0}
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
36 changes: 32 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 or focused.
// The remainingMs/startedAt/oldestId refs implement a true pause-and-resume instead of resetting the 5s timer.
const [pausedNoticeId, setPausedNoticeId] = 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,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);
Expand All @@ -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,
Expand Down