From 2228e658cc822d928e7157cb4ac96432ffd3d5ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 18:22:30 -0700 Subject: [PATCH 1/2] Show archive action on hover and confirm archive focus - Reveal thread archive controls only while hovering or focusing the row - Keep the confirm action accessible after starting archive confirmation - Add coverage for hover hide/show and confirm button behavior --- apps/web/src/components/ChatView.browser.tsx | 81 ++++++++++ apps/web/src/components/Sidebar.tsx | 157 +++++++++++-------- 2 files changed, 171 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 06cbf0efbd..56dde6cab2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1760,6 +1760,87 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("hides the archive action when the pointer leaves a thread row", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-archive-hover-test" as MessageId, + targetText: "archive hover target", + }), + }); + + try { + const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); + + await expect.element(threadRow).toBeInTheDocument(); + const archiveButton = await waitForElement( + () => + document.querySelector(`[data-testid="thread-archive-${THREAD_ID}"]`), + "Unable to find archive button.", + ); + const archiveAction = archiveButton.parentElement; + expect( + archiveAction, + "Archive button should render inside a visibility wrapper.", + ).not.toBeNull(); + expect(getComputedStyle(archiveAction!).opacity).toBe("0"); + + await threadRow.hover(); + await vi.waitFor( + () => { + expect(getComputedStyle(archiveAction!).opacity).toBe("1"); + }, + { timeout: 4_000, interval: 16 }, + ); + + await page.getByTestId("composer-editor").hover(); + await vi.waitFor( + () => { + expect(getComputedStyle(archiveAction!).opacity).toBe("0"); + }, + { timeout: 4_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("shows the confirm archive action after clicking the archive button", async () => { + localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + confirmThreadArchive: true, + }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-archive-confirm-test" as MessageId, + targetText: "archive confirm target", + }), + }); + + try { + const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); + + await expect.element(threadRow).toBeInTheDocument(); + await threadRow.hover(); + + const archiveButton = page.getByTestId(`thread-archive-${THREAD_ID}`); + await expect.element(archiveButton).toBeInTheDocument(); + await archiveButton.click(); + + const confirmButton = page.getByTestId(`thread-archive-confirm-${THREAD_ID}`); + await expect.element(confirmButton).toBeInTheDocument(); + await expect.element(confirmButton).toBeVisible(); + } finally { + localStorage.removeItem("t3code:client-settings:v1"); + await mounted.cleanup(); + } + }); + it("keeps the new thread selected after clicking the new-thread button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 100d0e3f47..90c548bcb1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -356,7 +356,6 @@ export default function Sidebar() { const addProjectInputRef = useRef(null); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); - const [hoveredThreadId, setHoveredThreadId] = useState(null); const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState(null); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet @@ -364,6 +363,7 @@ export default function Sidebar() { const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); + const confirmArchiveButtonRefs = useRef(new Map()); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); @@ -1222,24 +1222,36 @@ export default function Sidebar() { const terminalStatus = terminalStatusFromRunningIds( selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, ); + const isConfirmingArchive = confirmingArchiveThreadId === thread.id && !isThreadRunning; + const threadMetaClassName = isConfirmingArchive + ? "pointer-events-none opacity-0" + : !isThreadRunning + ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" + : "pointer-events-none"; return ( { - setHoveredThreadId(thread.id); - }} onMouseLeave={() => { - setHoveredThreadId((current) => (current === thread.id ? null : current)); setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); }} + onBlurCapture={(event) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + }); + }} > } size="sm" isActive={isActive} + data-testid={`thread-row-${thread.id}`} className={`${resolveThreadRowClassName({ isActive, isSelected, @@ -1254,7 +1266,6 @@ export default function Sidebar() { }} onContextMenu={(event) => { event.preventDefault(); - setHoveredThreadId((current) => (current === thread.id ? null : current)); if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { void handleMultiSelectContextMenu({ x: event.clientX, @@ -1352,12 +1363,20 @@ export default function Sidebar() { )}
- {confirmingArchiveThreadId === thread.id && !isThreadRunning ? ( + {isConfirmingArchive ? ( - ) : hoveredThreadId === thread.id && !isThreadRunning ? ( + ) : !isThreadRunning ? ( appSettings.confirmThreadArchive ? ( - +
+ +
) : ( { - event.stopPropagation(); - }} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - setHoveredThreadId((current) => - current === thread.id ? null : current, - ); - void attemptArchiveThread(thread.id); - }} - > - - +
+ +
} /> Archive
) - ) : ( - <> - {showThreadJumpHints && jumpLabel ? ( - - {jumpLabel} - - ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - - )} - - )} + ) : null} + + {showThreadJumpHints && jumpLabel ? ( + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} + + )} +
From 88d88ba335415437ee4516e80bac0ca07deb1549 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 18:24:32 -0700 Subject: [PATCH 2/2] Fix archive action hover layering in sidebar - Add isolate to thread rows so the archive control layers correctly - Remove unnecessary z-index from archive confirm affordances --- apps/web/src/components/Sidebar.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 90c548bcb1..14eddf1bb2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1255,7 +1255,7 @@ export default function Sidebar() { className={`${resolveThreadRowClassName({ isActive, isSelected, - })} relative`} + })} relative isolate`} onClick={(event) => { handleThreadClick(event, thread.id, orderedProjectThreadIds); }} @@ -1376,7 +1376,7 @@ export default function Sidebar() { data-thread-selection-safe data-testid={`thread-archive-confirm-${thread.id}`} aria-label={`Confirm archive ${thread.title}`} - className="absolute top-1/2 right-1 z-10 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40" + className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40" onPointerDown={(event) => { event.stopPropagation(); }} @@ -1393,7 +1393,7 @@ export default function Sidebar() { ) : !isThreadRunning ? ( appSettings.confirmThreadArchive ? ( -
+