From db901929daa4572d840ceeb4a37f160ef0c7b712 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Thu, 10 Sep 2026 10:25:48 -0700 Subject: [PATCH] Refine Pulse project navigation and message action placement Signed-off-by: Arjun Mahanti --- desktop/src/app/routes/pulse.tsx | 2 + .../messages/ui/MessageBubbleLayout.tsx | 4 +- .../projects/ui/ProjectChannelHome.tsx | 4 +- .../projects/ui/ProjectDetailChrome.tsx | 138 ++++++++-------- .../projects/ui/ProjectDetailScreen.tsx | 2 + .../projects/ui/ProjectsPanelToolbar.tsx | 78 +++++++++ .../features/projects/ui/ProjectsScreen.tsx | 11 +- .../src/features/projects/ui/ProjectsView.tsx | 146 ++++++++++++----- .../projects/ui/projectDetailHelpers.ts | 2 + .../projects/ui/useProjectRepositoryPanel.ts | 9 +- .../features/pulse/lib/workspaceNavigation.ts | 1 + .../features/pulse/ui/PulseProjectsView.tsx | 150 ++++++++++++++++++ .../features/pulse/ui/PulseWorkspacePage.tsx | 27 ++-- desktop/tests/e2e/pulse-unified.spec.ts | 91 ++++++++++- 14 files changed, 540 insertions(+), 125 deletions(-) create mode 100644 desktop/src/features/projects/ui/ProjectsPanelToolbar.tsx create mode 100644 desktop/src/features/pulse/ui/PulseProjectsView.tsx diff --git a/desktop/src/app/routes/pulse.tsx b/desktop/src/app/routes/pulse.tsx index e9458b3739..1b4697bad0 100644 --- a/desktop/src/app/routes/pulse.tsx +++ b/desktop/src/app/routes/pulse.tsx @@ -22,6 +22,7 @@ const PulseScreen = React.lazy(async () => { type PulseRouteSearch = ReturnType & { 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: diff --git a/desktop/src/features/messages/ui/MessageBubbleLayout.tsx b/desktop/src/features/messages/ui/MessageBubbleLayout.tsx index 708fa1d18c..2178bf36bf 100644 --- a/desktop/src/features/messages/ui/MessageBubbleLayout.tsx +++ b/desktop/src/features/messages/ui/MessageBubbleLayout.tsx @@ -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} diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx index dc526b1f0e..11a8c86bde 100644 --- a/desktop/src/features/projects/ui/ProjectChannelHome.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -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(null); diff --git a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx index 0293d1b2b9..198b146b39 100644 --- a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx @@ -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" > - + + )} + + )}
{actions}
@@ -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" > - + {onActivity ? ( + + + Projects + + ) : ( + + )} + + + {section} + + + )}
{actions}
diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 78c236e676..1b6a73f8c6 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -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 ( 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 ( +
+ + {filter !== "all" && filter !== "channels" && ( + + )} + +
+ ); +} + +/** Keep selection actions available without the redundant context rail. */ +export function ProjectsPanelSelection({ children }: { children: ReactNode }) { + const selection = useProjectSelection(); + return selection?.items.length ? children : null; +} diff --git a/desktop/src/features/projects/ui/ProjectsScreen.tsx b/desktop/src/features/projects/ui/ProjectsScreen.tsx index bb84804e8e..4f34ef511d 100644 --- a/desktop/src/features/projects/ui/ProjectsScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectsScreen.tsx @@ -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 (
- +
); } diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index f79cf910cb..cf72c90c66 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -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(() => { + const [storedFilter, setFilter] = React.useState(() => { 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 ? ( + + setSelectionAgentContext((context) => + context ? null : overviewAgentContext, + ) + } + sectionTitle={projectsSectionTitle(filter)} + /> + ) : isNarrowProjectsLayout ? ( setNarrowContextOpen((open) => !open)} open={contextOpen} @@ -721,11 +746,14 @@ export function ProjectsView() {
@@ -737,9 +765,11 @@ export function ProjectsView() {
- + {embedded ? ( + 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); + }} + /> + ) : ( + + )}
+ {embedded && ( + + + + )}
) : null} - - - - {isNarrowProjectsLayout ? ( + + + )} + {!embedded && isNarrowProjectsLayout ? ( { // Return focus to the chrome toggle so the keyboard journey can diff --git a/desktop/src/features/projects/ui/projectDetailHelpers.ts b/desktop/src/features/projects/ui/projectDetailHelpers.ts index 6a0630127d..b95a0d8a8d 100644 --- a/desktop/src/features/projects/ui/projectDetailHelpers.ts +++ b/desktop/src/features/projects/ui/projectDetailHelpers.ts @@ -23,6 +23,8 @@ export const PROJECT_TAB_CRUMB_LABELS: Record = { }; export type ProjectDetailScreenProps = { + /** Start focused on content when embedded beside workspace navigation. */ + initialPanelCollapsed?: boolean; commitHash?: string; entityNavigationId?: string; filePath?: string; diff --git a/desktop/src/features/projects/ui/useProjectRepositoryPanel.ts b/desktop/src/features/projects/ui/useProjectRepositoryPanel.ts index 2589f36209..14d32a29a9 100644 --- a/desktop/src/features/projects/ui/useProjectRepositoryPanel.ts +++ b/desktop/src/features/projects/ui/useProjectRepositoryPanel.ts @@ -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; diff --git a/desktop/src/features/pulse/lib/workspaceNavigation.ts b/desktop/src/features/pulse/lib/workspaceNavigation.ts index 851474c70b..51b2a59236 100644 --- a/desktop/src/features/pulse/lib/workspaceNavigation.ts +++ b/desktop/src/features/pulse/lib/workspaceNavigation.ts @@ -11,6 +11,7 @@ export function isPulseWorkspacePage( export const PULSE_WORKSPACE_KEYS = [ "projectId", + "projectSection", "workflowId", "view", "pane", diff --git a/desktop/src/features/pulse/ui/PulseProjectsView.tsx b/desktop/src/features/pulse/ui/PulseProjectsView.tsx new file mode 100644 index 0000000000..62e534998a --- /dev/null +++ b/desktop/src/features/pulse/ui/PulseProjectsView.tsx @@ -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 ( +
+ +
+ + }> + {detail ?? ( + + )} + + +
+
+ ); +} diff --git a/desktop/src/features/pulse/ui/PulseWorkspacePage.tsx b/desktop/src/features/pulse/ui/PulseWorkspacePage.tsx index 7cee7eefae..62ca853b61 100644 --- a/desktop/src/features/pulse/ui/PulseWorkspacePage.tsx +++ b/desktop/src/features/pulse/ui/PulseWorkspacePage.tsx @@ -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 }) { }> {page === "projects" ? ( - values.projectId ? ( - - ) : ( - - ) + + ) : undefined + } + /> ) : page === "agents" ? ( ) : ( diff --git a/desktop/tests/e2e/pulse-unified.spec.ts b/desktop/tests/e2e/pulse-unified.spec.ts index e5b91fc947..d831eb33ca 100644 --- a/desktop/tests/e2e/pulse-unified.spec.ts +++ b/desktop/tests/e2e/pulse-unified.spec.ts @@ -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();