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
16 changes: 12 additions & 4 deletions src/components/conversations/sidebar-conversation-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,12 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
clicks don't select the conversation; `tabIndex={-1}` keeps them
mouse-only (the context menu Pin/Unpin + Status is the keyboard/
AT-accessible path). */}
<div className="flex h-full shrink-0 items-center pr-[0.5rem]">
{/* pr-[0.375rem] + the list's px-1.5 (0.375rem) puts the time
badge / hover action buttons at a uniform 0.75rem inset from the
sidebar border — the same right edge as the section-header
actions, folder-header actions, and New chat / Search shortcut
badges. */}
<div className="flex h-full shrink-0 items-center pr-[0.375rem]">
<span className="flex items-center group-hover:hidden">
{isRunning ? (
<span
Expand Down Expand Up @@ -265,7 +270,10 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
that still clears the 3:1 non-text-contrast bar over the row's
hover background; hover deepens to full foreground. The folder
⋯ button shares this exact palette so all action icons stay a
consistent two colors. */}
consistent two colors. Each button is justify-end so its 14px
glyph flushes to the slot's right edge (0.75rem) — the same edge
the default time/status badge fills — instead of sitting ~5px in
as a centred icon in a transparent box would. */}
<div className="hidden items-center gap-px group-hover:flex">
{onTogglePin && (
<button
Expand All @@ -278,7 +286,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
title={isPinned ? t("unpin") : t("pin")}
aria-label={isPinned ? t("unpin") : t("pin")}
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-[0.375rem]",
"flex h-6 w-6 shrink-0 items-center justify-end rounded-[0.375rem]",
"cursor-pointer outline-none transition-colors duration-150",
"text-muted-foreground/90 hover:text-sidebar-foreground"
)}
Expand All @@ -303,7 +311,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
title={isCompleted ? t("reopen") : t("markCompleted")}
aria-label={isCompleted ? t("reopen") : t("markCompleted")}
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-[0.375rem]",
"flex h-6 w-6 shrink-0 items-center justify-end rounded-[0.375rem]",
"cursor-pointer outline-none transition-colors duration-150",
"text-muted-foreground/90 hover:text-sidebar-foreground"
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ vi.mock("virtua", () => ({
}))

