Skip to content
41 changes: 41 additions & 0 deletions apps/web/src/components/WorkspaceBreadcrumb.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";

import {
WorkspaceBreadcrumb,
WorkspaceBreadcrumbItem,
WorkspaceBreadcrumbSeparator,
} from "./WorkspaceBreadcrumb";

describe("WorkspaceBreadcrumb", () => {
it("composes plain text and custom children", () => {
const markup = renderToStaticMarkup(
<WorkspaceBreadcrumb ariaLabel="Project breadcrumb">
<WorkspaceBreadcrumbItem>Projects</WorkspaceBreadcrumbItem>
<WorkspaceBreadcrumbSeparator />
<WorkspaceBreadcrumbItem current>
<button type="button">Switch project</button>
</WorkspaceBreadcrumbItem>
</WorkspaceBreadcrumb>,
);

expect(markup).toContain('aria-label="Project breadcrumb"');
expect(markup).toContain("Projects");
expect(markup).toContain(">/</span>");
expect(markup).toContain('aria-current="page"');
expect(markup).toContain('<button type="button">Switch project</button>');
});

it("can preserve a page heading for a plain-text breadcrumb", () => {
const markup = renderToStaticMarkup(
<WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
<WorkspaceBreadcrumbItem as="h1" current>
Usage
</WorkspaceBreadcrumbItem>
</WorkspaceBreadcrumb>,
);

expect(markup).toContain('<h1 aria-current="page"');
expect(markup).toContain(">Usage</h1>");
});
});
58 changes: 58 additions & 0 deletions apps/web/src/components/WorkspaceBreadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { ReactNode } from "react";

import { cn } from "../lib/utils";

interface WorkspaceBreadcrumbProps {
readonly ariaLabel: string;
readonly children: ReactNode;
readonly className?: string;
}

export function WorkspaceBreadcrumb({ ariaLabel, children, className }: WorkspaceBreadcrumbProps) {
return (
<nav
aria-label={ariaLabel}
className={cn(
"flex min-w-0 items-center gap-2 overflow-hidden [-webkit-app-region:no-drag] sm:gap-3",
className,
)}
>
{children}
</nav>
);
}

interface WorkspaceBreadcrumbItemProps {
readonly as?: "h1" | "span";
readonly children: ReactNode;
readonly className?: string;
readonly current?: boolean;
}

export function WorkspaceBreadcrumbItem({
as: Component = "span",
children,
className,
current = false,
}: WorkspaceBreadcrumbItemProps) {
return (
<Component
aria-current={current ? "page" : undefined}
className={cn(
"min-w-0 text-sm font-medium",
current ? "text-foreground" : "shrink-0 text-muted-foreground",
className,
)}
>
{children}
</Component>
);
}

export function WorkspaceBreadcrumbSeparator() {
return (
<span aria-hidden="true" className="shrink-0 text-icon-muted">
/
</span>
);
}
145 changes: 76 additions & 69 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { ProjectFavicon } from "../ProjectFavicon";
import {
WorkspaceBreadcrumb,
WorkspaceBreadcrumbItem,
WorkspaceBreadcrumbSeparator,
} from "../WorkspaceBreadcrumb";
import { cn } from "~/lib/utils";

