mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Refine Pulse project navigation and message action placement
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
@@ -22,6 +22,7 @@ const PulseScreen = React.lazy(async () => {
|
||||
|
||||
type PulseRouteSearch = ReturnType<typeof parseProjectDetailSearch> & {
|
||||
projectId?: string;
|
||||
projectSection?: string;
|
||||
workflowId?: string;
|
||||
profilePersona?: string;
|
||||
view?: "create" | "edit" | "duplicate";
|
||||
@@ -52,6 +53,7 @@ function validatePulseSearch(
|
||||
return {
|
||||
...parseProjectDetailSearch(search),
|
||||
projectId: stringValue("projectId"),
|
||||
projectSection: stringValue("projectSection"),
|
||||
workflowId: stringValue("workflowId"),
|
||||
profilePersona: stringValue("profilePersona"),
|
||||
view:
|
||||
|
||||
@@ -117,8 +117,8 @@ export function MessageBubbleLayout({
|
||||
style={{ right: -8 - actionShift }}
|
||||
data-testid="bubble-actions-anchor"
|
||||
className={cn(
|
||||
"absolute -right-2 z-20",
|
||||
reactions ? "bottom-full mb-5" : "top-0 -translate-y-1/2",
|
||||
"absolute -right-2 bottom-full z-20",
|
||||
reactions ? "mb-5" : "translate-y-[4px]",
|
||||
)}
|
||||
>
|
||||
{actions}
|
||||
|
||||
@@ -85,6 +85,7 @@ function ProjectHomeHeaderToggle({
|
||||
}
|
||||
|
||||
export function ProjectChannelHome({
|
||||
initialPanelCollapsed = false,
|
||||
allowRepositoryHealing,
|
||||
autoSendDraftKey,
|
||||
project,
|
||||
@@ -92,6 +93,7 @@ export function ProjectChannelHome({
|
||||
targetMessageEvents = EMPTY_TARGET_MESSAGE_EVENTS,
|
||||
targetMessageId,
|
||||
}: {
|
||||
initialPanelCollapsed?: boolean;
|
||||
allowRepositoryHealing: boolean;
|
||||
autoSendDraftKey?: string | null;
|
||||
project: Project;
|
||||
@@ -108,7 +110,7 @@ export function ProjectChannelHome({
|
||||
autoSend?: string;
|
||||
messageId?: string;
|
||||
};
|
||||
const [summaryOpen, setSummaryOpen] = React.useState(true);
|
||||
const [summaryOpen, setSummaryOpen] = React.useState(!initialPanelCollapsed);
|
||||
const [addRepositoryOpen, setAddRepositoryOpen] = React.useState(false);
|
||||
const [workspaceSheetTab, setWorkspaceSheetTab] =
|
||||
React.useState<ProjectHomeWorkspaceSheetTab | null>(null);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { ChevronRight, Folders } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import { AppTopChromePortal } from "@/app/AppTopChromePortal";
|
||||
import type { Project, Repository } from "@/features/projects/hooks";
|
||||
|
||||
/** Embedded workspaces can provide their own navigation in place of breadcrumbs. */
|
||||
export const ProjectBreadcrumbVisibilityContext = createContext(true);
|
||||
|
||||
export type ProjectDetailWorkItemCrumb = {
|
||||
category: string;
|
||||
title: string;
|
||||
@@ -28,6 +32,7 @@ export function ProjectDetailChrome({
|
||||
project: Project;
|
||||
repository?: Repository | null;
|
||||
}) {
|
||||
const showBreadcrumb = useContext(ProjectBreadcrumbVisibilityContext);
|
||||
const repositoryCrumb = repository ? (
|
||||
activeWorkItemCrumb ? (
|
||||
<>
|
||||
@@ -90,45 +95,47 @@ export function ProjectDetailChrome({
|
||||
data-tauri-drag-region
|
||||
data-testid="project-detail-chrome"
|
||||
>
|
||||
<nav
|
||||
aria-label="Project breadcrumb"
|
||||
className="absolute flex max-w-[50%] min-w-0 -translate-x-1/2 -translate-y-px items-center gap-0.5 text-xs text-sidebar-foreground/65 transition-[left] duration-200 ease-linear motion-reduce:transition-none"
|
||||
style={{
|
||||
left: "calc(50% + var(--app-top-chrome-center-offset, 0rem))",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md px-1 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onGoProjects}
|
||||
type="button"
|
||||
{showBreadcrumb && (
|
||||
<nav
|
||||
aria-label="Project breadcrumb"
|
||||
className="absolute flex max-w-[50%] min-w-0 -translate-x-1/2 -translate-y-px items-center gap-0.5 text-xs text-sidebar-foreground/65 transition-[left] duration-200 ease-linear motion-reduce:transition-none"
|
||||
style={{
|
||||
left: "calc(50% + var(--app-top-chrome-center-offset, 0rem))",
|
||||
}}
|
||||
>
|
||||
<Folders className="h-3.5 w-3.5" />
|
||||
Projects
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
{repositoryCrumb ? (
|
||||
<>
|
||||
<button
|
||||
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
<button
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md px-1 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onGoProjects}
|
||||
type="button"
|
||||
>
|
||||
<Folders className="h-3.5 w-3.5" />
|
||||
Projects
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
{repositoryCrumb ? (
|
||||
<>
|
||||
<button
|
||||
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="project-breadcrumb-project"
|
||||
onClick={onGoProjectHome}
|
||||
type="button"
|
||||
>
|
||||
{project.name}
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
{repositoryCrumb}
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
aria-current="page"
|
||||
className="min-w-0 truncate px-0.5 font-medium opacity-60"
|
||||
data-testid="project-breadcrumb-project"
|
||||
onClick={onGoProjectHome}
|
||||
type="button"
|
||||
>
|
||||
{project.name}
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
{repositoryCrumb}
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
aria-current="page"
|
||||
className="min-w-0 truncate px-0.5 font-medium opacity-60"
|
||||
data-testid="project-breadcrumb-project"
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
||||
{actions}
|
||||
</div>
|
||||
@@ -149,6 +156,7 @@ export function ProjectsWorkspaceChrome({
|
||||
onGoActivity: () => void;
|
||||
section: string;
|
||||
}) {
|
||||
const showBreadcrumb = useContext(ProjectBreadcrumbVisibilityContext);
|
||||
const onActivity = section === "Activity";
|
||||
|
||||
return (
|
||||
@@ -158,36 +166,38 @@ export function ProjectsWorkspaceChrome({
|
||||
data-tauri-drag-region
|
||||
data-testid="projects-workspace-chrome"
|
||||
>
|
||||
<nav
|
||||
aria-label="Projects breadcrumb"
|
||||
className="absolute flex max-w-[50%] min-w-0 -translate-x-1/2 -translate-y-px items-center gap-0.5 text-xs text-sidebar-foreground/65 transition-[left] duration-200 ease-linear motion-reduce:transition-none"
|
||||
style={{
|
||||
left: "calc(50% + var(--app-top-chrome-center-offset, 0rem))",
|
||||
}}
|
||||
>
|
||||
{onActivity ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5 px-1 py-1 font-medium">
|
||||
<Folders className="h-3.5 w-3.5 shrink-0" />
|
||||
Projects
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={BREADCRUMB_BUTTON_CLASS}
|
||||
onClick={onGoActivity}
|
||||
type="button"
|
||||
>
|
||||
<Folders className="h-3.5 w-3.5 shrink-0" />
|
||||
Projects
|
||||
</button>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
<span
|
||||
aria-current="page"
|
||||
className="min-w-0 truncate px-0.5 font-medium opacity-60"
|
||||
{showBreadcrumb && (
|
||||
<nav
|
||||
aria-label="Projects breadcrumb"
|
||||
className="absolute flex max-w-[50%] min-w-0 -translate-x-1/2 -translate-y-px items-center gap-0.5 text-xs text-sidebar-foreground/65 transition-[left] duration-200 ease-linear motion-reduce:transition-none"
|
||||
style={{
|
||||
left: "calc(50% + var(--app-top-chrome-center-offset, 0rem))",
|
||||
}}
|
||||
>
|
||||
{section}
|
||||
</span>
|
||||
</nav>
|
||||
{onActivity ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5 px-1 py-1 font-medium">
|
||||
<Folders className="h-3.5 w-3.5 shrink-0" />
|
||||
Projects
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={BREADCRUMB_BUTTON_CLASS}
|
||||
onClick={onGoActivity}
|
||||
type="button"
|
||||
>
|
||||
<Folders className="h-3.5 w-3.5 shrink-0" />
|
||||
Projects
|
||||
</button>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
<span
|
||||
aria-current="page"
|
||||
className="min-w-0 truncate px-0.5 font-medium opacity-60"
|
||||
>
|
||||
{section}
|
||||
</span>
|
||||
</nav>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
@@ -206,6 +206,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
);
|
||||
const repositoryPanel = useProjectRepositoryPanel(
|
||||
`${repository?.id ?? ""}:${activeTab}:${selectedPullRequestId ?? ""}:${selectedIssueId ?? ""}:${selectedCommitHash ?? ""}`,
|
||||
props.initialPanelCollapsed,
|
||||
);
|
||||
const {
|
||||
commitDiffQuery,
|
||||
@@ -694,6 +695,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
if (showChannelHome) {
|
||||
return (
|
||||
<ProjectChannelHome
|
||||
initialPanelCollapsed={props.initialPanelCollapsed}
|
||||
allowRepositoryHealing={isProjectRelayValidated(project)}
|
||||
project={project}
|
||||
projects={projectsQuery.data ?? [project]}
|
||||
|
||||
78
desktop/src/features/projects/ui/ProjectsPanelToolbar.tsx
Normal file
78
desktop/src/features/projects/ui/ProjectsPanelToolbar.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useProjectSelection } from "../lib/useProjectSelection";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import type { ProjectsFilter, ProjectsSort } from "../lib/projectsViewHelpers";
|
||||
import { ProjectsSortSelect } from "./ProjectsListHeaderBar";
|
||||
import { projectsSectionTitle } from "./projectsSectionMeta";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
/** Content controls for Projects when navigation lives in a separate rail. */
|
||||
export function ProjectsPanelToolbar({
|
||||
filter,
|
||||
query,
|
||||
onQueryChange,
|
||||
sort,
|
||||
onSortChange,
|
||||
onCreate,
|
||||
canCreateTarget,
|
||||
}: {
|
||||
filter: ProjectsFilter;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
sort: ProjectsSort;
|
||||
onSortChange: (sort: ProjectsSort) => void;
|
||||
onCreate: () => void;
|
||||
canCreateTarget: boolean;
|
||||
}) {
|
||||
const label =
|
||||
filter === "repositories"
|
||||
? "Add repository"
|
||||
: filter === "channels"
|
||||
? "Add channel"
|
||||
: filter === "issues"
|
||||
? "Create task"
|
||||
: filter === "prs"
|
||||
? "Create review"
|
||||
: "Create project";
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<label className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
aria-label={`Search ${projectsSectionTitle(filter)}`}
|
||||
placeholder={`Search ${projectsSectionTitle(filter).toLowerCase()}`}
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
className="h-9 min-w-0 w-full bg-transparent text-sm outline-none"
|
||||
/>
|
||||
</label>
|
||||
{filter !== "all" && filter !== "channels" && (
|
||||
<ProjectsSortSelect sort={sort} onChange={onSortChange} />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onCreate}
|
||||
disabled={
|
||||
(filter === "repositories" || filter === "channels") &&
|
||||
!canCreateTarget
|
||||
}
|
||||
>
|
||||
<Plus aria-hidden="true" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep selection actions available without the redundant context rail. */
|
||||
export function ProjectsPanelSelection({ children }: { children: ReactNode }) {
|
||||
const selection = useProjectSelection();
|
||||
return selection?.items.length ? children : null;
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import type { ProjectsFilter } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import { ProjectsView } from "@/features/projects/ui/ProjectsView";
|
||||
|
||||
export function ProjectsScreen() {
|
||||
export function ProjectsScreen({
|
||||
section,
|
||||
onSectionChange,
|
||||
}: {
|
||||
section?: ProjectsFilter;
|
||||
onSectionChange?: (section: ProjectsFilter) => void;
|
||||
} = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
@@ -27,7 +34,7 @@ export function ProjectsScreen() {
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<ProjectsView />
|
||||
<ProjectsView section={section} onSectionChange={onSectionChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
useRepositoryUnavailableReasonFor,
|
||||
} from "@/features/projects/useRepositoryAccess";
|
||||
import { projectRepoHostForProject } from "@/features/projects/lib/projectRepoHost";
|
||||
import {
|
||||
ProjectsPanelToolbar,
|
||||
ProjectsPanelSelection,
|
||||
} from "./ProjectsPanelToolbar";
|
||||
import { ProjectsOverviewChatToggle } from "./ProjectsOverviewChatToggle";
|
||||
import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed";
|
||||
import { ProjectsChannelsList } from "@/features/projects/ui/ProjectsChannelsList";
|
||||
import {
|
||||
@@ -108,7 +113,14 @@ import {
|
||||
const MANY_PROJECTS_THRESHOLD = 12;
|
||||
const PROJECTS_CONTEXT_POD_MIN_VIEWPORT_PX = 1024;
|
||||
|
||||
export function ProjectsView() {
|
||||
export function ProjectsView({
|
||||
section,
|
||||
onSectionChange,
|
||||
}: {
|
||||
section?: ProjectsFilter;
|
||||
onSectionChange?: (section: ProjectsFilter) => void;
|
||||
} = {}) {
|
||||
const embedded = section !== undefined;
|
||||
const { goProject } = useAppNavigation();
|
||||
const { activeCommunity } = useCommunities();
|
||||
const relayOrigin = useRelayOrigin();
|
||||
@@ -126,12 +138,13 @@ export function ProjectsView() {
|
||||
const localRepositoriesQuery = useProjectLocalRepositoriesQuery(
|
||||
activeCommunity?.reposDir,
|
||||
);
|
||||
const [filter, setFilter] = React.useState<ProjectsFilter>(() => {
|
||||
const [storedFilter, setFilter] = React.useState<ProjectsFilter>(() => {
|
||||
const storedFilter = readStoredFilter();
|
||||
return storedFilter === "mine" || storedFilter === "local"
|
||||
? "repositories"
|
||||
: storedFilter;
|
||||
});
|
||||
const filter = section ?? storedFilter;
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [overviewPanelOpen, setOverviewPanelOpen] = React.useState(true);
|
||||
const [narrowContextOpen, setNarrowContextOpen] = React.useState(false);
|
||||
@@ -460,6 +473,7 @@ export function ProjectsView() {
|
||||
});
|
||||
const handleFilterChange = React.useCallback(
|
||||
(nextFilter: ProjectsFilter) => {
|
||||
onSectionChange?.(nextFilter);
|
||||
writeStoredFilter(nextFilter);
|
||||
// Tab content swaps mount hundreds of rows/cards at once; a transition
|
||||
// lets React keep the click responsive and paint the previous tab until
|
||||
@@ -469,7 +483,7 @@ export function ProjectsView() {
|
||||
setFilter(nextFilter);
|
||||
});
|
||||
},
|
||||
[setSelectionAgentContext],
|
||||
[setSelectionAgentContext, onSectionChange],
|
||||
);
|
||||
|
||||
// Route by the canonical `owner:dtag` project ID — a bare dtag is
|
||||
@@ -669,10 +683,21 @@ export function ProjectsView() {
|
||||
? narrowContextOpen
|
||||
: overviewPanelOpen;
|
||||
const overviewChatOpen =
|
||||
selectionAgentContext !== null && !isNarrowProjectsLayout;
|
||||
const overviewContextOpen = overviewPanelOpen && !isNarrowProjectsLayout;
|
||||
selectionAgentContext !== null && (embedded || !isNarrowProjectsLayout);
|
||||
const overviewContextOpen =
|
||||
!embedded && overviewPanelOpen && !isNarrowProjectsLayout;
|
||||
const overviewDetached = overviewContextOpen || overviewChatOpen;
|
||||
const chromeActions = isNarrowProjectsLayout ? (
|
||||
const chromeActions = embedded ? (
|
||||
<ProjectsOverviewChatToggle
|
||||
active={overviewChatOpen}
|
||||
onToggle={() =>
|
||||
setSelectionAgentContext((context) =>
|
||||
context ? null : overviewAgentContext,
|
||||
)
|
||||
}
|
||||
sectionTitle={projectsSectionTitle(filter)}
|
||||
/>
|
||||
) : isNarrowProjectsLayout ? (
|
||||
<ProjectsOverviewNarrowContextToggle
|
||||
onToggle={() => setNarrowContextOpen((open) => !open)}
|
||||
open={contextOpen}
|
||||
@@ -721,11 +746,14 @@ export function ProjectsView() {
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden",
|
||||
!isNarrowProjectsLayout && "bg-sidebar pb-2 pr-2 pt-px",
|
||||
!isNarrowProjectsLayout && sidebar?.open === false && "pl-2",
|
||||
!embedded && !isNarrowProjectsLayout && "bg-sidebar pb-2 pr-2 pt-px",
|
||||
!embedded &&
|
||||
!isNarrowProjectsLayout &&
|
||||
sidebar?.open === false &&
|
||||
"pl-2",
|
||||
)}
|
||||
data-project-context-detached={
|
||||
isNarrowProjectsLayout ? undefined : "true"
|
||||
embedded || isNarrowProjectsLayout ? undefined : "true"
|
||||
}
|
||||
data-testid="projects-overview-layout"
|
||||
>
|
||||
@@ -737,9 +765,11 @@ export function ProjectsView() {
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
|
||||
!isNarrowProjectsLayout
|
||||
? "ml-px rounded-2xl bg-background"
|
||||
: cn("rounded-tl-xl", topChromeInset.divider),
|
||||
embedded
|
||||
? "bg-background"
|
||||
: !isNarrowProjectsLayout
|
||||
? "ml-px rounded-2xl bg-background"
|
||||
: cn("rounded-tl-xl", topChromeInset.divider),
|
||||
)}
|
||||
data-testid={
|
||||
overviewDetached ? "projects-overview-content-pod" : undefined
|
||||
@@ -805,16 +835,50 @@ export function ProjectsView() {
|
||||
PROJECT_COLUMN_HEADER_BACKDROP_CLASS,
|
||||
overviewDetached && "rounded-t-2xl",
|
||||
)}
|
||||
data-testid="projects-page-tabs"
|
||||
data-testid={
|
||||
embedded
|
||||
? "projects-panel-toolbar"
|
||||
: "projects-page-tabs"
|
||||
}
|
||||
>
|
||||
<ProjectsSectionSearch
|
||||
filter={filter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onQueryChange={setSearchQuery}
|
||||
onSortChange={handleSortChange}
|
||||
sort={sort}
|
||||
/>
|
||||
{embedded ? (
|
||||
<ProjectsPanelToolbar
|
||||
filter={filter}
|
||||
query={searchQuery}
|
||||
onQueryChange={setSearchQuery}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
canCreateTarget={editableProjects.length > 0}
|
||||
onCreate={() => {
|
||||
if (filter === "repositories")
|
||||
setCreateRepositoryOpen(true);
|
||||
else if (filter === "channels")
|
||||
setCreateChannelOpen(true);
|
||||
else if (filter === "issues")
|
||||
setCreateIssueOpen(true);
|
||||
else if (filter === "prs")
|
||||
setCreatePullRequestOpen(true);
|
||||
else setCreateProjectOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ProjectsSectionSearch
|
||||
filter={filter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onQueryChange={setSearchQuery}
|
||||
onSortChange={handleSortChange}
|
||||
sort={sort}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{embedded && (
|
||||
<ProjectsPanelSelection>
|
||||
<ProjectsOverviewContextPanel
|
||||
{...contextPanelProps}
|
||||
onSelectSection={handleFilterChange}
|
||||
/>
|
||||
</ProjectsPanelSelection>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
filter === "all" ? "mx-auto w-full max-w-6xl" : "w-full"
|
||||
@@ -932,27 +996,29 @@ export function ProjectsView() {
|
||||
/>
|
||||
) : null}
|
||||
</ProjectContextRail>
|
||||
<ProjectContextRail
|
||||
open={overviewContextOpen}
|
||||
panelWidthPx={PROJECT_CONTEXT_PANEL_DEFAULT_WIDTH_PX}
|
||||
rounded={false}
|
||||
testId="projects-overview-context-rail"
|
||||
>
|
||||
<aside
|
||||
aria-label="Project context"
|
||||
className="relative z-30 flex h-full flex-col overflow-hidden bg-sidebar text-sidebar-foreground"
|
||||
{!embedded && (
|
||||
<ProjectContextRail
|
||||
open={overviewContextOpen}
|
||||
panelWidthPx={PROJECT_CONTEXT_PANEL_DEFAULT_WIDTH_PX}
|
||||
rounded={false}
|
||||
testId="projects-overview-context-rail"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<ProjectsOverviewContextPanel
|
||||
{...contextPanelProps}
|
||||
onSelectSection={(section) => {
|
||||
handleFilterChange(section);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</ProjectContextRail>
|
||||
{isNarrowProjectsLayout ? (
|
||||
<aside
|
||||
aria-label="Project context"
|
||||
className="relative z-30 flex h-full flex-col overflow-hidden bg-sidebar text-sidebar-foreground"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<ProjectsOverviewContextPanel
|
||||
{...contextPanelProps}
|
||||
onSelectSection={(section) => {
|
||||
handleFilterChange(section);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</ProjectContextRail>
|
||||
)}
|
||||
{!embedded && isNarrowProjectsLayout ? (
|
||||
<ProjectsOverviewContextSheet
|
||||
onCloseAutoFocus={(event) => {
|
||||
// Return focus to the chrome toggle so the keyboard journey can
|
||||
|
||||
@@ -23,6 +23,8 @@ export const PROJECT_TAB_CRUMB_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export type ProjectDetailScreenProps = {
|
||||
/** Start focused on content when embedded beside workspace navigation. */
|
||||
initialPanelCollapsed?: boolean;
|
||||
commitHash?: string;
|
||||
entityNavigationId?: string;
|
||||
filePath?: string;
|
||||
|
||||
@@ -33,8 +33,13 @@ function initialMode(): ProjectRightPanelMode {
|
||||
}
|
||||
}
|
||||
|
||||
export function useProjectRepositoryPanel(ownerKey: string) {
|
||||
const [collapsed, setCollapsedState] = React.useState(initialCollapsed);
|
||||
export function useProjectRepositoryPanel(
|
||||
ownerKey: string,
|
||||
initialPanelCollapsed?: boolean,
|
||||
) {
|
||||
const [collapsed, setCollapsedState] = React.useState(
|
||||
() => initialPanelCollapsed ?? initialCollapsed(),
|
||||
);
|
||||
const [selectionAgentContext, setSelectionAgentContext] = React.useState<{
|
||||
context: ProjectDetailAgentContext;
|
||||
ownerKey: string;
|
||||
|
||||
@@ -11,6 +11,7 @@ export function isPulseWorkspacePage(
|
||||
|
||||
export const PULSE_WORKSPACE_KEYS = [
|
||||
"projectId",
|
||||
"projectSection",
|
||||
"workflowId",
|
||||
"view",
|
||||
"pane",
|
||||
|
||||
150
desktop/src/features/pulse/ui/PulseProjectsView.tsx
Normal file
150
desktop/src/features/pulse/ui/PulseProjectsView.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as React from "react";
|
||||
import { Activity, Folders } from "lucide-react";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { allowNavigation } from "@/app/navigation/navigationGuard";
|
||||
import { useProjectsQuery } from "@/features/projects/hooks";
|
||||
import type { ProjectsFilter } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import { isExplicitProject } from "@/features/projects/projectModels";
|
||||
import { ProjectBreadcrumbVisibilityContext } from "@/features/projects/ui/ProjectDetailChrome";
|
||||
import { ProjectsScreen } from "@/features/projects/ui/ProjectsScreen";
|
||||
import {
|
||||
projectsSectionIcon,
|
||||
projectsSectionTitle,
|
||||
} from "@/features/projects/ui/projectsSectionMeta";
|
||||
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
import {
|
||||
CLEAR_WORKSPACE_PANELS,
|
||||
PULSE_WORKSPACE_KEYS,
|
||||
} from "../lib/workspaceNavigation";
|
||||
import { CLEAR_CONVERSATION_PANELS } from "../lib/pulsePanelState";
|
||||
|
||||
const sections = [
|
||||
"all",
|
||||
"projects",
|
||||
"repositories",
|
||||
"issues",
|
||||
"prs",
|
||||
"channels",
|
||||
] as const;
|
||||
const searchKeys = PULSE_WORKSPACE_KEYS;
|
||||
|
||||
/** Keep project navigation visible while category content or a project is open. */
|
||||
export function PulseProjectsView({ detail }: { detail?: React.ReactNode }) {
|
||||
const { values, applyPatch } = useHistorySearchState(searchKeys);
|
||||
const query = useProjectsQuery();
|
||||
const { goProject } = useAppNavigation();
|
||||
const section =
|
||||
sections.find((section) => section === values.projectSection) ?? "all";
|
||||
const projects = React.useMemo(
|
||||
() =>
|
||||
(query.data ?? [])
|
||||
.filter(isExplicitProject)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[query.data],
|
||||
);
|
||||
const selectSection = (next: ProjectsFilter) => {
|
||||
if (
|
||||
!allowNavigation({
|
||||
kind: "route",
|
||||
href: `/pulse?feed=projects&projectSection=${next}`,
|
||||
})
|
||||
)
|
||||
return;
|
||||
applyPatch({
|
||||
...CLEAR_CONVERSATION_PANELS,
|
||||
...CLEAR_WORKSPACE_PANELS,
|
||||
projectSection: next,
|
||||
});
|
||||
};
|
||||
const rowClass = (active: boolean) =>
|
||||
cn(
|
||||
"mb-1 flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left text-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
active ? "bg-muted/70 font-semibold" : "hover:bg-muted/35",
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 min-w-0 flex-1"
|
||||
data-testid="pulse-projects-split"
|
||||
>
|
||||
<nav
|
||||
aria-label="Project navigation"
|
||||
data-testid="pulse-projects-list"
|
||||
className="w-[220px] min-w-0 shrink-0 overflow-y-auto border-r border-border/50 p-2"
|
||||
>
|
||||
{sections.map((item) => {
|
||||
const Icon = item === "all" ? Activity : projectsSectionIcon(item);
|
||||
const active = !values.projectId && section === item;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={item}
|
||||
aria-current={active ? "page" : undefined}
|
||||
data-testid={`projects-section-${item === "all" ? "activity" : item}`}
|
||||
onClick={() => selectSection(item)}
|
||||
className={rowClass(active)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
{projectsSectionTitle(item)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div aria-hidden="true" className="my-2 border-t border-border/40" />
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
aria-label={`Open project ${project.name}`}
|
||||
aria-current={values.projectId === project.id ? "page" : undefined}
|
||||
data-project-id={project.id}
|
||||
onClick={() => void goProject(project.id)}
|
||||
className={rowClass(values.projectId === project.id)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"
|
||||
>
|
||||
<Folders className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{project.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{query.isLoading && (
|
||||
<p role="status" className="px-3 py-4 text-xs text-muted-foreground">
|
||||
Loading projects…
|
||||
</p>
|
||||
)}
|
||||
{query.isError && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void query.refetch()}
|
||||
className="px-3 py-4 text-sm text-muted-foreground"
|
||||
>
|
||||
Retry loading projects
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
<div
|
||||
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
|
||||
data-testid="pulse-projects-detail"
|
||||
>
|
||||
<ProjectBreadcrumbVisibilityContext.Provider value={false}>
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="projects" />}>
|
||||
{detail ?? (
|
||||
<ProjectsScreen
|
||||
section={section}
|
||||
onSectionChange={selectSection}
|
||||
/>
|
||||
)}
|
||||
</React.Suspense>
|
||||
</ProjectBreadcrumbVisibilityContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
workspaceNavigationTarget,
|
||||
} from "../lib/workspaceNavigation";
|
||||
|
||||
const ProjectsScreen = React.lazy(() =>
|
||||
import("@/features/projects/ui/ProjectsScreen").then((m) => ({
|
||||
default: m.ProjectsScreen,
|
||||
})),
|
||||
const PulseProjectsView = React.lazy(() =>
|
||||
import("./PulseProjectsView").then((m) => ({ default: m.PulseProjectsView })),
|
||||
);
|
||||
const ProjectDetailScreen = React.lazy(() =>
|
||||
import("@/features/projects/ui/ProjectDetailScreen").then((m) => ({
|
||||
@@ -90,15 +88,18 @@ export function PulseWorkspacePage({ page }: { page: WorkspacePage }) {
|
||||
<MainInsetProvider mainInsetRef={mainRef}>
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind={page} />}>
|
||||
{page === "projects" ? (
|
||||
values.projectId ? (
|
||||
<ProjectDetailScreen
|
||||
{...parseProjectDetailSearch(values)}
|
||||
projectId={values.projectId}
|
||||
entityNavigationId={state.entityNavigationId}
|
||||
/>
|
||||
) : (
|
||||
<ProjectsScreen />
|
||||
)
|
||||
<PulseProjectsView
|
||||
detail={
|
||||
values.projectId ? (
|
||||
<ProjectDetailScreen
|
||||
{...parseProjectDetailSearch(values)}
|
||||
initialPanelCollapsed
|
||||
projectId={values.projectId}
|
||||
entityNavigationId={state.entityNavigationId}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : page === "agents" ? (
|
||||
<AgentsScreen />
|
||||
) : (
|
||||
|
||||
@@ -1462,6 +1462,42 @@ test("bubbles follow the theme and anchor reactions and hover controls without r
|
||||
channelName: "alice-tyler",
|
||||
}),
|
||||
);
|
||||
const linkIds = await page.evaluate(
|
||||
({ alice }) => {
|
||||
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||||
if (!emit) throw new Error("Mock emitter missing");
|
||||
return [alice, undefined].map(
|
||||
(pubkey) =>
|
||||
emit({
|
||||
channelName: "alice-tyler",
|
||||
content: "https://example.com/accessible-link",
|
||||
pubkey,
|
||||
}).id,
|
||||
);
|
||||
},
|
||||
{ alice: TEST_IDENTITIES.alice.pubkey },
|
||||
);
|
||||
for (const id of linkIds) {
|
||||
const message = page
|
||||
.getByTestId("pulse-combined-detail")
|
||||
.getByTestId("message-row")
|
||||
.filter({ has: page.getByTestId(`message-action-bar-${id}`) });
|
||||
await message.scrollIntoViewIfNeeded();
|
||||
await message.hover();
|
||||
const bar = message
|
||||
.getByTestId(`message-action-bar-${id}`)
|
||||
.locator(":scope > div");
|
||||
await expect(bar).toBeVisible();
|
||||
const bubbleBox = await boundsOf(message.getByTestId("message-body"));
|
||||
const barBox = await boundsOf(bar);
|
||||
expect(barBox.y + barBox.height - bubbleBox.y).toBeCloseTo(4, 0);
|
||||
await message
|
||||
.getByRole("link", {
|
||||
name: "https://example.com/accessible-link",
|
||||
exact: true,
|
||||
})
|
||||
.click({ trial: true });
|
||||
}
|
||||
await page.evaluate(
|
||||
({ alice }) => {
|
||||
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||||
@@ -1898,13 +1934,66 @@ test("workspace entrypoints keep projects, agents, and workflows in the main pan
|
||||
await expect(editor).toHaveCount(0);
|
||||
await expect(main.getByTestId("workflows-view")).toBeVisible();
|
||||
await apps.getByRole("button", { name: "Projects", exact: true }).click();
|
||||
await main.getByTestId("projects-section-projects").click();
|
||||
const projectsRail = main.getByTestId("pulse-projects-list");
|
||||
await expect(projectsRail).toBeVisible();
|
||||
await expect(projectsRail).toHaveCSS("width", "220px");
|
||||
for (const section of [
|
||||
"repositories",
|
||||
"issues",
|
||||
"prs",
|
||||
"channels",
|
||||
"activity",
|
||||
]) {
|
||||
const row = projectsRail.getByTestId(`projects-section-${section}`);
|
||||
await row.click();
|
||||
await expect(row).toHaveAttribute("aria-current", "page");
|
||||
await expect(main.getByTestId("projects-panel-toolbar")).toBeVisible();
|
||||
}
|
||||
await expect(main.getByTestId("projects-page-tabs")).toHaveCount(0);
|
||||
await expect(main.getByTestId("projects-overview-context-rail")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await projectsRail.getByTestId("projects-section-projects").click();
|
||||
await expect(
|
||||
projectsRail.getByTestId("projects-section-projects"),
|
||||
).toHaveAttribute("aria-current", "page");
|
||||
const search = main.getByRole("searchbox", {
|
||||
name: "Search Projects",
|
||||
exact: true,
|
||||
});
|
||||
await search.fill("no-such-project");
|
||||
await expect(
|
||||
main.locator(
|
||||
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
||||
),
|
||||
).toHaveCount(0);
|
||||
await search.clear();
|
||||
await main
|
||||
.getByRole("button", { name: "Create project", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
const project = main.locator(
|
||||
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
||||
);
|
||||
await expect(project).toBeVisible();
|
||||
await project.click();
|
||||
await expect(page).toHaveURL(/#\/pulse\?.*projectId=/);
|
||||
await expect(projectsRail).toBeVisible();
|
||||
await expect(
|
||||
projectsRail.getByRole("button", {
|
||||
name: "Open project buzz",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveAttribute("aria-current", "page");
|
||||
await waitForAnimations(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Show Overview", exact: true }),
|
||||
).toBeVisible();
|
||||
await main.screenshot({
|
||||
path: "test-results/pulse-prototype/project-detail-split.png",
|
||||
});
|
||||
await expect(main.getByTestId("pulse-workspace-projects")).toBeVisible();
|
||||
await expect(apps).toBeVisible();
|
||||
await page.goBack();
|
||||
|
||||
Reference in New Issue
Block a user