// FolderHeader renders exactly one of FolderClosed/FolderOpen in its body →
// folder re-render probe. Every other icon stays real.
// folder re-render probe. Every other icon stays real. (The Folders section
// header's Open Folder / Clone Repository actions use FolderOpenDot / FolderGit2,
// which are NOT mocked here, so they never inflate this probe.)
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>()
return {
Expand Down
37 changes: 29 additions & 8 deletions src/components/conversations/sidebar-conversation-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ import {
ChevronRight,
Download,
ExternalLink,
FolderGit2,
FolderOpen,
GitBranch,
FolderOpenDot,
ListChecks,
Loader2,
MoreHorizontal,
Expand Down Expand Up @@ -364,7 +365,7 @@ const FolderHeader = memo(function FolderHeader({
aria-label={t("moreOptions")}
aria-haspopup="menu"
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center",
"flex h-6 w-6 shrink-0 items-center justify-end",
// Shares the card action-icon palette: default /90 is the lightest
// muted shade clearing 3:1 non-text contrast (incl. on touch, where
// this stays visible); hover deepens to full foreground.
Expand All @@ -386,8 +387,14 @@ const FolderHeader = memo(function FolderHeader({
className={cn(
// Mirrors the ⋯ button's action-icon palette and hover-reveal so
// the two read as one trailing control cluster. As the rightmost
// control it carries the small right-edge margin.
"mr-[0.125rem] flex h-7 w-7 shrink-0 items-center justify-center",
// control it carries the right-edge margin that lines this cluster
// up with the other sidebar affordances: 0.375rem + the list's
// px-1.5 (0.375rem) = a uniform 0.75rem inset from the border,
// matching the section-header actions and conversation-card badges.
// h-6 (not h-7) keeps every action-icon centre on the same axis, and
// justify-end flushes the glyph to that 0.75rem edge so the visible
// icon — not the transparent button box — lines up with the badges.
"mr-[0.375rem] flex h-6 w-6 shrink-0 items-center justify-end",
"rounded-[0.375rem] cursor-pointer outline-none text-muted-foreground/90",
"opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100",
"transition-[opacity,color] duration-150 hover:text-sidebar-foreground"
Expand Down Expand Up @@ -1628,6 +1635,11 @@ export function SidebarConversationList({
}
}, [openFolder])

// Stable trigger for the Clone Repository dialog, passed to the memoized
// Folders section header. Empty deps (setCloneOpen is a stable setter) so the
// header doesn't re-render on every parent render.
const handleOpenCloneDialog = useCallback(() => setCloneOpen(true), [])

const handleBrowserSelect = useCallback(
(path: string) => {
openFolder(path).catch((err) => {
Expand Down Expand Up @@ -1722,6 +1734,15 @@ export function SidebarConversationList({
// entry point, reachable even when empty). `openChatModeTab` is a stable
// context callback, so the memo holds.
onNewChat={row.section === "chats" ? openChatModeTab : undefined}
// The folders section gets two right-edge hover actions mirroring the
// top-of-page NewFolderDropdown: Open Folder and Clone Repository.
// Both handlers are stable, so the memo holds.
onOpenFolder={
row.section === "folders" ? handleOpenFolderAction : undefined
}
onCloneRepository={
row.section === "folders" ? handleOpenCloneDialog : undefined
}
// Every section header carries a top gap: it separates "Folders" from
// the "Pinned" section above it, and — now that a fixed New chat /
// Search region sits above the scrolled list — gives the first section
Expand Down Expand Up @@ -1832,7 +1853,7 @@ export function SidebarConversationList({
className="w-full max-w-[14rem] justify-start"
onClick={handleOpenFolderAction}
>
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
<FolderOpenDot className="h-3.5 w-3.5 mr-1.5" />
{tFolderDropdown("openFolder")}
</Button>
<Button
Expand All @@ -1841,7 +1862,7 @@ export function SidebarConversationList({
className="w-full max-w-[14rem] justify-start"
onClick={() => setCloneOpen(true)}
>
<GitBranch className="h-3.5 w-3.5 mr-1.5" />
<FolderGit2 className="h-3.5 w-3.5 mr-1.5" />
{tFolderDropdown("cloneRepository")}
</Button>
<Button
Expand Down Expand Up @@ -1953,11 +1974,11 @@ export function SidebarConversationList({
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={handleOpenFolderAction}>
<FolderOpen className="h-4 w-4" />
<FolderOpenDot className="h-4 w-4" />
{tFolderDropdown("openFolder")}
</ContextMenuItem>
<ContextMenuItem onSelect={() => setCloneOpen(true)}>
<GitBranch className="h-4 w-4" />
<FolderGit2 className="h-4 w-4" />
{tFolderDropdown("cloneRepository")}
</ContextMenuItem>
<ContextMenuItem onSelect={handleProjectBoot}>
Expand Down
132 changes: 132 additions & 0 deletions src/components/conversations/sidebar-section-header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { type ReactElement } from "react"
import { fireEvent, render } from "@testing-library/react"
import { NextIntlClientProvider } from "next-intl"
import { describe, expect, it, vi, beforeEach } from "vitest"

import { SidebarSectionHeader } from "./sidebar-section-header"
import enMessages from "@/i18n/messages/en.json"

// The hover-reveal action buttons carry only an aria-label (icon, no text), so
// getByLabelText addresses them unambiguously. CSS hides them until hover, but
// fireEvent dispatches directly on the node regardless of pointer-events, so the
// wiring is testable without a real pointer.
const onToggle = vi.fn()
const onNewChat = vi.fn()
const onOpenFolder = vi.fn()
const onCloneRepository = vi.fn()

function renderWithIntl(ui: ReactElement) {
return render(
<NextIntlClientProvider locale="en" messages={enMessages}>
{ui}
</NextIntlClientProvider>
)
}

beforeEach(() => {
onToggle.mockClear()
onNewChat.mockClear()
onOpenFolder.mockClear()
onCloneRepository.mockClear()
})

describe("SidebarSectionHeader folders-section actions", () => {
it("renders Open Folder and Clone Repository buttons on the folders section", () => {
const { getByLabelText } = renderWithIntl(
<SidebarSectionHeader
section="folders"
expanded
onToggle={onToggle}
onOpenFolder={onOpenFolder}
onCloneRepository={onCloneRepository}
/>
)
expect(getByLabelText("Open Folder")).not.toBeNull()
expect(getByLabelText("Clone Repository")).not.toBeNull()
})

it("invokes the matching handler without toggling the section", () => {
const { getByLabelText } = renderWithIntl(
<SidebarSectionHeader
section="folders"
expanded
onToggle={onToggle}
onOpenFolder={onOpenFolder}
onCloneRepository={onCloneRepository}
/>
)

fireEvent.click(getByLabelText("Open Folder"))
expect(onOpenFolder).toHaveBeenCalledTimes(1)

fireEvent.click(getByLabelText("Clone Repository"))
expect(onCloneRepository).toHaveBeenCalledTimes(1)

// The actions are siblings of the toggle button (never nested), so clicking
// them never collapses/expands the section.
expect(onToggle).not.toHaveBeenCalled()
})

it("renders only the actions whose callbacks are provided", () => {
const { getByLabelText, queryByLabelText } = renderWithIntl(
<SidebarSectionHeader
section="folders"
expanded
onToggle={onToggle}
onOpenFolder={onOpenFolder}
/>
)
expect(getByLabelText("Open Folder")).not.toBeNull()
expect(queryByLabelText("Clone Repository")).toBeNull()
})

it("still toggles the section when the header label is clicked", () => {
const { getByText } = renderWithIntl(
<SidebarSectionHeader
section="folders"
expanded
onToggle={onToggle}
onOpenFolder={onOpenFolder}
onCloneRepository={onCloneRepository}
/>
)
fireEvent.click(getByText("Folders"))
expect(onToggle).toHaveBeenCalledWith("folders")
})
})

describe("SidebarSectionHeader action gating by section", () => {
it("offers only New chat on the chats section, never the folder actions", () => {
// Pass the folder callbacks too: they must be gated by `section`, not merely
// by callback presence.
const { getByLabelText, queryByLabelText } = renderWithIntl(
<SidebarSectionHeader
section="chats"
expanded
onToggle={onToggle}
onNewChat={onNewChat}
onOpenFolder={onOpenFolder}
onCloneRepository={onCloneRepository}
/>
)
expect(getByLabelText("New chat")).not.toBeNull()
expect(queryByLabelText("Open Folder")).toBeNull()
expect(queryByLabelText("Clone Repository")).toBeNull()
})

it("offers no action buttons on the pinned section", () => {
const { queryByLabelText } = renderWithIntl(
<SidebarSectionHeader
section="pinned"
expanded
onToggle={onToggle}
onNewChat={onNewChat}
onOpenFolder={onOpenFolder}
onCloneRepository={onCloneRepository}
/>
)
expect(queryByLabelText("New chat")).toBeNull()
expect(queryByLabelText("Open Folder")).toBeNull()
expect(queryByLabelText("Clone Repository")).toBeNull()
})
})
Loading
Loading