interface ChatHeaderProps {
Expand Down Expand Up @@ -211,91 +216,93 @@ export const ChatHeader = memo(function ChatHeader({
className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"
onContextMenu={handleHeaderContextMenu}
>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
<WorkspaceBreadcrumb ariaLabel="Thread breadcrumb" className="flex-1">
{/* The project always leads the header: knowing which project a
thread lives in is priority zero, and the thread title alone
doesn't answer it. */}
{activeProjectName ? (
<span className="inline-flex shrink-0 items-center gap-2">
<>
<WorkspaceBreadcrumbItem className="inline-flex items-center">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={`New thread in ${activeProjectName}`}
onClick={onNewThreadInProject}
className="inline-flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<ProjectFavicon
environmentId={activeThreadEnvironmentId}
cwd={activeProjectCwd ?? ""}
faviconPath={activeProjectFaviconPath}
className="size-3.5"
/>
<span className="max-w-40 truncate text-sm font-medium">{activeProjectName}</span>
</TooltipTrigger>
<TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
</Tooltip>
</WorkspaceBreadcrumbItem>
<WorkspaceBreadcrumbSeparator />
</>
) : null}
<WorkspaceBreadcrumbItem current className="flex flex-1">
{renamingTitle !== null ? (
<input
autoFocus
aria-label="Thread title"
className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-ring/50 focus:ring-ring"
defaultValue={renamingTitle}
onBlur={(event) => {
if (renameCommittedRef.current) return;
commitRename(event.currentTarget.value);
}}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={handleRenameKeyDown}
/>
) : isServerThread ? (
<Tooltip>
<TooltipTrigger
render={
<button
ref={titleButtonRef}
type="button"
aria-label={`New thread in ${activeProjectName}`}
onClick={onNewThreadInProject}
className="inline-flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Thread actions for ${activeThreadTitle}`}
aria-haspopup="menu"
onClick={openMenuFromTitle}
className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<ProjectFavicon
environmentId={activeThreadEnvironmentId}
cwd={activeProjectCwd ?? ""}
faviconPath={activeProjectFaviconPath}
className="size-3.5"
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
{activeThreadTitle}
</h2>
<ChevronDownIcon
aria-hidden
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/thread-title:opacity-100 group-focus-visible/thread-title:opacity-100"
/>
<span className="max-w-40 truncate text-sm font-medium">{activeProjectName}</span>
</TooltipTrigger>
<TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
<span aria-hidden className="text-icon-muted">
/
</span>
</span>
) : null}
{renamingTitle !== null ? (
<input
autoFocus
aria-label="Thread title"
className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-ring/50 focus:ring-ring"
defaultValue={renamingTitle}
onBlur={(event) => {
if (renameCommittedRef.current) return;
commitRename(event.currentTarget.value);
}}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={handleRenameKeyDown}
/>
) : isServerThread ? (
<Tooltip>
<TooltipTrigger
render={
<button
ref={titleButtonRef}
type="button"
aria-label={`Thread actions for ${activeThreadTitle}`}
aria-haspopup="menu"
onClick={openMenuFromTitle}
className="group/thread-title inline-flex min-w-0 cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
{activeThreadTitle}
</h2>
<ChevronDownIcon
aria-hidden
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/thread-title:opacity-100 group-focus-visible/thread-title:opacity-100"
) : (
<Tooltip>
<TooltipTrigger
render={
<h2
aria-label={activeThreadTitle}
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"
>
{activeThreadTitle}
</h2>
}
/>
</TooltipTrigger>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger
render={
<h2
aria-label={activeThreadTitle}
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"
>
{activeThreadTitle}
</h2>
}
/>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
)}
</div>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
)}
</WorkspaceBreadcrumbItem>
</WorkspaceBreadcrumb>
Comment thread
cursor[bot] marked this conversation as resolved.
<div
data-chat-header-actions
className={cn(
Expand Down
80 changes: 41 additions & 39 deletions apps/web/src/components/settings/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ import {
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
import { SidebarInset } from "../ui/sidebar";
import { stackedThreadToast, toastManager } from "../ui/toast";
import {
WorkspaceBreadcrumb,
WorkspaceBreadcrumbItem,
WorkspaceBreadcrumbSeparator,
} from "../WorkspaceBreadcrumb";
import {
SettingResetButton,
SettingsPageContainer,
Expand Down Expand Up @@ -193,46 +198,43 @@ function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) {
const selected = groups.find((group) => group.projectKey === projectKey) ?? null;

return (
<nav
aria-label="Project settings breadcrumb"
className="flex min-w-0 items-center gap-2 overflow-hidden [-webkit-app-region:no-drag] sm:gap-3"
>
<span className="shrink-0 text-sm font-medium text-muted-foreground">Projects</span>
<span aria-hidden="true" className="shrink-0 text-icon-muted">
/
</span>
{selected ? (
<Select
value={selected.projectKey}
onValueChange={(value) =>
void navigate({
to: "/projects/$projectKey",
params: { projectKey: String(value) },
replace: true,
hashScrollIntoView: false,
})
}
>
<SelectTrigger
aria-label="Switch project"
className="h-auto min-h-0 min-w-0 max-w-64 gap-1 border-0 px-0 py-0 text-sm font-medium text-foreground sm:h-auto sm:min-h-0 sm:text-sm"
size="xs"
variant="ghost"
<WorkspaceBreadcrumb ariaLabel="Project settings breadcrumb">
<WorkspaceBreadcrumbItem>Projects</WorkspaceBreadcrumbItem>
<WorkspaceBreadcrumbSeparator />
<WorkspaceBreadcrumbItem current>
{selected ? (
<Select
value={selected.projectKey}
onValueChange={(value) =>
void navigate({
to: "/projects/$projectKey",
params: { projectKey: String(value) },
replace: true,
hashScrollIntoView: false,
})
}
>
<SelectValue>{selected.displayName}</SelectValue>
</SelectTrigger>
<SelectPopup align="start" alignItemWithTrigger={false}>
{groups.map((candidate) => (
<SelectItem key={candidate.projectKey} hideIndicator value={candidate.projectKey}>
{candidate.displayName}
</SelectItem>
))}
</SelectPopup>
</Select>
) : (
<span className="truncate text-sm text-muted-foreground">Unavailable project</span>
)}
</nav>
<SelectTrigger
aria-label="Switch project"
className="h-auto min-h-0 min-w-0 max-w-64 gap-1 border-0 px-0 py-0 text-sm font-medium text-foreground sm:h-auto sm:min-h-0 sm:text-sm"
size="xs"
variant="ghost"
>
<SelectValue>{selected.displayName}</SelectValue>
</SelectTrigger>
<SelectPopup align="start" alignItemWithTrigger={false}>
{groups.map((candidate) => (
<SelectItem key={candidate.projectKey} hideIndicator value={candidate.projectKey}>
{candidate.displayName}
</SelectItem>
))}
</SelectPopup>
</Select>
) : (
<span className="truncate text-sm text-muted-foreground">Unavailable project</span>
)}
</WorkspaceBreadcrumbItem>
</WorkspaceBreadcrumb>
);
}

Expand Down
Loading
Loading