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
76 changes: 75 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1599,6 +1599,24 @@ function ChatViewContent(props: ChatViewProps) {
return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
}, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
const activeLatestTurn = activeThread?.latestTurn ?? null;
// Reading a finished thread clears the sidebar's Done badge. The visit is
// stamped at the turn's completion time — not now/updatedAt — so it clears
// exactly the completion the user is looking at: a wake or completion that
// lands later still gets its signal (markThreadVisited never moves the
// timestamp backwards).
useEffect(() => {
const completedAt = serverThread?.latestTurn?.completedAt;
if (!serverThread?.id || !completedAt) return;
markThreadVisited(
scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
completedAt,
);
}, [
markThreadVisited,
serverThread?.environmentId,
serverThread?.id,
serverThread?.latestTurn?.completedAt,
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Visit stamp skipped while loading

Medium Severity

When a finished thread is opened while its details are still loading, the useEffect that marks it as visited doesn't run. This occurs because the effect's serverThread dependency is null during the loading phase. As a result, the sidebar's "Done" badge and "Woke" indicator incorrectly persist until the thread's full details load.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b41a398. Configure here.

useEffect(() => {
setMountedTerminalThreadKeys((currentThreadIds) => {
const nextThreadIds = reconcileMountedTerminalThreadIds({
Expand Down Expand Up @@ -4041,6 +4059,37 @@ function ChatViewContent(props: ChatViewProps) {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
// Mirror of the sidebar's Woke pill for the open thread: same visit
// comparison, same merged/closed-PR suppression (finished work needs no
// wake-up call). Drives the dismissible composer banner below.
const activeThreadLastVisitedAt = useUiStateStore((store) =>
activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
);
const activeThreadWokeVisible = useMemo(() => {
if (activeThreadWokeAt === null) return false;
if (activeThreadPr?.state === "merged" || activeThreadPr?.state === "closed") return false;
const wokeAtMs = Date.parse(activeThreadWokeAt);
if (Number.isNaN(wokeAtMs)) return false;
// Having the thread open counts as a visit at completedAt (the effect
// above stamps it); folding that floor in here keeps a completion-
// triggered wake from flashing a banner for one frame before the stamp
// lands. An unparseable stored visit counts as never-visited: corrupt
// local data must not eat the wake signal.
const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
const completedAtMs = activeLatestTurn?.completedAt
? Date.parse(activeLatestTurn.completedAt)
: NaN;
const lastVisitedMs = Math.max(
Number.isNaN(storedVisitMs) ? -Infinity : storedVisitMs,
Number.isNaN(completedAtMs) ? -Infinity : completedAtMs,
);
return lastVisitedMs < wokeAtMs;
}, [
activeLatestTurn?.completedAt,
activeThreadLastVisitedAt,
activeThreadPr?.state,
activeThreadWokeAt,
]);
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
Expand Down Expand Up @@ -4300,6 +4349,23 @@ function ChatViewContent(props: ChatViewProps) {
handleStopBackgroundWork,
isStoppingBackgroundWork,
]);
// A woken thread announces itself in the open view, not just the sidebar
// pill. Dismissing marks the wake as seen (same acknowledgment as the
// pill); sending a message clears it as a side effect of the send path.
const wokeThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
if (!activeThreadWokeVisible) {
return null;
}
return {
id: `thread-woke:${activeThread?.id ?? "unknown"}`,
variant: "info",
icon: <AlarmClockIcon />,
title: "This thread woke from snooze",
description: "Dismiss to clear the Woke indicator, or send a message to keep going.",
dismissLabel: "Dismiss Woke notification",
onDismiss: acknowledgeActiveThreadWoke,
};
}, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);
// The stack renders items[0] front-most and tucks the rest behind hover, so
// ordering is priority: system banners, then the branch-mismatch notice,
// and the informational parked-thread banner last — it must never cover another.
Expand Down Expand Up @@ -4354,13 +4420,20 @@ function ChatViewContent(props: ChatViewProps) {
const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
const backgroundLivenessItems =
backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
return [...systemComposerBannerItems, ...backgroundLivenessItems, ...parkedThreadItems];
return [
...systemComposerBannerItems,
...backgroundLivenessItems,
...wokeThreadItems,
...parkedThreadItems,
];
}
return [
...systemComposerBannerItems,
...backgroundLivenessItems,
...wokeThreadItems,
{
id: `branch-mismatch:${activeBranchMismatchKey}`,
variant: "info",
Expand Down Expand Up @@ -4411,6 +4484,7 @@ function ChatViewContent(props: ChatViewProps) {
parkedThreadBannerItem,
showBranchMismatchBanner,
systemComposerBannerItems,
wokeThreadBannerItem,
]);

useEffect(() => {
Expand Down
45 changes: 26 additions & 19 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,19 +470,40 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
const terminalProcessCount = runningTerminalIds.length;

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
(thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
? vcsEnvironment.status({
environmentId: thread.environmentId,
input: { cwd: gitCwd },
})
: null,
);
const pr = resolveThreadPr({
threadBranch: thread.branch,
gitStatus: gitStatus.data,
});
const prState = pr?.state ?? null;

// Same semantics as v1 (never-visited counts as read): flipping the beta
// flag must not light up every historical thread as unread.
const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
const status = resolveSidebarV2Status(thread);
// A woken thread reappears at its original position (the sort is
// deliberately static), so the pill has to carry the weight. Snoozing is
// an explicit act, so unlike Done, a never-visited woke thread still
// shows the pill; visiting clears it. An unparseable visit timestamp
// counts as never-visited — corrupt local data must not eat the wake
// signal.
// an explicit act, so the pill clears only when the user re-engages:
// reading a completion-triggered wake, clicking the pill, sending a
// message, settling, archiving — or finishing the work outright (merged
// or closed PR). Timer wakes survive a mere visit. An unparseable visit
// timestamp counts as never-visited — corrupt local data must not eat
// the wake signal.
const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate);
const isWoke =
wokeAtDate !== null &&
(lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
prState !== "merged" &&
prState !== "closed";
// In-flight rows (working, or waiting on approval/input) fade as a whole:
// there is nothing for the user to do yet, so prominence is reserved for
// rows that need a human — done (unread), read-but-unsettled, failed, and
Expand Down Expand Up @@ -546,30 +567,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
: null;
const isWokeStatus = topStatus?.icon === "woke";

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
(thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
? vcsEnvironment.status({
environmentId: thread.environmentId,
input: { cwd: gitCwd },
})
: null,
);
const branchMismatch = resolveLocalCheckoutBranchMismatch({
effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
activeWorktreePath: thread.worktreePath,
activeThreadBranch: thread.branch,
currentGitBranch: gitStatus.data?.refName ?? null,
});
const pr = resolveThreadPr({
threadBranch: thread.branch,
gitStatus: gitStatus.data,
});
const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
// Report the PR state up: the parent partitions rows with effectiveSettled,
// and a merged/closed PR auto-settles a thread — data only rows have.
const prState = pr?.state ?? null;
useEffect(() => {
onChangeRequestState(threadKey, prState);
}, [onChangeRequestState, prState, threadKey]);
Expand Down
Loading