diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9db3baee5bb8..bdb2d55f4f19 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1294,6 +1294,51 @@ describe("isThreadSettledForDisplay", () => { }), ).toBe(true); }); + + it("auto-settles on merged/closed PR when the server supports settlement", () => { + const serverConfigs = { + get(_environmentId: string) { + return { + environment: { + capabilities: { threadSettlement: true }, + }, + }; + }, + }; + // Activity well past the queued-turn grace window so PR auto-settle is + // not blocked by a just-sent message without a turn. + const activeThread = { + ...baseThread, + settledOverride: null, + settledAt: null, + latestUserMessageAt: "2026-04-01T00:00:00.000Z", + }; + + expect( + isThreadSettledForDisplay(activeThread, { + serverConfigs, + now, + autoSettleAfterDays: null, + changeRequestState: "merged", + }), + ).toBe(true); + expect( + isThreadSettledForDisplay(activeThread, { + serverConfigs, + now, + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBe(true); + expect( + isThreadSettledForDisplay(activeThread, { + serverConfigs, + now, + autoSettleAfterDays: null, + changeRequestState: null, + }), + ).toBe(false); + }); }); describe("groupSettledThreadsByRecencyForSidebarV2", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ce1fe8a020b4..46c67e02e3c7 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -424,8 +424,8 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { } /** - * Shared settled classification for display surfaces (sidebar v2, board), so - * they always agree on what is settled. Threads on servers without the + * Shared settled classification for display surfaces (sidebar v1/v2, board), + * so they always agree on what is settled. Threads on servers without the * settlement capability (old server, or descriptor not loaded yet) never * classify as settled: the user could neither un-settle nor pin them, so * auto-settling them would strand rows in a tail with no working affordances. diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 52d0aa0ff666..b2b96fddb559 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -37,7 +37,7 @@ import { hasComposerDraftMessage, useComposerDraftStore } from "../composerDraft import { ProjectFavicon, ProjectFaviconFallback } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; -import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; +import React, { useCallback, useContext, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { DndContext, @@ -295,6 +295,20 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; +/** + * Active sidebar rows report resolved PR state upward so hide-settled / + * settled-shelf classification can auto-settle merged/closed PRs the same way + * Sidebar V2 and the board do. Settled history rows skip reporting. + */ +type SidebarChangeRequestStateReporter = ( + threadKey: string, + state: "open" | "closed" | "merged" | null, +) => void; +const noopSidebarChangeRequestStateReporter: SidebarChangeRequestStateReporter = () => {}; +const SidebarChangeRequestStateContext = React.createContext( + noopSidebarChangeRequestStateReporter, +); + /** * Reveal provider details while Command/Control is held when the compact * sidebar setting normally hides them. @@ -576,6 +590,13 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr gitStatus: gitStatus.data ?? null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + // Lift PR state so parent hide-settled / shelf classification can auto-settle + // merged/closed PRs (matches Sidebar V2 row reporting). + const onChangeRequestState = useContext(SidebarChangeRequestStateContext); + const prState = pr?.state ?? null; + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -3065,7 +3086,7 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); const gitCwd = thread.worktreePath ?? project.workspaceRoot; // Settled shelf rows match Sidebar V2 history: no list VCS subscription - // (PR auto-settle is out of scope here; badges aren't live on history). + // (PR auto-settle already applied or isn't needed; badges aren't live on history). const gitStatus = useEnvironmentQuery( !props.isSettled && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.listStatus({ @@ -3079,6 +3100,13 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { gitStatus: gitStatus.data ?? null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + // Report PR state for partition; settled history keeps last reported value. + const onChangeRequestState = useContext(SidebarChangeRequestStateContext); + const prState = pr?.state ?? null; + useEffect(() => { + if (props.isSettled) return; + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, props.isSettled, threadKey]); const threadModelPresentation = useMemo( () => resolveThreadModelPresentation( @@ -5261,23 +5289,45 @@ export default function Sidebar() { visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next classification pass — same + // path Sidebar V2 and the board use so hide-settled matches across surfaces. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); const settledThreadKeys = useMemo(() => { const now = `${nowMinute}:00.000Z`; const keys = new Set(); for (const thread of visibleThreads) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); if ( isThreadSettledForDisplay(thread, { serverConfigs, now, autoSettleAfterDays, - changeRequestState: null, + changeRequestState: changeRequestStateByKey.get(threadKey) ?? null, }) ) { - keys.add(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); + keys.add(threadKey); } } return keys; - }, [autoSettleAfterDays, nowMinute, serverConfigs, visibleThreads]); + }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, serverConfigs, visibleThreads]); const selectedProjectFilterKey = storedProjectFilter !== null && sortedProjects.some((project) => project.projectKey === storedProjectFilter) @@ -5672,7 +5722,7 @@ export default function Sidebar() { }, []); return ( - <> + {prewarmedSidebarThreadRefs.map((threadRef) => ( ))} @@ -5739,6 +5789,6 @@ export default function Sidebar() { )} - + ); } diff --git a/apps/web/src/forkSurfaceExistence.test.ts b/apps/web/src/forkSurfaceExistence.test.ts index 0a4d03a10d19..d79c6b50b4d5 100644 --- a/apps/web/src/forkSurfaceExistence.test.ts +++ b/apps/web/src/forkSurfaceExistence.test.ts @@ -28,6 +28,11 @@ describe("fork surface existence (anti stack-drop)", () => { expect(sidebar).toContain("recent-thread-settled-"); expect(sidebar).toContain("Un-settle thread"); expect(sidebar).toContain("!props.isSettled"); + // Hide-settled must use row-lifted PR state (merged/closed auto-settle), + // same as Sidebar V2 — never hard-code changeRequestState: null here. + expect(sidebar).toContain("changeRequestStateByKey"); + expect(sidebar).toContain("SidebarChangeRequestStateContext"); + expect(sidebar).not.toContain("changeRequestState: null"); }); it("Sidebar V2 keeps Settled shelf labeling and new-thread affordance", () => {