Refine Pulse project navigation and message action placement

Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-09-10 10:25:48 -07:00
parent 0bff71ab16
commit db901929da
14 changed files with 540 additions and 125 deletions

View File

@@ -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:

View File

@@ -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}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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]}

View 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;
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -11,6 +11,7 @@ export function isPulseWorkspacePage(
export const PULSE_WORKSPACE_KEYS = [
"projectId",
"projectSection",
"workflowId",
"view",
"pane",

View 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>
);
}

View File

@@ -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 />
) : (

View File

@@ -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();