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)", () => {...@@ -1773,6 +1773,87 @@ describe("ChatView timeline estimator parity (full app)", () => {
1773 }1773 }
1774 });1774 });
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
1776 it("keeps the new thread selected after clicking the new-thread button", async () => {1857 it("keeps the new thread selected after clicking the new-thread button", async () => {
1777 const mounted = await mountChatView({1858 const mounted = await mountChatView({
1778 viewport: DEFAULT_VIEWPORT,1859 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/Sidebar.tsx+90-67
...@@ -396,7 +396,6 @@ export default function Sidebar() {...@@ -396,7 +396,6 @@ export default function Sidebar() {
396 const addProjectInputRef = useRef<HTMLInputElement | null>(null);396 const addProjectInputRef = useRef<HTMLInputElement | null>(null);
397 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);397 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
398 const [renamingTitle, setRenamingTitle] = useState("");398 const [renamingTitle, setRenamingTitle] = useState("");
399 const [hoveredThreadId, setHoveredThreadId] = useState<ThreadId | null>(null);
400 const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);399 const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);
401 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<400 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
402 ReadonlySet<ProjectId>401 ReadonlySet<ProjectId>
...@@ -404,6 +403,7 @@ export default function Sidebar() {...@@ -404,6 +403,7 @@ export default function Sidebar() {
404 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();403 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
405 const renamingCommittedRef = useRef(false);404 const renamingCommittedRef = useRef(false);
406 const renamingInputRef = useRef<HTMLInputElement | null>(null);405 const renamingInputRef = useRef<HTMLInputElement | null>(null);
406 const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>());
407 const dragInProgressRef = useRef(false);407 const dragInProgressRef = useRef(false);
408 const suppressProjectClickAfterDragRef = useRef(false);408 const suppressProjectClickAfterDragRef = useRef(false);
409 const suppressProjectClickForContextMenuRef = useRef(false);409 const suppressProjectClickForContextMenuRef = useRef(false);
...@@ -1272,28 +1272,40 @@ export default function Sidebar() {...@@ -1272,28 +1272,40 @@ export default function Sidebar() {
1272 const terminalStatus = terminalStatusFromRunningIds(1272 const terminalStatus = terminalStatusFromRunningIds(
1273 selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds,1273 selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds,
1274 );1274 );
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
1276 return (1282 return (
1277 <SidebarMenuSubItem1283 <SidebarMenuSubItem
1278 key={thread.id}1284 key={thread.id}
1279 className="w-full"1285 className="w-full"
1280 data-thread-item1286 data-thread-item
1281 onMouseEnter={() => {
1282 setHoveredThreadId(thread.id);
1283 }}
1284 onMouseLeave={() => {1287 onMouseLeave={() => {
1285 setHoveredThreadId((current) => (current === thread.id ? null : current));
1286 setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));1288 setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
1287 }}1289 }}
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 }}
1288 >1299 >
1289 <SidebarMenuSubButton1300 <SidebarMenuSubButton
1290 render={<div role="button" tabIndex={0} />}1301 render={<div role="button" tabIndex={0} />}
1291 size="sm"1302 size="sm"
1292 isActive={isActive}1303 isActive={isActive}
1304 data-testid={`thread-row-${thread.id}`}
1293 className={`${resolveThreadRowClassName({1305 className={`${resolveThreadRowClassName({
1294 isActive,1306 isActive,
1295 isSelected,1307 isSelected,
1296 })} relative`}1308 })} relative isolate`}
1297 onClick={(event) => {1309 onClick={(event) => {
1298 handleThreadClick(event, thread.id, orderedProjectThreadIds);1310 handleThreadClick(event, thread.id, orderedProjectThreadIds);
1299 }}1311 }}
...@@ -1304,7 +1316,6 @@ export default function Sidebar() {...@@ -1304,7 +1316,6 @@ export default function Sidebar() {
1304 }}1316 }}
1305 onContextMenu={(event) => {1317 onContextMenu={(event) => {
1306 event.preventDefault();1318 event.preventDefault();
1307 setHoveredThreadId((current) => (current === thread.id ? null : current));
1308 if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {1319 if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {
1309 void handleMultiSelectContextMenu({1320 void handleMultiSelectContextMenu({
1310 x: event.clientX,1321 x: event.clientX,
...@@ -1391,10 +1402,18 @@ export default function Sidebar() {...@@ -1391,10 +1402,18 @@ export default function Sidebar() {
1391 </span>1402 </span>
1392 )}1403 )}
1393 <div className="flex min-w-12 justify-end">1404 <div className="flex min-w-12 justify-end">
1394 {confirmingArchiveThreadId === thread.id && !isThreadRunning ? (1405 {isConfirmingArchive ? (
1395 <button1406 <button
1407 ref={(element) => {
1408 if (element) {
1409 confirmArchiveButtonRefs.current.set(thread.id, element);
1410 } else {
1411 confirmArchiveButtonRefs.current.delete(thread.id);
1412 }
1413 }}
1396 type="button"1414 type="button"
1397 data-thread-selection-safe1415 data-thread-selection-safe
1416 data-testid={`thread-archive-confirm-${thread.id}`}
1398 aria-label={`Confirm archive ${thread.title}`}1417 aria-label={`Confirm archive ${thread.title}`}
1399 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"1418 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"
1400 onPointerDown={(event) => {1419 onPointerDown={(event) => {
...@@ -1406,80 +1425,84 @@ export default function Sidebar() {...@@ -1406,80 +1425,84 @@ export default function Sidebar() {
1406 setConfirmingArchiveThreadId((current) =>1425 setConfirmingArchiveThreadId((current) =>
1407 current === thread.id ? null : current,1426 current === thread.id ? null : current,
1408 );1427 );
1409 setHoveredThreadId((current) => (current === thread.id ? null : current));
1410 void attemptArchiveThread(thread.id);1428 void attemptArchiveThread(thread.id);
1411 }}1429 }}
1412 >1430 >
1413 Confirm1431 Confirm
1414 </button>1432 </button>
1415 ) : hoveredThreadId === thread.id && !isThreadRunning ? (1433 ) : !isThreadRunning ? (
1416 appSettings.confirmThreadArchive ? (1434 appSettings.confirmThreadArchive ? (
1417 <button1435 <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">
1418 type="button"1436 <button
1419 data-thread-selection-safe1437 type="button"
1420 aria-label={`Archive ${thread.title}`}1438 data-thread-selection-safe
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"1439 data-testid={`thread-archive-${thread.id}`}
1422 onPointerDown={(event) => {1440 aria-label={`Archive ${thread.title}`}
1423 event.stopPropagation();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"
1424 }}1442 onPointerDown={(event) => {
1425 onClick={(event) => {1443 event.stopPropagation();
1426 event.preventDefault();1444 }}
1427 event.stopPropagation();1445 onClick={(event) => {
1428 setConfirmingArchiveThreadId(thread.id);1446 event.preventDefault();
1429 }}1447 event.stopPropagation();
1430 >1448 setConfirmingArchiveThreadId(thread.id);
1431 <ArchiveIcon className="size-3.5" />1449 requestAnimationFrame(() => {
1432 </button>1450 confirmArchiveButtonRefs.current.get(thread.id)?.focus();
1451 });
1452 }}
1453 >
1454 <ArchiveIcon className="size-3.5" />
1455 </button>
1456 </div>
1433 ) : (1457 ) : (
1434 <Tooltip>1458 <Tooltip>
1435 <TooltipTrigger1459 <TooltipTrigger
1436 render={1460 render={
1437 <button1461 <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">
1438 type="button"1462 <button
1439 data-thread-selection-safe1463 type="button"
1440 aria-label={`Archive ${thread.title}`}1464 data-thread-selection-safe
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"1465 data-testid={`thread-archive-${thread.id}`}
1442 onPointerDown={(event) => {1466 aria-label={`Archive ${thread.title}`}
1443 event.stopPropagation();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"
1444 }}1468 onPointerDown={(event) => {
1445 onClick={(event) => {1469 event.stopPropagation();
1446 event.preventDefault();1470 }}
1447 event.stopPropagation();1471 onClick={(event) => {
1448 setHoveredThreadId((current) =>1472 event.preventDefault();
1449 current === thread.id ? null : current,1473 event.stopPropagation();
1450 );1474 void attemptArchiveThread(thread.id);
1451 void attemptArchiveThread(thread.id);1475 }}
1452 }}1476 >
1453 >1477 <ArchiveIcon className="size-3.5" />
1454 <ArchiveIcon className="size-3.5" />1478 </button>
1455 </button>1479 </div>
1456 }1480 }
1457 />1481 />
1458 <TooltipPopup side="top">Archive</TooltipPopup>1482 <TooltipPopup side="top">Archive</TooltipPopup>
1459 </Tooltip>1483 </Tooltip>
1460 )1484 )
1461 ) : (1485 ) : null}
1462 <>1486 <span className={threadMetaClassName}>
1463 {showThreadJumpHints && jumpLabel ? (1487 {showThreadJumpHints && jumpLabel ? (
1464 <span1488 <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"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"
1466 title={jumpLabel}1490 title={jumpLabel}
1467 >1491 >
1468 {jumpLabel}1492 {jumpLabel}
1469 </span>1493 </span>
1470 ) : (1494 ) : (
1471 <span1495 <span
1472 className={`text-[10px] ${1496 className={`text-[10px] ${
1473 isHighlighted1497 isHighlighted
1474 ? "text-foreground/72 dark:text-foreground/82"1498 ? "text-foreground/72 dark:text-foreground/82"
1475 : "text-muted-foreground/40"1499 : "text-muted-foreground/40"
1476 }`}1500 }`}
1477 >1501 >
1478 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}1502 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
1479 </span>1503 </span>
1480 )}1504 )}
1481 </>1505 </span>
1482 )}
1483 </div>1506 </div>
1484 </div>1507 </div>
1485 </SidebarMenuSubButton>1508 </SidebarMenuSubButton>