Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>(`[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,
Expand Down
157 changes: 90 additions & 67 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -356,14 +356,14 @@ export default function Sidebar() {
const addProjectInputRef = useRef<HTMLInputElement | null>(null);
const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
const [renamingTitle, setRenamingTitle] = useState("");
const [hoveredThreadId, setHoveredThreadId] = useState<ThreadId | null>(null);
const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
ReadonlySet<ProjectId>
>(() => new Set());
const [showThreadJumpHints, setShowThreadJumpHints] = useState(false);
const renamingCommittedRef = useRef(false);
const renamingInputRef = useRef<HTMLInputElement | null>(null);
const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>());
const dragInProgressRef = useRef(false);
const suppressProjectClickAfterDragRef = useRef(false);
const suppressProjectClickForContextMenuRef = useRef(false);
Expand Down Expand Up @@ -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 (
<SidebarMenuSubItem
key={thread.id}
className="w-full"
data-thread-item
onMouseEnter={() => {
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));
});
}}
>
<SidebarMenuSubButton
render={<div role="button" tabIndex={0} />}
size="sm"
isActive={isActive}
data-testid={`thread-row-${thread.id}`}
className={`${resolveThreadRowClassName({
isActive,
isSelected,
})} relative`}
})} relative isolate`}
onClick={(event) => {
handleThreadClick(event, thread.id, orderedProjectThreadIds);
}}
Expand All @@ -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,
Expand Down Expand Up @@ -1352,10 +1363,18 @@ export default function Sidebar() {
</span>
)}
<div className="flex min-w-12 justify-end">
{confirmingArchiveThreadId === thread.id && !isThreadRunning ? (
{isConfirmingArchive ? (
<button
ref={(element) => {
if (element) {
confirmArchiveButtonRefs.current.set(thread.id, element);
} else {
confirmArchiveButtonRefs.current.delete(thread.id);
}
}}
type="button"
data-thread-selection-safe
data-testid={`thread-archive-confirm-${thread.id}`}
aria-label={`Confirm archive ${thread.title}`}
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) => {
Expand All @@ -1367,80 +1386,84 @@ export default function Sidebar() {
setConfirmingArchiveThreadId((current) =>
current === thread.id ? null : current,
);
setHoveredThreadId((current) => (current === thread.id ? null : current));
void attemptArchiveThread(thread.id);
}}
>
Confirm
</button>
) : hoveredThreadId === thread.id && !isThreadRunning ? (
) : !isThreadRunning ? (
appSettings.confirmThreadArchive ? (
<button
type="button"
data-thread-selection-safe
aria-label={`Archive ${thread.title}`}
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"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setConfirmingArchiveThreadId(thread.id);
}}
>
<ArchiveIcon className="size-3.5" />
</button>
<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">
<button
type="button"
data-thread-selection-safe
data-testid={`thread-archive-${thread.id}`}
aria-label={`Archive ${thread.title}`}
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"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setConfirmingArchiveThreadId(thread.id);
requestAnimationFrame(() => {
confirmArchiveButtonRefs.current.get(thread.id)?.focus();
});
}}
>
<ArchiveIcon className="size-3.5" />
</button>
</div>
) : (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
data-thread-selection-safe
aria-label={`Archive ${thread.title}`}
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"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setHoveredThreadId((current) =>
current === thread.id ? null : current,
);
void attemptArchiveThread(thread.id);
}}
>
<ArchiveIcon className="size-3.5" />
</button>
<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">
<button
type="button"
data-thread-selection-safe
data-testid={`thread-archive-${thread.id}`}
aria-label={`Archive ${thread.title}`}
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"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void attemptArchiveThread(thread.id);
}}
>
<ArchiveIcon className="size-3.5" />
</button>
</div>
}
/>
<TooltipPopup side="top">Archive</TooltipPopup>
</Tooltip>
)
) : (
<>
{showThreadJumpHints && jumpLabel ? (
<span
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"
title={jumpLabel}
>
{jumpLabel}
</span>
) : (
<span
className={`text-[10px] ${
isHighlighted
? "text-foreground/72 dark:text-foreground/82"
: "text-muted-foreground/40"
}`}
>
{formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
</span>
)}
</>
)}
) : null}
<span className={threadMetaClassName}>
{showThreadJumpHints && jumpLabel ? (
<span
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"
title={jumpLabel}
>
{jumpLabel}
</span>
) : (
<span
className={`text-[10px] ${
isHighlighted
? "text-foreground/72 dark:text-foreground/82"
: "text-muted-foreground/40"
}`}
>
{formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
</span>
)}
</span>
</div>
</div>
</SidebarMenuSubButton>
Expand Down
Loading