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
5 changes: 5 additions & 0 deletions apps/app/src/components/sidebar/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ export function AppSidebar({
onNewChat={handleNewChat}
onSearchThreads={closeOnMobile}
/>
<div
aria-hidden="true"
className="mx-2 my-2 shrink-0 border-t border-sidebar-border/25"
data-testid="app-sidebar-navigation-divider"
/>
<SidebarContent>
<PluginThreadList
replacement={threadListReplacement}
Expand Down
172 changes: 104 additions & 68 deletions apps/app/src/components/sidebar/ProjectList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ import { BbHttpError } from "@bb/sdk/browser";
import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
import { cn } from "@bb/shared-ui/lib/utils";
import { Button } from "@bb/shared-ui/button";
import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint";
import {
AppCommandShortcutHint,
AppCommandShortcutPill,
} from "@/components/commands/AppCommandShortcutHint";
import {
ThreadSectionCreateDialog,
ThreadSectionRenameDialog,
Expand Down Expand Up @@ -157,16 +160,23 @@ interface ProjectListProps {
isCreatingProject?: boolean;
}

interface ProjectListActionButtonsProps {
interface ProjectListNewThreadActionProps {
splitEnabled?: boolean;
newThreadSplit?: {
onPointerDown?: PointerEventHandler<HTMLElement>;
openInSplit(): void;
};
onNewChat?: () => void;
}

interface ProjectListSearchThreadsActionProps {
onSearchThreads?: () => void;
}

interface ProjectListActionButtonsProps
extends ProjectListNewThreadActionProps,
ProjectListSearchThreadsActionProps {}

interface ProjectListShellProps {
children: ReactNode;
}
Expand Down Expand Up @@ -213,11 +223,6 @@ export const PROJECT_LIST_ACTION_BUTTON_CLASS = cn(
"min-w-0 cursor-pointer justify-start overflow-hidden font-normal ring-sidebar-ring focus-visible:ring-2 disabled:cursor-default disabled:opacity-70 max-md:pointer-coarse:[&_svg]:size-5",
);

const PROJECT_LIST_ACTION_ICON_BUTTON_CLASS = cn(
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md text-sidebar-foreground/85 outline-none ring-sidebar-ring transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 disabled:cursor-default disabled:opacity-50",
COARSE_POINTER_ROW_ACTION_SIZE_CLASS,
);

const PROJECT_LIST_SECTION_ACTION_BUTTON_CLASS = cn(
"inline-flex items-center justify-center rounded-md text-muted-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-foreground focus-visible:ring-2 disabled:opacity-50",
LIST_HOVER_TRANSITION,
Expand Down Expand Up @@ -754,79 +759,110 @@ function ProjectListNavigationLoadingRow({
);
}

export function ProjectListActionButtons({
export function ProjectListNewThreadAction({
splitEnabled = false,
newThreadSplit,
onNewChat,
onSearchThreads,
}: ProjectListActionButtonsProps) {
const commandRunner = useAppCommandRunner();
}: ProjectListNewThreadActionProps) {
const isNewChatDisabled = !onNewChat;
const newThreadShortcut = useAppCommandShortcut("thread.new");
const threadSearchShortcut = useAppCommandShortcut("thread.search");
const newThreadSplitIndicator = usePaneContentSplitIndicator(
{ kind: "new-thread" },
splitEnabled,
);

return (
<div className="space-y-1">
<div className="flex min-w-0 items-center gap-0.5">
<Button
type="button"
size="sm"
variant="ghost"
className={cn(PROJECT_LIST_ACTION_BUTTON_CLASS, "flex-1")}
onPointerDown={newThreadSplit?.onPointerDown}
onClick={(event) => {
if (event.metaKey || event.ctrlKey) {
newThreadSplit?.openInSplit();
return;
}
onNewChat?.();
}}
disabled={isNewChatDisabled}
aria-label={
newThreadShortcut
? `New thread (${newThreadShortcut.label})`
: "New thread"
}
aria-keyshortcuts={newThreadShortcut?.ariaKeyshortcuts}
>
<Icon name="MessageSquarePlus" />
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="min-w-0 truncate text-left">New thread</span>
{newThreadSplitIndicator.miniMap ? (
<SplitPaneMiniMap
slots={newThreadSplitIndicator.miniMap}
label="New thread — open in split"
/>
) : null}
<AppCommandShortcutHint shortcut={newThreadShortcut} />
</span>
</Button>
<span className="flex shrink-0 items-center gap-1">
<AppCommandShortcutHint shortcut={threadSearchShortcut} />
<Button
type="button"
size="icon"
variant="ghost"
aria-label={
threadSearchShortcut
? `Search threads (${threadSearchShortcut.label})`
: "Search threads"
}
aria-keyshortcuts={threadSearchShortcut?.ariaKeyshortcuts}
className={PROJECT_LIST_ACTION_ICON_BUTTON_CLASS}
onClick={(event) => {
onSearchThreads?.();
commandRunner.dispatch("thread.search", event.currentTarget);
}}
>
<Icon name="Search" className={COARSE_POINTER_ICON_SIZE_CLASS} />
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className={cn(PROJECT_LIST_ACTION_BUTTON_CLASS, "w-full")}
onPointerDown={newThreadSplit?.onPointerDown}
onClick={(event) => {
if (event.metaKey || event.ctrlKey) {
newThreadSplit?.openInSplit();
return;
}
onNewChat?.();
}}
disabled={isNewChatDisabled}
aria-label={
newThreadShortcut
? `New thread (${newThreadShortcut.label})`
: "New thread"
}
aria-keyshortcuts={newThreadShortcut?.ariaKeyshortcuts}
>
<Icon name="MessageSquarePlus" />
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="min-w-0 truncate text-left">New thread</span>
{newThreadSplitIndicator.miniMap ? (
<SplitPaneMiniMap
slots={newThreadSplitIndicator.miniMap}
label="New thread — open in split"
/>
) : null}
<AppCommandShortcutHint shortcut={newThreadShortcut} />
</span>
</Button>
);
}

export function ProjectListSearchThreadsAction({
onSearchThreads,
}: ProjectListSearchThreadsActionProps) {
const commandRunner = useAppCommandRunner();
const threadSearchShortcut = useAppCommandShortcut("thread.search");

return (
<Button
type="button"
size="sm"
variant="ghost"
className={cn(
PROJECT_LIST_ACTION_BUTTON_CLASS,
"group/search-threads w-full pr-1",
)}
onClick={(event) => {
onSearchThreads?.();
commandRunner.dispatch("thread.search", event.currentTarget);
}}
aria-label={
threadSearchShortcut
? `Search threads (${threadSearchShortcut.label})`
: "Search threads"
}
aria-keyshortcuts={threadSearchShortcut?.ariaKeyshortcuts}
>
<Icon name="Search" />
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-left">
Search threads
</span>
</div>
{threadSearchShortcut ? (
<span className="inline-flex shrink-0 opacity-0 transition-opacity group-hover/search-threads:opacity-100 group-focus-visible/search-threads:opacity-100 max-md:pointer-coarse:hidden">
<AppCommandShortcutPill shortcut={threadSearchShortcut} />
</span>
) : null}
</span>
</Button>
);
}

export function ProjectListActionButtons({
splitEnabled = false,
newThreadSplit,
onNewChat,
onSearchThreads,
}: ProjectListActionButtonsProps) {
return (
<div className="space-y-1">
<ProjectListNewThreadAction
splitEnabled={splitEnabled}
newThreadSplit={newThreadSplit}
onNewChat={onNewChat}
/>
<ProjectListSearchThreadsAction onSearchThreads={onSearchThreads} />
</div>
);
}
Expand Down
69 changes: 69 additions & 0 deletions apps/app/src/components/sidebar/ProjectListActionButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectListSearchThreadsAction } from "./ProjectList";

const mocks = vi.hoisted(() => ({
dispatch: vi.fn(),
}));

vi.mock("@/components/commands/AppCommandProvider", () => ({
useAppCommandRunner: () => ({
dispatch: mocks.dispatch,
isCommandAvailable: () => true,
}),
useAppCommandShortcut: (command: string) =>
command === "thread.search"
? { ariaKeyshortcuts: "Meta+K", label: "⌘K" }
: null,
useIsAppCommandModifierHeld: () => false,
}));

afterEach(() => {
cleanup();
mocks.dispatch.mockReset();
});

describe("ProjectListSearchThreadsAction", () => {
it("reveals the reserved trailing Search shortcut on hover or focus without changing activation", () => {
const onSearchThreads = vi.fn();
render(
<ProjectListSearchThreadsAction onSearchThreads={onSearchThreads} />,
);

const button = screen.getByRole("button", {
name: "Search threads (⌘K)",
});
const shortcut = screen.getByText("⌘K");
const label = screen.getByText("Search threads");

expect(button.getAttribute("aria-keyshortcuts")).toBe("Meta+K");
expect(shortcut.tagName).toBe("KBD");
expect(shortcut.getAttribute("aria-hidden")).toBe("true");
expect(label.classList.contains("flex-1")).toBe(true);
const shortcutSlot = shortcut.parentElement;
expect(shortcutSlot?.lastElementChild).toBe(shortcut);
expect(button.classList.contains("group/search-threads")).toBe(true);
expect(shortcutSlot?.classList.contains("opacity-0")).toBe(true);
expect(
shortcutSlot?.classList.contains(
"group-hover/search-threads:opacity-100",
),
).toBe(true);
expect(
shortcutSlot?.classList.contains(
"group-focus-visible/search-threads:opacity-100",
),
).toBe(true);
expect(
shortcutSlot?.classList.contains("max-md:pointer-coarse:hidden"),
).toBe(true);
expect(button.classList.contains("pr-1")).toBe(true);

fireEvent.click(button);

expect(onSearchThreads).toHaveBeenCalledOnce();
expect(mocks.dispatch).toHaveBeenCalledWith("thread.search", button);
});
});
Loading