From d25a0bebb3fa62d9a1c9e10ca4fda61c4866d01c Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:10:20 +0200 Subject: [PATCH 1/2] feat(web): collapsible Settled shelf when hide settled on classic Recent Classic v1 recency/none no longer drops settled threads when Hide settled is on. Active threads stay in the main list; settled move to a bottom shelf (collapsible, paged, keep open route row) matching Sidebar V2. --- apps/web/src/components/Sidebar.logic.ts | 3 +- apps/web/src/components/Sidebar.tsx | 298 ++++++++++++++---- .../src/components/listEnvironmentFilter.ts | 4 +- 3 files changed, 245 insertions(+), 60 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 36218b67f71..c4f3051292f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -62,7 +62,8 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; // Settled-tail paging: recent history is the common lookup; the deep tail -// stays behind an explicit Show more. Shared by SidebarV2 and the board. +// stays behind an explicit Show more. Shared by SidebarV2, classic Recent +// (hide-settled shelf), and the board. export const SETTLED_TAIL_INITIAL_COUNT = 10; export const SETTLED_TAIL_PAGE_COUNT = 25; export type SidebarNewThreadEnvMode = "local" | "worktree"; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 858ee7cfe01..c14acc75def 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { ArchiveIcon, ArrowUpDownIcon, BotIcon, + ChevronDownIcon, ChevronRightIcon, CloudIcon, ContainerIcon, @@ -13,6 +14,7 @@ import { ListFilterIcon, LoaderIcon, PinIcon, + PlusIcon, SearchIcon, ServerIcon, SettingsIcon, @@ -214,8 +216,11 @@ import { resolveThreadStatusPill, isThreadSettledForDisplay, orderItemsByPreferredIds, + SETTLED_TAIL_INITIAL_COUNT, + SETTLED_TAIL_PAGE_COUNT, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, + sortSettledThreadsForSidebarV2, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; @@ -230,6 +235,7 @@ import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings" import { DEFAULT_HIDE_SETTLED_PROJECTS, DEFAULT_HIDE_SETTLED_RECENT, + DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED, DEFAULT_WEB_LIST_MODE, DEFAULT_WEB_THREAD_GROUPING, EMPTY_LIST_ENVIRONMENT_FILTER, @@ -243,6 +249,7 @@ import { ListEnvironmentFilterSchema, ListHideSettledSchema, ListProjectFilterSchema, + SIDEBAR_V2_SETTLED_SHELF_EXPANDED_STORAGE_KEY, WEB_LIST_MODE_LABELS, WEB_LIST_MODES, WEB_THREAD_GROUPING_LABELS, @@ -3646,6 +3653,11 @@ const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { * than one non-empty bucket is present (Last Hour / Earlier Today / …). */ groupByRecency: boolean; + /** + * When true, settled threads leave the main list and sit in a collapsible + * shelf at the bottom (same idea as Sidebar V2 — out of the way, never gone). + */ + hideSettledThreads: boolean; routeThreadKey: string | null; navigateToThread: (threadRef: ScopedThreadRef) => void; handleNewThread: ReturnType; @@ -3657,6 +3669,119 @@ const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { threadJumpLabelByKey: ReadonlyMap; threadByKey: ReadonlyMap; }) { + const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( + SIDEBAR_V2_SETTLED_SHELF_EXPANDED_STORAGE_KEY, + DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED, + ListHideSettledSchema, + ); + const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); + + const { activeEntries, settledEntries } = useMemo(() => { + if (!props.hideSettledThreads) { + return { + activeEntries: props.recentThreads, + settledEntries: [] as SidebarRecentThread[], + }; + } + const active: SidebarRecentThread[] = []; + const settled: SidebarRecentThread[] = []; + for (const entry of props.recentThreads) { + const threadKey = scopedThreadKey( + scopeThreadRef(entry.thread.environmentId, entry.thread.id), + ); + if (props.settledThreadKeys.has(threadKey)) { + settled.push(entry); + } else { + active.push(entry); + } + } + // Settled is history: order by when work ended, matching V2 shelf sort. + if (settled.length <= 1) { + return { activeEntries: active, settledEntries: settled }; + } + const sortedThreads = sortSettledThreadsForSidebarV2(settled.map((entry) => entry.thread)); + const entryByKey = new Map( + settled.map((entry) => [ + scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), + entry, + ]), + ); + return { + activeEntries: active, + settledEntries: sortedThreads.flatMap((thread) => { + const entry = entryByKey.get( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + return entry ? [entry] : []; + }), + }; + }, [props.hideSettledThreads, props.recentThreads, props.settledThreadKeys]); + + // When hide-settled turns off or the settled tail empties, drop a deep page + // so the next shelf open starts from the initial window again. + const settledPagingActive = props.hideSettledThreads && settledEntries.length > 0; + const lastSettledPagingActiveRef = useRef(settledPagingActive); + if (lastSettledPagingActiveRef.current !== settledPagingActive) { + lastSettledPagingActiveRef.current = settledPagingActive; + if (!settledPagingActive && settledVisibleCount !== SETTLED_TAIL_INITIAL_COUNT) { + setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); + } + } + + const pagedSettledEntries = useMemo(() => { + if (settledEntries.length <= settledVisibleCount) return settledEntries; + const visible = settledEntries.slice(0, settledVisibleCount); + // Open thread must stay reachable under "Show more". + if (props.routeThreadKey !== null) { + const routeEntry = settledEntries + .slice(settledVisibleCount) + .find( + (entry) => + scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)) === + props.routeThreadKey, + ); + if (routeEntry !== undefined) visible.push(routeEntry); + } + return visible; + }, [props.routeThreadKey, settledEntries, settledVisibleCount]); + + const renderedSettledEntries = useMemo(() => { + if (!props.hideSettledThreads || settledEntries.length === 0) return []; + if (settledShelfExpanded) return pagedSettledEntries; + if (props.routeThreadKey === null) return []; + const routeEntry = pagedSettledEntries.find( + (entry) => + scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)) === + props.routeThreadKey, + ); + return routeEntry === undefined ? [] : [routeEntry]; + }, [ + pagedSettledEntries, + props.hideSettledThreads, + props.routeThreadKey, + settledEntries.length, + settledShelfExpanded, + ]); + + const hiddenSettledCount = settledEntries.length - pagedSettledEntries.length; + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), + [], + ); + const toggleSettledShelf = useCallback( + () => setSettledShelfExpanded((value) => !value), + [setSettledShelfExpanded], + ); + + // Multi-select range walks rendered rows only (collapsed shelf is out). + const orderedRecentThreadKeys = useMemo( + () => + [...activeEntries, ...renderedSettledEntries].map(({ thread }) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [activeEntries, renderedSettledEntries], + ); + if (props.recentThreads.length === 0) { return ( @@ -3664,9 +3789,6 @@ const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { ); } - const orderedRecentThreadKeys = props.recentThreads.map(({ thread }) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); const renderThreadRow = (entry: SidebarRecentThread) => { const threadKey = scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)); @@ -3689,55 +3811,111 @@ const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { ); }; - if (!props.groupByRecency) { - return ( - - - {props.recentThreads.map(renderThreadRow)} - - + const renderActiveList = () => { + if (activeEntries.length === 0) { + return null; + } + + if (!props.groupByRecency) { + return ( + + + {activeEntries.map(renderThreadRow)} + + + ); + } + + const recencyGroups = groupSortedThreadsByRecency(activeEntries.map((entry) => entry.thread)); + const showSectionHeaders = shouldShowRecencySectionHeaders(recencyGroups); + const entryByThreadKey = new Map( + activeEntries.map((entry) => [ + scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), + entry, + ]), ); - } - const recencyGroups = groupSortedThreadsByRecency( - props.recentThreads.map((entry) => entry.thread), - ); - const showSectionHeaders = shouldShowRecencySectionHeaders(recencyGroups); - const entryByThreadKey = new Map( - props.recentThreads.map((entry) => [ - scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), - entry, - ]), - ); + // Single non-empty bucket: skip headers (e.g. everything is "Last Hour"). + if (!showSectionHeaders) { + return ( + + + {activeEntries.map(renderThreadRow)} + + + ); + } + + return ( + <> + {recencyGroups.map((group) => ( + +
+ {group.label} +
+ + {group.threads.flatMap((thread) => { + const entry = entryByThreadKey.get( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + return entry ? [renderThreadRow(entry)] : []; + })} + +
+ ))} + + ); + }; - // Single non-empty bucket: skip headers (e.g. everything is "Last Hour"). - if (!showSectionHeaders) { + const renderSettledShelf = () => { + if (!props.hideSettledThreads || settledEntries.length === 0) { + return null; + } return ( - - {props.recentThreads.map(renderThreadRow)} - + + {renderedSettledEntries.length > 0 ? ( + + {renderedSettledEntries.map(renderThreadRow)} + + ) : null} + {settledShelfExpanded && hiddenSettledCount > 0 ? ( + + ) : null} ); - } + }; return ( <> - {recencyGroups.map((group) => ( - -
- {group.label} -
- - {group.threads.flatMap((thread) => { - const entry = entryByThreadKey.get( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - return entry ? [renderThreadRow(entry)] : []; - })} - -
- ))} + {renderActiveList()} + {renderSettledShelf()} ); }); @@ -4229,6 +4407,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( (() => { const memberKeysForSelectedProject = selectedProjectFilterKey === null @@ -4854,14 +5037,6 @@ export default function Sidebar() { ).map((member) => scopedProjectKey(scopeProjectRef(member.environmentId, member.id))), ); return sortThreads(visibleThreads, "updated_at").flatMap((thread) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - if ( - usesFlatThreadGrouping(storedThreadGrouping) && - hideSettledRecent && - settledThreadKeys.has(threadKey) - ) { - return []; - } const memberKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); const physicalKey = projectPhysicalKeyByScopedRef.get(memberKey) ?? memberKey; const projectKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; @@ -4876,22 +5051,29 @@ export default function Sidebar() { return project ? [{ thread, project }] : []; }); }, [ - hideSettledRecent, physicalToLogicalKey, projectPhysicalKeyByScopedRef, selectedProjectFilterKey, - settledThreadKeys, sidebarProjectByKey, sortedProjects, - storedThreadGrouping, visibleThreads, ]); + // Jump shortcuts target the main inbox only when settled are shelved — + // collapsed history shouldn't consume 1–9 slots. const recentThreadKeys = useMemo( () => - recentThreads.map(({ thread }) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - [recentThreads], + recentThreads.flatMap(({ thread }) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + if ( + hideSettledRecent && + usesFlatThreadGrouping(storedThreadGrouping) && + settledThreadKeys.has(threadKey) + ) { + return []; + } + return [threadKey]; + }), + [hideSettledRecent, recentThreads, settledThreadKeys, storedThreadGrouping], ); const visibleSidebarThreadKeys = useMemo( () => diff --git a/apps/web/src/components/listEnvironmentFilter.ts b/apps/web/src/components/listEnvironmentFilter.ts index 5ba09e56d69..5a34ff17f5a 100644 --- a/apps/web/src/components/listEnvironmentFilter.ts +++ b/apps/web/src/components/listEnvironmentFilter.ts @@ -93,7 +93,9 @@ export const LIST_THREAD_GROUPING_STORAGE_KEY = "t3code:list:thread-grouping:v1" export const LIST_PROJECT_FILTER_STORAGE_KEY = "t3code:list:project-filter:v1"; export const LIST_PROJECT_FILTER_ALL = "all"; /** - * Per organization: when true, settled threads are omitted. + * Per organization: when true, settled threads leave the main list. + * Classic recency/none shelves them in a collapsible Settled section (like V2); + * project groups still omit them from each project’s thread list. * Recency/none default to hide (cleaner inbox); project groups default to show. */ export const LIST_HIDE_SETTLED_RECENT_STORAGE_KEY = "t3code:list:hide-settled-recent:v1"; From a2231e66465f766be64ed247b18bc1286e9d2b6b Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:20:43 +0200 Subject: [PATCH 2/2] fix(desktop): stub onBeforeQuitForUpdate in DesktopUpdates tests Unblocks package typecheck after ElectronApp gained onBeforeQuitForUpdate; other desktop test mocks already stubbed it. --- apps/desktop/src/updates/DesktopUpdates.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 26bd86668cc..1c9298f3c13 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -136,6 +136,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, on: () => Effect.void as any, } satisfies ElectronApp.ElectronApp["Service"]);