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..14eddf1bb2 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,28 +1222,40 @@ 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, - })} relative`} + })} relative isolate`} onClick={(event) => { handleThreadClick(event, thread.id, orderedProjectThreadIds); }} @@ -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,10 +1363,18 @@ 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)} + + )} +