authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-29 18:36:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-29 18:36:53-07:00
log59f23d7bfd71f12ea0a9fff7e060206b1b0a344f
treea3b97a0f3c999db4330fe8b8ba19d9ed37a57ce6
parent4a4795e5f0f0083e16de64b86b499d8cca718b52
signature Signed by PGP key B5690EEEBB952194

Show archive action on hover with confirm focus (#1561)


2 files changed, 171 insertions(+), 67 deletions(-)

apps/web/src/components/ChatView.browser.tsx+81
......@@ -1773,6 +1773,87 @@ describe("ChatView timeline estimator parity (full app)", () => {
17731773 }
17741774 });
17751775
1776 it("hides the archive action when the pointer leaves a thread row", async () => {
1777 const mounted = await mountChatView({
1778 viewport: DEFAULT_VIEWPORT,
1779 snapshot: createSnapshotForTargetUser({
1780 targetMessageId: "msg-user-archive-hover-test" as MessageId,
1781 targetText: "archive hover target",
1782 }),
1783 });
1784
1785 try {
1786 const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`);
1787
1788 await expect.element(threadRow).toBeInTheDocument();
1789 const archiveButton = await waitForElement(
1790 () =>
1791 document.querySelector<HTMLButtonElement>(`[data-testid="thread-archive-${THREAD_ID}"]`),
1792 "Unable to find archive button.",
1793 );
1794 const archiveAction = archiveButton.parentElement;
1795 expect(
1796 archiveAction,
1797 "Archive button should render inside a visibility wrapper.",
1798 ).not.toBeNull();
1799 expect(getComputedStyle(archiveAction!).opacity).toBe("0");
1800
1801 await threadRow.hover();
1802 await vi.waitFor(
1803 () => {
1804 expect(getComputedStyle(archiveAction!).opacity).toBe("1");
1805 },
1806 { timeout: 4_000, interval: 16 },
1807 );
1808
1809 await page.getByTestId("composer-editor").hover();
1810 await vi.waitFor(
1811 () => {
1812 expect(getComputedStyle(archiveAction!).opacity).toBe("0");
1813 },
1814 { timeout: 4_000, interval: 16 },
1815 );
1816 } finally {
1817 await mounted.cleanup();
1818 }
1819 });
1820
1821 it("shows the confirm archive action after clicking the archive button", async () => {
1822 localStorage.setItem(
1823 "t3code:client-settings:v1",
1824 JSON.stringify({
1825 ...DEFAULT_CLIENT_SETTINGS,
1826 confirmThreadArchive: true,
1827 }),
1828 );
1829
1830 const mounted = await mountChatView({
1831 viewport: DEFAULT_VIEWPORT,
1832 snapshot: createSnapshotForTargetUser({
1833 targetMessageId: "msg-user-archive-confirm-test" as MessageId,
1834 targetText: "archive confirm target",
1835 }),
1836 });
1837
1838 try {
1839 const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`);
1840
1841 await expect.element(threadRow).toBeInTheDocument();
1842 await threadRow.hover();
1843
1844 const archiveButton = page.getByTestId(`thread-archive-${THREAD_ID}`);
1845 await expect.element(archiveButton).toBeInTheDocument();
1846 await archiveButton.click();
1847
1848 const confirmButton = page.getByTestId(`thread-archive-confirm-${THREAD_ID}`);
1849 await expect.element(confirmButton).toBeInTheDocument();
1850 await expect.element(confirmButton).toBeVisible();
1851 } finally {
1852 localStorage.removeItem("t3code:client-settings:v1");
1853 await mounted.cleanup();
1854 }
1855 });
1856
17761857 it("keeps the new thread selected after clicking the new-thread button", async () => {
17771858 const mounted = await mountChatView({
17781859 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/Sidebar.tsx+90-67
......@@ -396,7 +396,6 @@ export default function Sidebar() {
396396 const addProjectInputRef = useRef<HTMLInputElement | null>(null);
397397 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
398398 const [renamingTitle, setRenamingTitle] = useState("");
399 const [hoveredThreadId, setHoveredThreadId] = useState<ThreadId | null>(null);
400399 const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);
401400 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
402401 ReadonlySet<ProjectId>
......@@ -404,6 +403,7 @@ export default function Sidebar() {
404403 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
405404 const renamingCommittedRef = useRef(false);
406405 const renamingInputRef = useRef<HTMLInputElement | null>(null);
406 const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>());
407407 const dragInProgressRef = useRef(false);
408408 const suppressProjectClickAfterDragRef = useRef(false);
409409 const suppressProjectClickForContextMenuRef = useRef(false);
......@@ -1272,28 +1272,40 @@ export default function Sidebar() {
12721272 const terminalStatus = terminalStatusFromRunningIds(
12731273 selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds,
12741274 );
1275 const isConfirmingArchive = confirmingArchiveThreadId === thread.id && !isThreadRunning;
1276 const threadMetaClassName = isConfirmingArchive
1277 ? "pointer-events-none opacity-0"
1278 : !isThreadRunning
1279 ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"
1280 : "pointer-events-none";
12751281
12761282 return (
12771283 <SidebarMenuSubItem
12781284 key={thread.id}
12791285 className="w-full"
12801286 data-thread-item
1281 onMouseEnter={() => {
1282 setHoveredThreadId(thread.id);
1283 }}
12841287 onMouseLeave={() => {
1285 setHoveredThreadId((current) => (current === thread.id ? null : current));
12861288 setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
12871289 }}
1290 onBlurCapture={(event) => {
1291 const currentTarget = event.currentTarget;
1292 requestAnimationFrame(() => {
1293 if (currentTarget.contains(document.activeElement)) {
1294 return;
1295 }
1296 setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
1297 });
1298 }}
12881299 >
12891300 <SidebarMenuSubButton
12901301 render={<div role="button" tabIndex={0} />}
12911302 size="sm"
12921303 isActive={isActive}
1304 data-testid={`thread-row-${thread.id}`}
12931305 className={`${resolveThreadRowClassName({
12941306 isActive,
12951307 isSelected,
1296 })} relative`}
1308 })} relative isolate`}
12971309 onClick={(event) => {
12981310 handleThreadClick(event, thread.id, orderedProjectThreadIds);
12991311 }}
......@@ -1304,7 +1316,6 @@ export default function Sidebar() {
13041316 }}
13051317 onContextMenu={(event) => {
13061318 event.preventDefault();
1307 setHoveredThreadId((current) => (current === thread.id ? null : current));
13081319 if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {
13091320 void handleMultiSelectContextMenu({
13101321 x: event.clientX,
......@@ -1391,10 +1402,18 @@ export default function Sidebar() {
13911402 </span>
13921403 )}
13931404 <div className="flex min-w-12 justify-end">
1394 {confirmingArchiveThreadId === thread.id && !isThreadRunning ? (
1405 {isConfirmingArchive ? (
13951406 <button
1407 ref={(element) => {
1408 if (element) {
1409 confirmArchiveButtonRefs.current.set(thread.id, element);
1410 } else {
1411 confirmArchiveButtonRefs.current.delete(thread.id);
1412 }
1413 }}
13961414 type="button"
13971415 data-thread-selection-safe
1416 data-testid={`thread-archive-confirm-${thread.id}`}
13981417 aria-label={`Confirm archive ${thread.title}`}
13991418 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"
14001419 onPointerDown={(event) => {
......@@ -1406,80 +1425,84 @@ export default function Sidebar() {
14061425 setConfirmingArchiveThreadId((current) =>
14071426 current === thread.id ? null : current,
14081427 );
1409 setHoveredThreadId((current) => (current === thread.id ? null : current));
14101428 void attemptArchiveThread(thread.id);
14111429 }}
14121430 >
14131431 Confirm
14141432 </button>
1415 ) : hoveredThreadId === thread.id && !isThreadRunning ? (
1433 ) : !isThreadRunning ? (
14161434 appSettings.confirmThreadArchive ? (
1417 <button
1418 type="button"
1419 data-thread-selection-safe
1420 aria-label={`Archive ${thread.title}`}
1421 className="absolute top-1/2 right-1 inline-flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
1422 onPointerDown={(event) => {
1423 event.stopPropagation();
1424 }}
1425 onClick={(event) => {
1426 event.preventDefault();
1427 event.stopPropagation();
1428 setConfirmingArchiveThreadId(thread.id);
1429 }}
1430 >
1431 <ArchiveIcon className="size-3.5" />
1432 </button>
1435 <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
1436 <button
1437 type="button"
1438 data-thread-selection-safe
1439 data-testid={`thread-archive-${thread.id}`}
1440 aria-label={`Archive ${thread.title}`}
1441 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
1442 onPointerDown={(event) => {
1443 event.stopPropagation();
1444 }}
1445 onClick={(event) => {
1446 event.preventDefault();
1447 event.stopPropagation();
1448 setConfirmingArchiveThreadId(thread.id);
1449 requestAnimationFrame(() => {
1450 confirmArchiveButtonRefs.current.get(thread.id)?.focus();
1451 });
1452 }}
1453 >
1454 <ArchiveIcon className="size-3.5" />
1455 </button>
1456 </div>
14331457 ) : (
14341458 <Tooltip>
14351459 <TooltipTrigger
14361460 render={
1437 <button
1438 type="button"
1439 data-thread-selection-safe
1440 aria-label={`Archive ${thread.title}`}
1441 className="absolute top-1/2 right-1 inline-flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
1442 onPointerDown={(event) => {
1443 event.stopPropagation();
1444 }}
1445 onClick={(event) => {
1446 event.preventDefault();
1447 event.stopPropagation();
1448 setHoveredThreadId((current) =>
1449 current === thread.id ? null : current,
1450 );
1451 void attemptArchiveThread(thread.id);
1452 }}
1453 >
1454 <ArchiveIcon className="size-3.5" />
1455 </button>
1461 <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
1462 <button
1463 type="button"
1464 data-thread-selection-safe
1465 data-testid={`thread-archive-${thread.id}`}
1466 aria-label={`Archive ${thread.title}`}
1467 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
1468 onPointerDown={(event) => {
1469 event.stopPropagation();
1470 }}
1471 onClick={(event) => {
1472 event.preventDefault();
1473 event.stopPropagation();
1474 void attemptArchiveThread(thread.id);
1475 }}
1476 >
1477 <ArchiveIcon className="size-3.5" />
1478 </button>
1479 </div>
14561480 }
14571481 />
14581482 <TooltipPopup side="top">Archive</TooltipPopup>
14591483 </Tooltip>
14601484 )
1461 ) : (
1462 <>
1463 {showThreadJumpHints && jumpLabel ? (
1464 <span
1465 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
1466 title={jumpLabel}
1467 >
1468 {jumpLabel}
1469 </span>
1470 ) : (
1471 <span
1472 className={`text-[10px] ${
1473 isHighlighted
1474 ? "text-foreground/72 dark:text-foreground/82"
1475 : "text-muted-foreground/40"
1476 }`}
1477 >
1478 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
1479 </span>
1480 )}
1481 </>
1482 )}
1485 ) : null}
1486 <span className={threadMetaClassName}>
1487 {showThreadJumpHints && jumpLabel ? (
1488 <span
1489 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
1490 title={jumpLabel}
1491 >
1492 {jumpLabel}
1493 </span>
1494 ) : (
1495 <span
1496 className={`text-[10px] ${
1497 isHighlighted
1498 ? "text-foreground/72 dark:text-foreground/82"
1499 : "text-muted-foreground/40"
1500 }`}
1501 >
1502 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
1503 </span>
1504 )}
1505 </span>
14831506 </div>
14841507 </div>
14851508 </SidebarMenuSubButton>